Commit 8f937080 authored by Denys Mishunov's avatar Denys Mishunov

Support extensions as configurable ES6 classes

This will allow the extensions to be either plain
objects, or configurable ES6 classes.
parent c0f74cfd
...@@ -5,7 +5,7 @@ import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; ...@@ -5,7 +5,7 @@ import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
import TemplateSelectorMediator from '../blob/file_template_mediator'; import TemplateSelectorMediator from '../blob/file_template_mediator';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import EditorLite from '~/editor/editor_lite'; import EditorLite from '~/editor/editor_lite';
import FileTemplateExtension from '~/editor/editor_file_template_ext'; import { FileTemplateExtension } from '~/editor/editor_file_template_ext';
export default class EditBlob { export default class EditBlob {
// The options object has: // The options object has:
...@@ -16,11 +16,11 @@ export default class EditBlob { ...@@ -16,11 +16,11 @@ export default class EditBlob {
if (this.options.isMarkdown) { if (this.options.isMarkdown) {
import('~/editor/editor_markdown_ext') import('~/editor/editor_markdown_ext')
.then(MarkdownExtension => { .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(MarkdownExtension.default); this.editor.use(new MarkdownExtension());
addEditorMarkdownListeners(this.editor); addEditorMarkdownListeners(this.editor);
}) })
.catch(() => createFlash(BLOB_EDITOR_ERROR)); .catch(e => createFlash(`${BLOB_EDITOR_ERROR}: ${e}`));
} }
this.initModePanesAndLinks(); this.initModePanesAndLinks();
...@@ -42,7 +42,7 @@ export default class EditBlob { ...@@ -42,7 +42,7 @@ export default class EditBlob {
blobPath: fileNameEl.value, blobPath: fileNameEl.value,
blobContent: editorEl.innerText, blobContent: editorEl.innerText,
}); });
this.editor.use(FileTemplateExtension); this.editor.use(new FileTemplateExtension());
fileNameEl.addEventListener('change', () => { fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value); this.editor.updateModelLanguage(fileNameEl.value);
......
...@@ -6,3 +6,7 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __( ...@@ -6,3 +6,7 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __(
export const URI_PREFIX = 'gitlab'; export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = 250; export const CONTENT_UPDATE_DEBOUNCE = 250;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
'Editor Lite instance is required to set up an extension.',
);
import { Position } from 'monaco-editor'; import { Position } from 'monaco-editor';
import { EditorLiteExtension } from './editor_lite_extension_base';
export default { export class FileTemplateExtension extends EditorLiteExtension {
navigateFileStart() { navigateFileStart() {
this.setPosition(new Position(1, 1)); this.setPosition(new Position(1, 1));
}, }
}; }
...@@ -8,7 +8,7 @@ import { clearDomElement } from './utils'; ...@@ -8,7 +8,7 @@ import { clearDomElement } from './utils';
import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants';
import { uuids } from '~/diffs/utils/uuids'; import { uuids } from '~/diffs/utils/uuids';
export default class Editor { export default class EditorLite {
constructor(options = {}) { constructor(options = {}) {
this.instances = []; this.instances = [];
this.options = { this.options = {
...@@ -17,7 +17,7 @@ export default class Editor { ...@@ -17,7 +17,7 @@ export default class Editor {
...options, ...options,
}; };
Editor.setupMonacoTheme(); EditorLite.setupMonacoTheme();
registerLanguages(...languages); registerLanguages(...languages);
} }
...@@ -54,12 +54,25 @@ export default class Editor { ...@@ -54,12 +54,25 @@ export default class Editor {
extensionsArray.forEach(ext => { extensionsArray.forEach(ext => {
const prefix = ext.includes('/') ? '' : 'editor/'; const prefix = ext.includes('/') ? '' : 'editor/';
const trimmedExt = ext.replace(/^\//, '').trim(); const trimmedExt = ext.replace(/^\//, '').trim();
Editor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); EditorLite.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
}); });
return Promise.all(promises); 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] });
}
});
}
/** /**
* Creates a monaco instance with the given options. * Creates a monaco instance with the given options.
* *
...@@ -101,10 +114,10 @@ export default class Editor { ...@@ -101,10 +114,10 @@ export default class Editor {
this.instances.splice(index, 1); this.instances.splice(index, 1);
model.dispose(); model.dispose();
}); });
instance.updateModelLanguage = path => Editor.updateModelLanguage(path, instance); instance.updateModelLanguage = path => EditorLite.updateModelLanguage(path, instance);
instance.use = args => this.use(args, instance); instance.use = args => this.use(args, instance);
Editor.loadExtensions(extensions, instance) EditorLite.loadExtensions(extensions, instance)
.then(modules => { .then(modules => {
if (modules) { if (modules) {
modules.forEach(module => { modules.forEach(module => {
...@@ -129,10 +142,17 @@ export default class Editor { ...@@ -129,10 +142,17 @@ export default class Editor {
use(exts = [], instance = null) { use(exts = [], instance = null) {
const extensions = Array.isArray(exts) ? exts : [exts]; const extensions = Array.isArray(exts) ? exts : [exts];
const initExtensions = inst => {
extensions.forEach(extension => {
EditorLite.mixIntoInstance(extension, inst);
});
};
if (instance) { if (instance) {
Object.assign(instance, ...extensions); initExtensions(instance);
} else { } else {
this.instances.forEach(inst => Object.assign(inst, ...extensions)); this.instances.forEach(inst => {
initExtensions(inst);
});
} }
} }
} }
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from './constants';
export class EditorLiteExtension {
constructor({ instance, ...options } = {}) {
if (instance) {
Object.assign(instance, options);
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
}
}
export default { import { EditorLiteExtension } from './editor_lite_extension_base';
export class EditorMarkdownExtension extends EditorLiteExtension {
getSelectedText(selection = this.getSelection()) { getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n'); const valArray = this.getValue().split('\n');
...@@ -18,19 +20,19 @@ export default { ...@@ -18,19 +20,19 @@ export default {
: [startLineText, endLineText].join('\n'); : [startLineText, endLineText].join('\n');
} }
return text; return text;
}, }
replaceSelectedText(text, select = undefined) { replaceSelectedText(text, select = undefined) {
const forceMoveMarkers = !select; const forceMoveMarkers = !select;
this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
}, }
moveCursor(dx = 0, dy = 0) { moveCursor(dx = 0, dy = 0) {
const pos = this.getPosition(); const pos = this.getPosition();
pos.column += dx; pos.column += dx;
pos.lineNumber += dy; pos.lineNumber += dy;
this.setPosition(pos); this.setPosition(pos);
}, }
/** /**
* Adjust existing selection to select text within the original selection. * Adjust existing selection to select text within the original selection.
...@@ -91,5 +93,100 @@ export default { ...@@ -91,5 +93,100 @@ export default {
.setEndPosition(newEndLineNumber, newEndColumn); .setEndPosition(newEndLineNumber, newEndColumn);
this.setSelection(newSelection); this.setSelection(newSelection);
}, }
}; }
// export default {
// 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);
// },
// };
---
title: Support extensions as configurable ES6 classes in Editor Lite
merge_request: 49813
author:
type: added
...@@ -10209,6 +10209,9 @@ msgstr "" ...@@ -10209,6 +10209,9 @@ msgstr ""
msgid "Editing" msgid "Editing"
msgstr "" msgstr ""
msgid "Editor Lite instance is required to set up an extension."
msgstr ""
msgid "Elasticsearch AWS IAM credentials" msgid "Elasticsearch AWS IAM credentials"
msgstr "" msgstr ""
......
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import EditBlob from '~/blob_edit/edit_blob'; import EditBlob from '~/blob_edit/edit_blob';
import EditorLite from '~/editor/editor_lite'; import EditorLite from '~/editor/editor_lite';
import MarkdownExtension from '~/editor/editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/editor_markdown_ext';
import FileTemplateExtension from '~/editor/editor_file_template_ext'; import { FileTemplateExtension } from '~/editor/editor_file_template_ext';
jest.mock('~/editor/editor_lite'); jest.mock('~/editor/editor_lite');
jest.mock('~/editor/editor_markdown_ext'); jest.mock('~/editor/editor_markdown_ext');
jest.mock('~/editor/editor_file_template_ext');
describe('Blob Editing', () => { describe('Blob Editing', () => {
const useMock = jest.fn(); const useMock = jest.fn();
...@@ -20,6 +21,10 @@ describe('Blob Editing', () => { ...@@ -20,6 +21,10 @@ describe('Blob Editing', () => {
); );
jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance); jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance);
}); });
afterEach(() => {
EditorMarkdownExtension.mockClear();
FileTemplateExtension.mockClear();
});
const editorInst = isMarkdown => { const editorInst = isMarkdown => {
return new EditBlob({ return new EditBlob({
...@@ -34,20 +39,20 @@ describe('Blob Editing', () => { ...@@ -34,20 +39,20 @@ describe('Blob Editing', () => {
it('loads FileTemplateExtension by default', async () => { it('loads FileTemplateExtension by default', async () => {
await initEditor(); await initEditor();
expect(useMock).toHaveBeenCalledWith(FileTemplateExtension); expect(FileTemplateExtension).toHaveBeenCalledTimes(1);
}); });
describe('Markdown', () => { describe('Markdown', () => {
it('does not load MarkdownExtension by default', async () => { it('does not load MarkdownExtension by default', async () => {
await initEditor(); await initEditor();
expect(useMock).not.toHaveBeenCalledWith(MarkdownExtension); expect(EditorMarkdownExtension).not.toHaveBeenCalled();
}); });
it('loads MarkdownExtension only for the markdown files', async () => { it('loads MarkdownExtension only for the markdown files', async () => {
await initEditor(true); await initEditor(true);
expect(useMock).toHaveBeenCalledTimes(2); expect(useMock).toHaveBeenCalledTimes(2);
expect(useMock).toHaveBeenNthCalledWith(1, FileTemplateExtension); expect(FileTemplateExtension).toHaveBeenCalledTimes(1);
expect(useMock).toHaveBeenNthCalledWith(2, MarkdownExtension); expect(EditorMarkdownExtension).toHaveBeenCalledTimes(1);
}); });
}); });
}); });
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '~/editor/constants';
import { EditorLiteExtension } from '~/editor/editor_lite_extension_base';
describe('The basis for an Editor Lite extension', () => {
const instance = {};
let ext;
it('accepts configuration options for an instance', () => {
expect(instance.foo).toBeUndefined();
ext = new EditorLiteExtension({ instance, foo: 'bar' });
expect(ext.foo).toBeUndefined();
expect(instance.foo).toBe('bar');
});
it('throws if only options are passed', () => {
expect(() => {
ext = new EditorLiteExtension({ foo: 'bar' });
}).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
});
it('does not fail if both instance and the options are omitted', () => {
expect(() => {
ext = new EditorLiteExtension();
}).not.toThrow();
});
});
/* eslint-disable max-classes-per-file */
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import Editor from '~/editor/editor_lite'; import Editor from '~/editor/editor_lite';
...@@ -242,17 +243,34 @@ describe('Base editor', () => { ...@@ -242,17 +243,34 @@ describe('Base editor', () => {
describe('extensions', () => { describe('extensions', () => {
let instance; let instance;
const foo1 = jest.fn(); const alphaRes = jest.fn();
const foo2 = jest.fn(); const betaRes = jest.fn();
const bar = jest.fn(); const fooRes = jest.fn();
const MyExt1 = { const barRes = jest.fn();
foo: foo1, class AlphaClass {
}; constructor() {
const MyExt2 = { this.res = alphaRes;
bar, }
alpha() {
return this?.nonExistentProp || alphaRes;
}
}
class BetaClass {
beta() {
return this?.nonExistentProp || betaRes;
}
}
const AlphaExt = new AlphaClass();
const BetaExt = new BetaClass();
const FooObjExt = {
foo() {
return fooRes;
},
}; };
const MyExt3 = { const BarObjExt = {
foo: foo2, bar() {
return barRes;
},
}; };
describe('basic functionality', () => { describe('basic functionality', () => {
...@@ -260,13 +278,6 @@ describe('Base editor', () => { ...@@ -260,13 +278,6 @@ describe('Base editor', () => {
instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); instance = editor.createInstance({ el: editorEl, blobPath, blobContent });
}); });
it('is extensible with the extensions', () => {
expect(instance.foo).toBeUndefined();
instance.use(MyExt1);
expect(instance.foo).toEqual(foo1);
});
it('does not fail if no extensions supplied', () => { it('does not fail if no extensions supplied', () => {
const spy = jest.spyOn(global.console, 'error'); const spy = jest.spyOn(global.console, 'error');
instance.use(); instance.use();
...@@ -274,22 +285,47 @@ describe('Base editor', () => { ...@@ -274,22 +285,47 @@ describe('Base editor', () => {
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
it('is extensible with multiple extensions', () => { it("does not extend instance with extension's constructor", () => {
expect(instance.foo).toBeUndefined(); expect(instance.constructor).toBeDefined();
expect(instance.bar).toBeUndefined(); const { constructor } = instance;
instance.use([MyExt1, MyExt2]); expect(AlphaExt.constructor).toBeDefined();
expect(AlphaExt.constructor).not.toEqual(constructor);
instance.use(AlphaExt);
expect(instance.constructor).toBe(constructor);
});
expect(instance.foo).toEqual(foo1); it.each`
expect(instance.bar).toEqual(bar); type | extensions | methods | expectations
${'ES6 classes'} | ${AlphaExt} | ${['alpha']} | ${[alphaRes]}
${'multiple ES6 classes'} | ${[AlphaExt, BetaExt]} | ${['alpha', 'beta']} | ${[alphaRes, betaRes]}
${'simple objects'} | ${FooObjExt} | ${['foo']} | ${[fooRes]}
${'multiple simple objects'} | ${[FooObjExt, BarObjExt]} | ${['foo', 'bar']} | ${[fooRes, barRes]}
${'combination of ES6 classes and objects'} | ${[AlphaExt, BarObjExt]} | ${['alpha', 'bar']} | ${[alphaRes, barRes]}
`('is extensible with $type', ({ extensions, methods, expectations } = {}) => {
methods.forEach(method => {
expect(instance[method]).toBeUndefined();
});
instance.use(extensions);
methods.forEach(method => {
expect(instance[method]).toBeDefined();
});
expectations.forEach((expectation, i) => {
expect(instance[methods[i]].call()).toEqual(expectation);
});
}); });
it('uses the last definition of a method in case of an overlap', () => { it('uses the last definition of a method in case of an overlap', () => {
instance.use([MyExt1, MyExt2, MyExt3]); const FooObjExt2 = { foo: 'foo2' };
instance.use([FooObjExt, BarObjExt, FooObjExt2]);
expect(instance).toEqual( expect(instance).toEqual(
expect.objectContaining({ expect.objectContaining({
foo: foo2, foo: 'foo2',
bar, ...BarObjExt,
}), }),
); );
}); });
...@@ -396,15 +432,15 @@ describe('Base editor', () => { ...@@ -396,15 +432,15 @@ describe('Base editor', () => {
}); });
it('extends all instances if no specific instance is passed', () => { it('extends all instances if no specific instance is passed', () => {
editor.use(MyExt1); editor.use(AlphaExt);
expect(inst1.foo).toEqual(foo1); expect(inst1.alpha()).toEqual(alphaRes);
expect(inst2.foo).toEqual(foo1); expect(inst2.alpha()).toEqual(alphaRes);
}); });
it('extends specific instance if it has been passed', () => { it('extends specific instance if it has been passed', () => {
editor.use(MyExt1, inst2); editor.use(AlphaExt, inst2);
expect(inst1.foo).toBeUndefined(); expect(inst1.alpha).toBeUndefined();
expect(inst2.foo).toEqual(foo1); expect(inst2.alpha()).toEqual(alphaRes);
}); });
}); });
}); });
......
import { Range, Position } from 'monaco-editor'; import { Range, Position } from 'monaco-editor';
import EditorLite from '~/editor/editor_lite'; import EditorLite from '~/editor/editor_lite';
import EditorMarkdownExtension from '~/editor/editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/editor_markdown_ext';
describe('Markdown Extension for Editor Lite', () => { describe('Markdown Extension for Editor Lite', () => {
let editor; let editor;
...@@ -31,7 +31,7 @@ describe('Markdown Extension for Editor Lite', () => { ...@@ -31,7 +31,7 @@ describe('Markdown Extension for Editor Lite', () => {
blobPath: filePath, blobPath: filePath,
blobContent: text, blobContent: text,
}); });
editor.use(EditorMarkdownExtension); editor.use(new EditorMarkdownExtension());
}); });
afterEach(() => { afterEach(() => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment