diff options
Diffstat (limited to 'app/assets/javascripts/editor/extensions')
8 files changed, 659 insertions, 577 deletions
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), + }; + } } |