Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
commitaee0a117a889461ce8ced6fcf73207fe017f1d99 (patch)
tree891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/editor
parent8d46af3258650d305f53b819eabf7ab18d22f59e (diff)
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/editor')
-rw-r--r--app/assets/javascripts/editor/constants.js4
-rw-r--r--app/assets/javascripts/editor/extensions/example_source_editor_extension.js14
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js47
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js111
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js16
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js330
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js167
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_webide_ext.js272
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js279
-rw-r--r--app/assets/javascripts/editor/source_editor.js179
-rw-r--r--app/assets/javascripts/editor/source_editor_extension.js2
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js76
12 files changed, 759 insertions, 738 deletions
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index e855e304d27..2ae9c377683 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__(
// EXTENSIONS' CONSTANTS
//
+// Source Editor Base Extension
+export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor';
+export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers';
+
// For CI config schemas the filename must match
// '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
index 119a2aea9eb..52e2bb0b5ff 100644
--- a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
+++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
@@ -7,6 +7,16 @@
export class MyFancyExtension {
/**
+ * A required getter returning the extension's name
+ * We have to provide it for every extension instead of relying on the built-in
+ * `name` prop because the prop does not survive the webpack's minification
+ * and the name mangling.
+ * @returns {string}
+ */
+ static get extensionName() {
+ return 'MyFancyExtension';
+ }
+ /**
* THE LIFE-CYCLE CALLBACKS
*/
@@ -16,11 +26,11 @@ export class MyFancyExtension {
* actions, keystrokes, update options, etc.
* Is called only once before the extension gets registered
*
- * @param { Object } [setupOptions] The setupOptions object
* @param { Object } [instance] The Source Editor instance
+ * @param { Object } [setupOptions] The setupOptions object
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
- onSetup(setupOptions, instance) {}
+ onSetup(instance, setupOptions) {}
/**
* The first thing called after the extension is
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 7069568275d..0290bb84b5f 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -1,32 +1,27 @@
import ciSchemaPath from '~/editor/schema/ci.json';
import { registerSchema } from '~/ide/utils';
-import { SourceEditorExtension } from './source_editor_extension_base';
-export class CiSchemaExtension extends SourceEditorExtension {
- /**
- * Registers a syntax schema to the editor based on project
- * identifier and commit.
- *
- * The schema is added to the file that is currently edited
- * in the editor.
- *
- * @param {Object} opts
- * @param {String} opts.projectNamespace
- * @param {String} opts.projectPath
- * @param {String?} opts.ref - Current ref. Defaults to main
- */
- registerCiSchema() {
- // In order for workers loaded from `data://` as the
- // ones loaded by monaco editor, we use absolute URLs
- // to fetch schema files, hence the `gon.gitlab_url`
- // reference. This prevents error:
- // "Failed to execute 'fetch' on 'WorkerGlobalScope'"
- const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
- const modelFileName = this.getModel().uri.path.split('/').pop();
+export class CiSchemaExtension {
+ static get extensionName() {
+ return 'CiSchema';
+ }
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ registerCiSchema: (instance) => {
+ // In order for workers loaded from `data://` as the
+ // ones loaded by monaco editor, we use absolute URLs
+ // to fetch schema files, hence the `gon.gitlab_url`
+ // reference. This prevents error:
+ // "Failed to execute 'fetch' on 'WorkerGlobalScope'"
+ const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
+ const modelFileName = instance.getModel().uri.path.split('/').pop();
- registerSchema({
- uri: absoluteSchemaUrl,
- fileMatch: [modelFileName],
- });
+ registerSchema({
+ uri: absoluteSchemaUrl,
+ fileMatch: [modelFileName],
+ });
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 03c68fed3b1..3aa19df964c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -1,13 +1,16 @@
import { Range } from 'monaco-editor';
-import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
-import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
+import {
+ EDITOR_TYPE_CODE,
+ EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
+ EXTENSION_BASE_LINE_NUMBERS_CLASS,
+} from '../constants';
const hashRegexp = new RegExp('#?L', 'g');
const createAnchor = (href) => {
const fragment = new DocumentFragment();
const el = document.createElement('a');
- el.classList.add('link-anchor');
+ el.classList.add(EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS);
el.href = href;
fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => {
@@ -17,38 +20,46 @@ const createAnchor = (href) => {
};
export class SourceEditorExtension {
- constructor({ instance, ...options } = {}) {
- if (instance) {
- Object.assign(instance, options);
- SourceEditorExtension.highlightLines(instance);
- if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
- SourceEditorExtension.setupLineLinking(instance);
- }
- SourceEditorExtension.deferRerender(instance);
- } else if (Object.entries(options).length) {
- throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
+ static get extensionName() {
+ return 'BaseExtension';
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ onUse(instance) {
+ SourceEditorExtension.highlightLines(instance);
+ if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
+ SourceEditorExtension.setupLineLinking(instance);
}
}
- static deferRerender(instance) {
- waitForCSSLoaded(() => {
- instance.layout();
- });
+ static onMouseMoveHandler(e) {
+ const target = e.target.element;
+ if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) {
+ const lineNum = e.target.position.lineNumber;
+ const hrefAttr = `#L${lineNum}`;
+ let lineLink = target.querySelector('a');
+ if (!lineLink) {
+ lineLink = createAnchor(hrefAttr);
+ target.appendChild(lineLink);
+ }
+ }
}
- static removeHighlights(instance) {
- Object.assign(instance, {
- lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
+ static setupLineLinking(instance) {
+ instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
+ instance.onMouseDown((e) => {
+ const isCorrectAnchor = e.target.element.classList.contains(
+ EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
+ );
+ if (!isCorrectAnchor) {
+ return;
+ }
+ if (instance.lineDecorations) {
+ instance.deltaDecorations(instance.lineDecorations, []);
+ }
});
}
- /**
- * Returns a function that can only be invoked once between
- * each browser screen repaint.
- * @param {Object} instance - The Source Editor instance
- * @param {Array} bounds - The [start, end] array with start
- * and end coordinates for highlighting
- */
static highlightLines(instance, bounds = null) {
const [start, end] =
bounds && Array.isArray(bounds)
@@ -74,29 +85,29 @@ export class SourceEditorExtension {
}
}
- static onMouseMoveHandler(e) {
- const target = e.target.element;
- if (target.classList.contains('line-numbers')) {
- const lineNum = e.target.position.lineNumber;
- const hrefAttr = `#L${lineNum}`;
- let el = target.querySelector('a');
- if (!el) {
- el = createAnchor(hrefAttr);
- target.appendChild(el);
- }
- }
- }
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ /**
+ * Removes existing line decorations and updates the reference on the instance
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ */
+ removeHighlights: (instance) => {
+ Object.assign(instance, {
+ lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
+ });
+ },
- static setupLineLinking(instance) {
- instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
- instance.onMouseDown((e) => {
- const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
- if (!isCorrectAnchor) {
- return;
- }
- if (instance.lineDecorations) {
- instance.deltaDecorations(instance.lineDecorations, []);
- }
- });
+ /**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Array} bounds - The [start, end] array with start
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * and end coordinates for highlighting
+ */
+ highlightLines(instance, bounds = null) {
+ SourceEditorExtension.highlightLines(instance, bounds);
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
index 397e090ed30..ba4980896e5 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
@@ -1,8 +1,16 @@
import { Position } from 'monaco-editor';
-import { SourceEditorExtension } from './source_editor_extension_base';
-export class FileTemplateExtension extends SourceEditorExtension {
- navigateFileStart() {
- this.setPosition(new Position(1, 1));
+export class FileTemplateExtension {
+ static get extensionName() {
+ return 'FileTemplate';
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ navigateFileStart: (instance) => {
+ instance.setPosition(new Position(1, 1));
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 57de21c933e..a16fe93026e 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -1,248 +1,102 @@
-import { debounce } from 'lodash';
-import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
-import createFlash from '~/flash';
-import { sanitize } from '~/lib/dompurify';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import syntaxHighlight from '~/syntax_highlight';
-import {
- EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
- EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
- EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
- EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
-} from '../constants';
-import { SourceEditorExtension } from './source_editor_extension_base';
-
-const getPreview = (text, previewMarkdownPath) => {
- return axios
- .post(previewMarkdownPath, {
- text,
- })
- .then(({ data }) => {
- return data.body;
- });
-};
-
-const setupDomElement = ({ injectToEl = null } = {}) => {
- const previewEl = document.createElement('div');
- previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
- previewEl.style.display = 'none';
- if (injectToEl) {
- injectToEl.appendChild(previewEl);
+export class EditorMarkdownExtension {
+ static get extensionName() {
+ return 'EditorMarkdown';
}
- return previewEl;
-};
-export class EditorMarkdownExtension extends SourceEditorExtension {
- constructor({ instance, previewMarkdownPath, ...args } = {}) {
- super({ instance, ...args });
- Object.assign(instance, {
- previewMarkdownPath,
- preview: {
- el: undefined,
- action: undefined,
- shown: false,
- modelChangeListener: undefined,
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ getSelectedText: (instance, selection = instance.getSelection()) => {
+ const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
+ const valArray = instance.getValue().split('\n');
+ let text = '';
+ if (startLineNumber === endLineNumber) {
+ text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
+ } else {
+ const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
+ const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
+
+ for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
+ text += `${valArray[i]}`;
+ if (i !== k - 1) text += `\n`;
+ }
+ text = text
+ ? [startLineText, text, endLineText].join('\n')
+ : [startLineText, endLineText].join('\n');
+ }
+ return text;
},
- });
- this.setupPreviewAction.call(instance);
-
- instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
- if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
- instance.setupPreviewAction();
- } else {
- instance.cleanup();
- }
- });
-
- instance.onDidChangeModel(() => {
- const model = instance.getModel();
- if (model) {
- const { language } = model.getLanguageIdentifier();
- instance.cleanup();
- if (language === 'markdown') {
- instance.setupPreviewAction();
+ replaceSelectedText: (instance, text, select) => {
+ const forceMoveMarkers = !select;
+ instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers }]);
+ },
+ moveCursor: (instance, dx = 0, dy = 0) => {
+ const pos = instance.getPosition();
+ pos.column += dx;
+ pos.lineNumber += dy;
+ instance.setPosition(pos);
+ },
+ /**
+ * Adjust existing selection to select text within the original selection.
+ * - If `selectedText` is not supplied, we fetch selected text with
+ *
+ * ALGORITHM:
+ *
+ * MULTI-LINE SELECTION
+ * 1. Find line that contains `toSelect` text.
+ * 2. Using the index of this line and the position of `toSelect` text in it,
+ * construct:
+ * * newStartLineNumber
+ * * newStartColumn
+ *
+ * SINGLE-LINE SELECTION
+ * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
+ * 2. Find the position of `toSelect` text in it to get `newStartColumn`
+ *
+ * 3. `newEndLineNumber` — Since this method is supposed to be used with
+ * markdown decorators that are pretty short, the `newEndLineNumber` is
+ * suggested to be assumed the same as the startLine.
+ * 4. `newEndColumn` — pretty obvious
+ * 5. Adjust the start and end positions of the current selection
+ * 6. Re-set selection on the instance
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance. Is passed automatically.
+ * @param {string} toSelect - New text to select within current selection.
+ * @param {string} selectedText - Currently selected text. It's just a
+ * shortcut: If it's not supplied, we fetch selected text from the instance
+ */
+ selectWithinSelection: (instance, toSelect, selectedText) => {
+ const currentSelection = instance.getSelection();
+ if (currentSelection.isEmpty() || !toSelect) {
+ return;
+ }
+ const text = selectedText || instance.getSelectedText(currentSelection);
+ let lineShift;
+ let newStartLineNumber;
+ let newStartColumn;
+
+ const textLines = text.split('\n');
+
+ if (textLines.length > 1) {
+ // Multi-line selection
+ lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1);
+ newStartLineNumber = currentSelection.startLineNumber + lineShift;
+ newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
+ } else {
+ // Single-line selection
+ newStartLineNumber = currentSelection.startLineNumber;
+ newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
}
- }
- });
- }
-
- static togglePreviewLayout() {
- const { width, height } = this.getLayoutInfo();
- const newWidth = this.preview.shown
- ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
- : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- this.layout({ width: newWidth, height });
- }
-
- static togglePreviewPanel() {
- const parentEl = this.getDomNode().parentElement;
- const { el: previewEl } = this.preview;
- parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
-
- if (previewEl.style.display === 'none') {
- // Show the preview panel
- this.fetchPreview();
- } else {
- // Hide the preview panel
- previewEl.style.display = 'none';
- }
- }
-
- cleanup() {
- if (this.preview.modelChangeListener) {
- this.preview.modelChangeListener.dispose();
- }
- this.preview.action.dispose();
- if (this.preview.shown) {
- EditorMarkdownExtension.togglePreviewPanel.call(this);
- EditorMarkdownExtension.togglePreviewLayout.call(this);
- }
- this.preview.shown = false;
- }
-
- fetchPreview() {
- const { el: previewEl } = this.preview;
- getPreview(this.getValue(), this.previewMarkdownPath)
- .then((data) => {
- previewEl.innerHTML = sanitize(data);
- syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
- previewEl.style.display = 'block';
- })
- .catch(() => createFlash(BLOB_PREVIEW_ERROR));
- }
- setupPreviewAction() {
- if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
+ const newEndLineNumber = newStartLineNumber;
+ const newEndColumn = newStartColumn + toSelect.length;
- this.preview.action = this.addAction({
- id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- label: __('Preview Markdown'),
- keybindings: [
- // eslint-disable-next-line no-bitwise,no-undef
- monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
- ],
- contextMenuGroupId: 'navigation',
- contextMenuOrder: 1.5,
+ const newSelection = currentSelection
+ .setStartPosition(newStartLineNumber, newStartColumn)
+ .setEndPosition(newEndLineNumber, newEndColumn);
- // Method that will be executed when the action is triggered.
- // @param ed The editor instance is passed in as a convenience
- run(instance) {
- instance.togglePreview();
+ instance.setSelection(newSelection);
},
- });
- }
-
- togglePreview() {
- if (!this.preview?.el) {
- this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
- }
- EditorMarkdownExtension.togglePreviewLayout.call(this);
- EditorMarkdownExtension.togglePreviewPanel.call(this);
-
- if (!this.preview?.shown) {
- this.preview.modelChangeListener = this.onDidChangeModelContent(
- debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
- );
- } else {
- this.preview.modelChangeListener.dispose();
- }
-
- this.preview.shown = !this.preview?.shown;
- }
-
- getSelectedText(selection = this.getSelection()) {
- const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
- const valArray = this.getValue().split('\n');
- let text = '';
- if (startLineNumber === endLineNumber) {
- text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
- } else {
- const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
- const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
-
- for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
- text += `${valArray[i]}`;
- if (i !== k - 1) text += `\n`;
- }
- text = text
- ? [startLineText, text, endLineText].join('\n')
- : [startLineText, endLineText].join('\n');
- }
- return text;
- }
-
- replaceSelectedText(text, select = undefined) {
- const forceMoveMarkers = !select;
- this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
- }
-
- moveCursor(dx = 0, dy = 0) {
- const pos = this.getPosition();
- pos.column += dx;
- pos.lineNumber += dy;
- this.setPosition(pos);
- }
-
- /**
- * Adjust existing selection to select text within the original selection.
- * - If `selectedText` is not supplied, we fetch selected text with
- *
- * ALGORITHM:
- *
- * MULTI-LINE SELECTION
- * 1. Find line that contains `toSelect` text.
- * 2. Using the index of this line and the position of `toSelect` text in it,
- * construct:
- * * newStartLineNumber
- * * newStartColumn
- *
- * SINGLE-LINE SELECTION
- * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
- * 2. Find the position of `toSelect` text in it to get `newStartColumn`
- *
- * 3. `newEndLineNumber` — Since this method is supposed to be used with
- * markdown decorators that are pretty short, the `newEndLineNumber` is
- * suggested to be assumed the same as the startLine.
- * 4. `newEndColumn` — pretty obvious
- * 5. Adjust the start and end positions of the current selection
- * 6. Re-set selection on the instance
- *
- * @param {string} toSelect - New text to select within current selection.
- * @param {string} selectedText - Currently selected text. It's just a
- * shortcut: If it's not supplied, we fetch selected text from the instance
- */
- selectWithinSelection(toSelect, selectedText) {
- const currentSelection = this.getSelection();
- if (currentSelection.isEmpty() || !toSelect) {
- return;
- }
- const text = selectedText || this.getSelectedText(currentSelection);
- let lineShift;
- let newStartLineNumber;
- let newStartColumn;
-
- const textLines = text.split('\n');
-
- if (textLines.length > 1) {
- // Multi-line selection
- lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1);
- newStartLineNumber = currentSelection.startLineNumber + lineShift;
- newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
- } else {
- // Single-line selection
- newStartLineNumber = currentSelection.startLineNumber;
- newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
- }
-
- const newEndLineNumber = newStartLineNumber;
- const newEndColumn = newStartColumn + toSelect.length;
-
- const newSelection = currentSelection
- .setStartPosition(newStartLineNumber, newStartColumn)
- .setEndPosition(newEndLineNumber, newEndColumn);
-
- this.setSelection(newSelection);
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
new file mode 100644
index 00000000000..9d53268c340
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -0,0 +1,167 @@
+import { debounce } from 'lodash';
+import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
+import createFlash from '~/flash';
+import { sanitize } from '~/lib/dompurify';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import syntaxHighlight from '~/syntax_highlight';
+import {
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
+} from '../constants';
+
+const fetchPreview = (text, previewMarkdownPath) => {
+ return axios
+ .post(previewMarkdownPath, {
+ text,
+ })
+ .then(({ data }) => {
+ return data.body;
+ });
+};
+
+const setupDomElement = ({ injectToEl = null } = {}) => {
+ const previewEl = document.createElement('div');
+ previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
+ previewEl.style.display = 'none';
+ if (injectToEl) {
+ injectToEl.appendChild(previewEl);
+ }
+ return previewEl;
+};
+
+export class EditorMarkdownPreviewExtension {
+ static get extensionName() {
+ return 'EditorMarkdownPreview';
+ }
+
+ onSetup(instance, setupOptions) {
+ this.preview = {
+ el: undefined,
+ action: undefined,
+ shown: false,
+ modelChangeListener: undefined,
+ path: setupOptions.previewMarkdownPath,
+ };
+ this.setupPreviewAction(instance);
+
+ instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
+ if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
+ instance.setupPreviewAction();
+ } else {
+ instance.cleanup();
+ }
+ });
+
+ instance.onDidChangeModel(() => {
+ const model = instance.getModel();
+ if (model) {
+ const { language } = model.getLanguageIdentifier();
+ instance.cleanup();
+ if (language === 'markdown') {
+ instance.setupPreviewAction();
+ }
+ }
+ });
+ }
+
+ togglePreviewLayout(instance) {
+ const { width, height } = instance.getLayoutInfo();
+ const newWidth = this.preview.shown
+ ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
+ : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ instance.layout({ width: newWidth, height });
+ }
+
+ togglePreviewPanel(instance) {
+ const parentEl = instance.getDomNode().parentElement;
+ const { el: previewEl } = this.preview;
+ parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
+
+ if (previewEl.style.display === 'none') {
+ // Show the preview panel
+ this.fetchPreview(instance);
+ } else {
+ // Hide the preview panel
+ previewEl.style.display = 'none';
+ }
+ }
+
+ fetchPreview(instance) {
+ const { el: previewEl } = this.preview;
+ fetchPreview(instance.getValue(), this.preview.path)
+ .then((data) => {
+ previewEl.innerHTML = sanitize(data);
+ syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
+ previewEl.style.display = 'block';
+ })
+ .catch(() => createFlash(BLOB_PREVIEW_ERROR));
+ }
+
+ setupPreviewAction(instance) {
+ if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
+
+ this.preview.action = instance.addAction({
+ id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ label: __('Preview Markdown'),
+ keybindings: [
+ // eslint-disable-next-line no-bitwise,no-undef
+ monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
+ ],
+ contextMenuGroupId: 'navigation',
+ contextMenuOrder: 1.5,
+
+ // Method that will be executed when the action is triggered.
+ // @param ed The editor instance is passed in as a convenience
+ run(inst) {
+ inst.togglePreview();
+ },
+ });
+ }
+
+ provides() {
+ return {
+ markdownPreview: this.preview,
+
+ cleanup: (instance) => {
+ if (this.preview.modelChangeListener) {
+ this.preview.modelChangeListener.dispose();
+ }
+ this.preview.action.dispose();
+ if (this.preview.shown) {
+ this.togglePreviewPanel(instance);
+ this.togglePreviewLayout(instance);
+ }
+ this.preview.shown = false;
+ },
+
+ fetchPreview: (instance) => this.fetchPreview(instance),
+
+ setupPreviewAction: (instance) => this.setupPreviewAction(instance),
+
+ togglePreview: (instance) => {
+ if (!this.preview?.el) {
+ this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement });
+ }
+ this.togglePreviewLayout(instance);
+ this.togglePreviewPanel(instance);
+
+ if (!this.preview?.shown) {
+ this.preview.modelChangeListener = instance.onDidChangeModelContent(
+ debounce(
+ this.fetchPreview.bind(this, instance),
+ EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
+ ),
+ );
+ } else {
+ this.preview.modelChangeListener.dispose();
+ }
+
+ this.preview.shown = !this.preview?.shown;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
index 98e05489c1c..4e8c11bac54 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
@@ -1,7 +1,15 @@
+/**
+ * A WebIDE Extension options for Source Editor
+ * @typedef {Object} WebIDEExtensionOptions
+ * @property {Object} modelManager The root manager for WebIDE models
+ * @property {Object} store The state store for communication
+ * @property {Object} file
+ * @property {Object} options The Monaco editor options
+ */
+
import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
@@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => {
};
export const UPDATE_DIMENSIONS_DELAY = 200;
+const defaultOptions = {
+ modelManager: undefined,
+ store: undefined,
+ file: undefined,
+ options: {},
+};
-export class EditorWebIdeExtension extends SourceEditorExtension {
- constructor({ instance, modelManager, ...options } = {}) {
- super({
- instance,
- ...options,
- modelManager,
- disposable: new Disposable(),
- debouncedUpdate: debounce(() => {
- instance.updateDimensions();
- }, UPDATE_DIMENSIONS_DELAY),
- });
-
- window.addEventListener('resize', instance.debouncedUpdate, false);
-
- instance.onDidDispose(() => {
- window.removeEventListener('resize', instance.debouncedUpdate);
-
- // catch any potential errors with disposing the error
- // this is mainly for tests caused by elements not existing
- try {
- instance.disposable.dispose();
- } catch (e) {
- if (process.env.NODE_ENV !== 'test') {
- // eslint-disable-next-line no-console
- console.error(e);
- }
- }
- });
+const addActions = (instance, store) => {
+ const getKeyCode = (key) => {
+ const monacoKeyMod = key.indexOf('KEY_') === 0;
- EditorWebIdeExtension.addActions(instance);
- }
+ return monacoKeyMod ? KeyCode[key] : KeyMod[key];
+ };
- static addActions(instance) {
- const { store } = instance;
- const getKeyCode = (key) => {
- const monacoKeyMod = key.indexOf('KEY_') === 0;
+ keymap.forEach((command) => {
+ const { bindings, id, label, action } = command;
- return monacoKeyMod ? KeyCode[key] : KeyMod[key];
- };
+ const keybindings = bindings.map((binding) => {
+ const keys = binding.split('+');
- keymap.forEach((command) => {
- const { bindings, id, label, action } = command;
-
- const keybindings = bindings.map((binding) => {
- const keys = binding.split('+');
-
- // eslint-disable-next-line no-bitwise
- return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
- });
-
- instance.addAction({
- id,
- label,
- keybindings,
- run() {
- store.dispatch(action.name, action.params);
- return null;
- },
- });
+ // eslint-disable-next-line no-bitwise
+ return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
- }
-
- createModel(file, head = null) {
- return this.modelManager.addModel(file, head);
- }
-
- attachModel(model) {
- if (isDiffEditorType(this)) {
- this.setModel({
- original: model.getOriginalModel(),
- modified: model.getModel(),
- });
- return;
- }
-
- this.setModel(model.getModel());
+ instance.addAction({
+ id,
+ label,
+ keybindings,
+ run() {
+ store.dispatch(action.name, action.params);
+ return null;
+ },
+ });
+ });
+};
- this.updateOptions(
- editorOptions.reduce((acc, obj) => {
- Object.keys(obj).forEach((key) => {
- Object.assign(acc, {
- [key]: obj[key](model),
- });
- });
- return acc;
- }, {}),
- );
- }
+const renderSideBySide = (domElement) => {
+ return domElement.offsetWidth >= 700;
+};
- attachMergeRequestModel(model) {
- this.setModel({
- original: model.getBaseModel(),
- modified: model.getModel(),
+const updateInstanceDimensions = (instance) => {
+ instance.layout();
+ if (isDiffEditorType(instance)) {
+ instance.updateOptions({
+ renderSideBySide: renderSideBySide(instance.getDomNode()),
});
}
+};
- updateDimensions() {
- this.layout();
- this.updateDiffView();
+export class EditorWebIdeExtension {
+ static get extensionName() {
+ return 'EditorWebIde';
}
- setPos({ lineNumber, column }) {
- this.revealPositionInCenter({
- lineNumber,
- column,
- });
- this.setPosition({
- lineNumber,
- column,
- });
+ /**
+ * Set up the WebIDE extension for Source Editor
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {WebIDEExtensionOptions} setupOptions
+ */
+ onSetup(instance, setupOptions = defaultOptions) {
+ this.modelManager = setupOptions.modelManager;
+ this.store = setupOptions.store;
+ this.file = setupOptions.file;
+ this.options = setupOptions.options;
+
+ this.disposable = new Disposable();
+ this.debouncedUpdate = debounce(() => {
+ updateInstanceDimensions(instance);
+ }, UPDATE_DIMENSIONS_DELAY);
+
+ addActions(instance, setupOptions.store);
}
- onPositionChange(cb) {
- if (!this.onDidChangeCursorPosition) {
- return;
- }
+ onUse(instance) {
+ window.addEventListener('resize', this.debouncedUpdate, false);
- this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
+ instance.onDidDispose(() => {
+ this.onUnuse();
+ });
}
- updateDiffView() {
- if (!isDiffEditorType(this)) {
- return;
+ onUnuse() {
+ window.removeEventListener('resize', this.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
}
-
- this.updateOptions({
- renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
- });
}
- replaceSelectedText(text) {
- let selection = this.getSelection();
- const range = new Range(
- selection.startLineNumber,
- selection.startColumn,
- selection.endLineNumber,
- selection.endColumn,
- );
+ provides() {
+ return {
+ createModel: (instance, file, head = null) => {
+ return this.modelManager.addModel(file, head);
+ },
+ attachModel: (instance, model) => {
+ if (isDiffEditorType(instance)) {
+ instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
- this.executeEdits('', [{ range, text }]);
+ return;
+ }
- selection = this.getSelection();
- this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
- }
+ instance.setModel(model.getModel());
+
+ instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach((key) => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+ },
+ attachMergeRequestModel: (instance, model) => {
+ instance.setModel({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ },
+ updateDimensions: (instance) => updateInstanceDimensions(instance),
+ setPos: (instance, { lineNumber, column }) => {
+ instance.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ instance.setPosition({
+ lineNumber,
+ column,
+ });
+ },
+ onPositionChange: (instance, cb) => {
+ if (typeof instance.onDidChangeCursorPosition !== 'function') {
+ return;
+ }
- static renderSideBySide(domElement) {
- return domElement.offsetWidth >= 700;
+ this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e)));
+ },
+ replaceSelectedText: (instance, text) => {
+ let selection = instance.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ instance.executeEdits('', [{ range, text }]);
+
+ selection = instance.getSelection();
+ instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
index 212e09c8724..05ce617ca7c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
@@ -1,50 +1,46 @@
+/**
+ * A Yaml Editor Extension options for Source Editor
+ * @typedef {Object} YamlEditorExtensionOptions
+ * @property { boolean } enableComments Convert model nodes with the comment
+ * pattern to comments?
+ * @property { string } highlightPath Add a line highlight to the
+ * node specified by this e.g. `"foo.bar[0]"`
+ * @property { * } model Any JS Object that will be stringified and used as the
+ * editor's value. Equivalent to using `setDataModel()`
+ * @property options SourceEditorExtension Options
+ */
+
import { toPath } from 'lodash';
import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml';
import { findPair } from 'yaml/util';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
-export class YamlEditorExtension extends SourceEditorExtension {
+export class YamlEditorExtension {
+ static get extensionName() {
+ return 'YamlEditor';
+ }
+
/**
* Extends the source editor with capabilities for yaml files.
*
- * @param { Instance } instance Source Editor Instance
- * @param { boolean } enableComments Convert model nodes with the comment
- * pattern to comments?
- * @param { string } highlightPath Add a line highlight to the
- * node specified by this e.g. `"foo.bar[0]"`
- * @param { * } model Any JS Object that will be stringified and used as the
- * editor's value. Equivalent to using `setDataModel()`
- * @param options SourceEditorExtension Options
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {YamlEditorExtensionOptions} setupOptions
*/
- constructor({
- instance,
- enableComments = false,
- highlightPath = null,
- model = null,
- ...options
- } = {}) {
- super({
- instance,
- options: {
- ...options,
- enableComments,
- highlightPath,
- },
- });
+ onSetup(instance, setupOptions = {}) {
+ const { enableComments = false, highlightPath = null, model = null } = setupOptions;
+ this.enableComments = enableComments;
+ this.highlightPath = highlightPath;
+ this.model = model;
if (model) {
- YamlEditorExtension.initFromModel(instance, model);
+ this.initFromModel(instance, model);
}
instance.onDidChangeModelContent(() => instance.onUpdate());
}
- /**
- * @private
- */
- static initFromModel(instance, model) {
+ initFromModel(instance, model) {
const doc = new Document(model);
- if (instance.options.enableComments) {
+ if (this.enableComments) {
YamlEditorExtension.transformComments(doc);
}
instance.setValue(doc.toString());
@@ -160,110 +156,13 @@ export class YamlEditorExtension extends SourceEditorExtension {
return doc;
}
- /**
- * Get the editor's value parsed as a `Document` as defined by the `yaml`
- * package
- * @returns {Document}
- */
- getDoc() {
- return parseDocument(this.getValue());
- }
-
- /**
- * Accepts a `Document` as defined by the `yaml` package and
- * sets the Editor's value to a stringified version of it.
- * @param { Document } doc
- */
- setDoc(doc) {
- if (this.options.enableComments) {
- YamlEditorExtension.transformComments(doc);
- }
-
- if (!this.getValue()) {
- this.setValue(doc.toString());
- } else {
- this.updateValue(doc.toString());
- }
- }
-
- /**
- * Returns the parsed value of the Editor's content as JS.
- * @returns {*}
- */
- getDataModel() {
- return this.getDoc().toJS();
- }
-
- /**
- * Accepts any JS Object and sets the Editor's value to a stringified version
- * of that value.
- *
- * @param value
- */
- setDataModel(value) {
- this.setDoc(new Document(value));
- }
-
- /**
- * Method to be executed when the Editor's <TextModel> was updated
- */
- onUpdate() {
- if (this.options.highlightPath) {
- this.highlight(this.options.highlightPath);
- }
- }
-
- /**
- * Set the editors content to the input without recreating the content model.
- *
- * @param blob
- */
- updateValue(blob) {
- // Using applyEdits() instead of setValue() ensures that tokens such as
- // highlighted lines aren't deleted/recreated which causes a flicker.
- const model = this.getModel();
- model.applyEdits([
- {
- // A nice improvement would be to replace getFullModelRange() with
- // a range of the actual diff, avoiding re-formatting the document,
- // but that's something for a later iteration.
- range: model.getFullModelRange(),
- text: blob,
- },
- ]);
- }
-
- /**
- * Add a line highlight style to the node specified by the path.
- *
- * @param {string|null|false} path A path to a node of the Editor's value,
- * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
- * highlights.
- */
- highlight(path) {
- if (this.options.highlightPath === path) return;
- if (!path) {
- SourceEditorExtension.removeHighlights(this);
- } else {
- const res = this.locate(path);
- SourceEditorExtension.highlightLines(this, res);
- }
- this.options.highlightPath = path || null;
+ static getDoc(instance) {
+ return parseDocument(instance.getValue());
}
- /**
- * Return the line numbers of a certain node identified by `path` within
- * the yaml.
- *
- * @param {string} path A path to a node, eg. `foo.bar[0]`
- * @returns {number[]} Array following the schema `[firstLine, lastLine]`
- * (both inclusive)
- *
- * @throws {Error} Will throw if the path is not found inside the document
- */
- locate(path) {
+ static locate(instance, path) {
if (!path) throw Error(`No path provided.`);
- const blob = this.getValue();
+ const blob = instance.getValue();
const doc = parseDocument(blob);
const pathArray = toPath(path);
@@ -290,4 +189,120 @@ export class YamlEditorExtension extends SourceEditorExtension {
const endLine = (endSlice.match(/\n/g) || []).length;
return [startLine, endLine];
}
+
+ setDoc(instance, doc) {
+ if (this.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+
+ if (!instance.getValue()) {
+ instance.setValue(doc.toString());
+ } else {
+ instance.updateValue(doc.toString());
+ }
+ }
+
+ highlight(instance, path) {
+ // IMPORTANT
+ // removeHighlight and highlightLines both come from
+ // SourceEditorExtension. So it has to be installed prior to this extension
+ if (this.highlightPath === path) return;
+ if (!path) {
+ instance.removeHighlights();
+ } else {
+ const res = YamlEditorExtension.locate(instance, path);
+ instance.highlightLines(res);
+ }
+ this.highlightPath = path || null;
+ }
+
+ provides() {
+ return {
+ /**
+ * Get the editor's value parsed as a `Document` as defined by the `yaml`
+ * package
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @returns {Document}
+ */
+ getDoc: (instance) => YamlEditorExtension.getDoc(instance),
+
+ /**
+ * Accepts a `Document` as defined by the `yaml` package and
+ * sets the Editor's value to a stringified version of it.
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param { Document } doc
+ */
+ setDoc: (instance, doc) => this.setDoc(instance, doc),
+
+ /**
+ * Returns the parsed value of the Editor's content as JS.
+ * @returns {*}
+ */
+ getDataModel: (instance) => YamlEditorExtension.getDoc(instance).toJS(),
+
+ /**
+ * Accepts any JS Object and sets the Editor's value to a stringified version
+ * of that value.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param value
+ */
+ setDataModel: (instance, value) => this.setDoc(instance, new Document(value)),
+
+ /**
+ * Method to be executed when the Editor's <TextModel> was updated
+ */
+ onUpdate: (instance) => {
+ if (this.highlightPath) {
+ this.highlight(instance, this.highlightPath);
+ }
+ },
+
+ /**
+ * Set the editors content to the input without recreating the content model.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param blob
+ */
+ updateValue: (instance, blob) => {
+ // Using applyEdits() instead of setValue() ensures that tokens such as
+ // highlighted lines aren't deleted/recreated which causes a flicker.
+ const model = instance.getModel();
+ model.applyEdits([
+ {
+ // A nice improvement would be to replace getFullModelRange() with
+ // a range of the actual diff, avoiding re-formatting the document,
+ // but that's something for a later iteration.
+ range: model.getFullModelRange(),
+ text: blob,
+ },
+ ]);
+ },
+
+ /**
+ * Add a line highlight style to the node specified by the path.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {string|null|false} path A path to a node of the Editor's value,
+ * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
+ * highlights.
+ */
+ highlight: (instance, path) => this.highlight(instance, path),
+
+ /**
+ * Return the line numbers of a certain node identified by `path` within
+ * the yaml.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {string} path A path to a node, eg. `foo.bar[0]`
+ * @returns {number[]} Array following the schema `[firstLine, lastLine]`
+ * (both inclusive)
+ *
+ * @throws {Error} Will throw if the path is not found inside the document
+ */
+ locate: (instance, path) => YamlEditorExtension.locate(instance, path),
+
+ initFromModel: (instance, model) => this.initFromModel(instance, model),
+ };
+ }
}
diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js
index 81ddf8d77fa..57e2b0da565 100644
--- a/app/assets/javascripts/editor/source_editor.js
+++ b/app/assets/javascripts/editor/source_editor.js
@@ -1,4 +1,5 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import languages from '~/ide/lib/languages';
import { registerLanguages } from '~/ide/utils';
@@ -11,10 +12,39 @@ import {
EDITOR_TYPE_DIFF,
} from './constants';
import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils';
+import EditorInstance from './source_editor_instance';
+
+const instanceRemoveFromRegistry = (editor, instance) => {
+ const index = editor.instances.findIndex((inst) => inst === instance);
+ editor.instances.splice(index, 1);
+};
+
+const instanceDisposeModels = (editor, instance, model) => {
+ const instanceModel = instance.getModel() || model;
+ if (!instanceModel) {
+ return;
+ }
+ if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
+ const { original, modified } = instanceModel;
+ if (original) {
+ original.dispose();
+ }
+ if (modified) {
+ modified.dispose();
+ }
+ } else {
+ instanceModel.dispose();
+ }
+};
export default class SourceEditor {
+ /**
+ * Constructs a global editor.
+ * @param {Object} options - Monaco config options used to create the editor
+ */
constructor(options = {}) {
this.instances = [];
+ this.extensionsStore = new Map();
this.options = {
extraEditorClassName: 'gl-source-editor',
...defaultEditorOptions,
@@ -26,39 +56,6 @@ export default class SourceEditor {
registerLanguages(...languages);
}
- static pushToImportsArray(arr, toImport) {
- arr.push(import(toImport));
- }
-
- static loadExtensions(extensions) {
- if (!extensions) {
- return Promise.resolve();
- }
- const promises = [];
- const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions;
-
- extensionsArray.forEach((ext) => {
- const prefix = ext.includes('/') ? '' : 'editor/';
- const trimmedExt = ext.replace(/^\//, '').trim();
- SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
- });
-
- return Promise.all(promises);
- }
-
- static mixIntoInstance(source, inst) {
- if (!inst) {
- return;
- }
- const isClassInstance = source.constructor.prototype !== Object.prototype;
- const sanitizedSource = isClassInstance ? source.constructor.prototype : source;
- Object.getOwnPropertyNames(sanitizedSource).forEach((prop) => {
- if (prop !== 'constructor') {
- Object.assign(inst, { [prop]: source[prop] });
- }
- });
- }
-
static prepareInstance(el) {
if (!el) {
throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
@@ -71,23 +68,6 @@ export default class SourceEditor {
});
}
- static manageDefaultExtensions(instance, el, extensions) {
- SourceEditor.loadExtensions(extensions, instance)
- .then((modules) => {
- if (modules) {
- modules.forEach((module) => {
- instance.use(module.default);
- });
- }
- })
- .then(() => {
- el.dispatchEvent(new Event(EDITOR_READY_EVENT));
- })
- .catch((e) => {
- throw e;
- });
- }
-
static createEditorModel({
blobPath,
blobContent,
@@ -115,71 +95,17 @@ export default class SourceEditor {
return diffModel;
}
- static convertMonacoToELInstance = (inst) => {
- const sourceEditorInstanceAPI = {
- updateModelLanguage: (path) => {
- return SourceEditor.instanceUpdateLanguage(inst, path);
- },
- use: (exts = []) => {
- return SourceEditor.instanceApplyExtension(inst, exts);
- },
- };
- const handler = {
- get(target, prop, receiver) {
- if (Reflect.has(sourceEditorInstanceAPI, prop)) {
- return sourceEditorInstanceAPI[prop];
- }
- return Reflect.get(target, prop, receiver);
- },
- };
- return new Proxy(inst, handler);
- };
-
- static instanceUpdateLanguage(inst, path) {
- const lang = getBlobLanguage(path);
- const model = inst.getModel();
- return monacoEditor.setModelLanguage(model, lang);
- }
-
- static instanceApplyExtension(inst, exts = []) {
- const extensions = [].concat(exts);
- extensions.forEach((extension) => {
- SourceEditor.mixIntoInstance(extension, inst);
- });
- return inst;
- }
-
- static instanceRemoveFromRegistry(editor, instance) {
- const index = editor.instances.findIndex((inst) => inst === instance);
- editor.instances.splice(index, 1);
- }
-
- static instanceDisposeModels(editor, instance, model) {
- const instanceModel = instance.getModel() || model;
- if (!instanceModel) {
- return;
- }
- if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
- const { original, modified } = instanceModel;
- if (original) {
- original.dispose();
- }
- if (modified) {
- modified.dispose();
- }
- } else {
- instanceModel.dispose();
- }
- }
-
/**
- * Creates a monaco instance with the given options.
- *
- * @param {Object} options Options used to initialize monaco.
- * @param {Element} options.el The element which will be used to create the monacoEditor.
+ * Creates a Source Editor Instance with the given options.
+ * @param {Object} options Options used to initialize the instance.
+ * @param {Element} options.el The element to attach the instance for.
* @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language.
* @param {string} options.blobContent The content to initialize the monacoEditor.
+ * @param {string} options.blobOriginalContent The original blob's content. Is used when creating a Diff Instance.
* @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath.
+ * @param {Boolean} options.isDiff Flag to enable creation of a Diff Instance?
+ * @param {...*} options.instanceOptions Configuration options used to instantiate an instance.
+ * @returns {EditorInstance}
*/
createInstance({
el = undefined,
@@ -187,20 +113,24 @@ export default class SourceEditor {
blobContent = '',
blobOriginalContent = '',
blobGlobalId = uuids()[0],
- extensions = [],
isDiff = false,
...instanceOptions
} = {}) {
SourceEditor.prepareInstance(el);
const createEditorFn = isDiff ? 'createDiffEditor' : 'create';
- const instance = SourceEditor.convertMonacoToELInstance(
+ const instance = new EditorInstance(
monacoEditor[createEditorFn].call(this, el, {
...this.options,
...instanceOptions,
}),
+ this.extensionsStore,
);
+ waitForCSSLoaded(() => {
+ instance.layout();
+ });
+
let model;
if (instanceOptions.model !== null) {
model = SourceEditor.createEditorModel({
@@ -214,16 +144,20 @@ export default class SourceEditor {
}
instance.onDidDispose(() => {
- SourceEditor.instanceRemoveFromRegistry(this, instance);
- SourceEditor.instanceDisposeModels(this, instance, model);
+ instanceRemoveFromRegistry(this, instance);
+ instanceDisposeModels(this, instance, model);
});
- SourceEditor.manageDefaultExtensions(instance, el, extensions);
-
this.instances.push(instance);
+ el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance }));
return instance;
}
+ /**
+ * Create a Diff Instance
+ * @param {Object} args Options to be passed further down to createInstance() with the same signature
+ * @returns {EditorInstance}
+ */
createDiffInstance(args) {
return this.createInstance({
...args,
@@ -231,14 +165,11 @@ export default class SourceEditor {
});
}
+ /**
+ * Dispose global editor
+ * Automatically disposes all the instances registered for this editor
+ */
dispose() {
this.instances.forEach((instance) => instance.dispose());
}
-
- use(exts) {
- this.instances.forEach((inst) => {
- inst.use(exts);
- });
- return this;
- }
}
diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js
index f6bc62a1c09..6d47e1e2248 100644
--- a/app/assets/javascripts/editor/source_editor_extension.js
+++ b/app/assets/javascripts/editor/source_editor_extension.js
@@ -5,10 +5,10 @@ export default class EditorExtension {
if (typeof definition !== 'function') {
throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR);
}
- this.name = definition.name; // both class- and fn-based extensions have a name
this.setupOptions = setupOptions;
// eslint-disable-next-line new-cap
this.obj = new definition();
+ this.extensionName = definition.extensionName || this.obj.extensionName; // both class- and fn-based extensions have a name
}
get api() {
diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js
index e0ca4ea518b..8372a59964b 100644
--- a/app/assets/javascripts/editor/source_editor_instance.js
+++ b/app/assets/javascripts/editor/source_editor_instance.js
@@ -13,7 +13,7 @@
* A Source Editor Extension
* @typedef {Object} SourceEditorExtension
* @property {Object} obj
- * @property {string} name
+ * @property {string} extensionName
* @property {Object} api
*/
@@ -43,12 +43,12 @@ const utils = {
}
},
- getStoredExtension: (extensionsStore, name) => {
+ getStoredExtension: (extensionsStore, extensionName) => {
if (!extensionsStore) {
logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
return undefined;
}
- return extensionsStore.get(name);
+ return extensionsStore.get(extensionName);
},
};
@@ -73,32 +73,18 @@ export default class EditorInstance {
if (methodExtension) {
const extension = extensionsStore.get(methodExtension);
- return (...args) => {
- return extension.api[prop].call(seInstance, ...args, receiver);
- };
+ if (typeof extension.api[prop] === 'function') {
+ return extension.api[prop].bind(extension.obj, receiver);
+ }
+
+ return extension.api[prop];
}
return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
},
- set(target, prop, value) {
- Object.assign(seInstance, {
- [prop]: value,
- });
- return true;
- },
};
const instProxy = new Proxy(rootInstance, getHandler);
- /**
- * Main entry point to apply an extension to the instance
- * @param {SourceEditorExtensionDefinition}
- */
- this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension);
-
- /**
- * Main entry point to un-use an extension and remove it from the instance
- * @param {SourceEditorExtension}
- */
- this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension);
+ this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);
return instProxy;
}
@@ -143,7 +129,7 @@ export default class EditorInstance {
}
// Existing Extension Path
- const existingExt = utils.getStoredExtension(extensionsStore, definition.name);
+ const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName);
if (existingExt) {
if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
return existingExt;
@@ -155,7 +141,7 @@ export default class EditorInstance {
const extensionInstance = new EditorExtension(extension);
const { setupOptions, obj: extensionObj } = extensionInstance;
if (extensionObj.onSetup) {
- extensionObj.onSetup(setupOptions, this);
+ extensionObj.onSetup(this, setupOptions);
}
if (extensionsStore) {
this.registerExtension(extensionInstance, extensionsStore);
@@ -170,14 +156,14 @@ export default class EditorInstance {
* @param {Map} extensionsStore - The global registry for the extension instances
*/
registerExtension(extension, extensionsStore) {
- const { name } = extension;
+ const { extensionName } = extension;
const hasExtensionRegistered =
- extensionsStore.has(name) &&
- isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions);
+ extensionsStore.has(extensionName) &&
+ isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions);
if (hasExtensionRegistered) {
return;
}
- extensionsStore.set(name, extension);
+ extensionsStore.set(extensionName, extension);
const { obj: extensionObj } = extension;
if (extensionObj.onUse) {
extensionObj.onUse(this);
@@ -189,7 +175,7 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension
*/
registerExtensionMethods(extension) {
- const { api, name } = extension;
+ const { api, extensionName } = extension;
if (!api) {
return;
@@ -199,7 +185,7 @@ export default class EditorInstance {
if (this[prop]) {
logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
} else {
- this.methods[prop] = name;
+ this.methods[prop] = extensionName;
}
}, this);
}
@@ -217,10 +203,10 @@ export default class EditorInstance {
if (!extension) {
throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
}
- const { name } = extension;
- const existingExt = utils.getStoredExtension(extensionsStore, name);
+ const { extensionName } = extension;
+ const existingExt = utils.getStoredExtension(extensionsStore, extensionName);
if (!existingExt) {
- throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name }));
+ throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName }));
}
const { obj: extensionObj } = existingExt;
if (extensionObj.onBeforeUnuse) {
@@ -237,12 +223,12 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
*/
unregisterExtensionMethods(extension) {
- const { api, name } = extension;
+ const { api, extensionName } = extension;
if (!api) {
return;
}
Object.keys(api).forEach((method) => {
- utils.removeExtFromMethod(method, name, this.methods);
+ utils.removeExtFromMethod(method, extensionName, this.methods);
});
}
@@ -262,6 +248,24 @@ export default class EditorInstance {
}
/**
+ * Main entry point to apply an extension to the instance
+ * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use
+ * @returns {EditorExtension|*}
+ */
+ use(extDefs) {
+ return this.dispatchExtAction(this.useExtension, extDefs);
+ }
+
+ /**
+ * Main entry point to remove an extension to the instance
+ * @param {SourceEditorExtension[]|SourceEditorExtension} exts -
+ * @returns {*}
+ */
+ unuse(exts) {
+ return this.dispatchExtAction(this.unuseExtension, exts);
+ }
+
+ /**
* Get the methods returned by extensions.
* @returns {Array}
*/