Commit 8f65bfc8 authored by Phil Hughes's avatar Phil Hughes

Merge branch '198605-monaco-blob' into 'master'

Introduced Markdown extension for Editor Lite

See merge request gitlab-org/gitlab!35285
parents d349b7f3 4b18a032
......@@ -8,15 +8,16 @@ import TemplateSelectorMediator from '../blob/file_template_mediator';
import getModeByFileExtension from '~/lib/utils/ace_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
const monacoEnabled = window?.gon?.features?.monacoBlobs;
const monacoEnabledGlobally = window.gon.features?.monacoBlobs;
export default class EditBlob {
// The options object has:
// assetsPath, filePath, currentAction, projectId, isMarkdown
constructor(options) {
this.options = options;
const { isMarkdown } = this.options;
Promise.resolve()
this.options.monacoEnabled = this.options.monacoEnabled ?? monacoEnabledGlobally;
const { isMarkdown, monacoEnabled } = this.options;
return Promise.resolve()
.then(() => {
return monacoEnabled ? this.configureMonacoEditor() : this.configureAceEditor();
})
......@@ -33,8 +34,15 @@ export default class EditBlob {
}
configureMonacoEditor() {
return import(/* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite').then(
EditorModule => {
const EditorPromise = import(
/* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite'
);
const MarkdownExtensionPromise = this.options.isMarkdown
? import('~/editor/editor_markdown_ext')
: Promise.resolve(false);
return Promise.all([EditorPromise, MarkdownExtensionPromise])
.then(([EditorModule, MarkdownExtension]) => {
const EditorLite = EditorModule.default;
const editorEl = document.getElementById('editor');
const fileNameEl =
......@@ -44,6 +52,10 @@ export default class EditBlob {
this.editor = new EditorLite();
if (MarkdownExtension) {
this.editor.use(MarkdownExtension.default);
}
this.editor.createInstance({
el: editorEl,
blobPath: fileNameEl.value,
......@@ -57,8 +69,8 @@ export default class EditBlob {
form.addEventListener('submit', () => {
fileContentEl.value = this.editor.getValue();
});
},
);
})
.catch(() => createFlash(BLOB_EDITOR_ERROR));
}
configureAceEditor() {
......@@ -126,7 +138,7 @@ export default class EditBlob {
}
initSoftWrap() {
this.isSoftWrapped = Boolean(monacoEnabled);
this.isSoftWrapped = Boolean(this.options.monacoEnabled);
this.$toggleButton = $('.soft-wrap-toggle');
this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
this.$toggleButton.on('click', () => this.toggleSoftWrap());
......@@ -135,7 +147,7 @@ export default class EditBlob {
toggleSoftWrap() {
this.isSoftWrapped = !this.isSoftWrapped;
this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
if (monacoEnabled) {
if (this.options.monacoEnabled) {
this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' });
} else {
this.editor.getSession().setUseWrapMode(this.isSoftWrapped);
......
export default {
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.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;
},
getSelection() {
return this.instance.getSelection();
},
replaceSelectedText(text, select = undefined) {
const forceMoveMarkers = !select;
this.instance.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
},
moveCursor(dx = 0, dy = 0) {
const pos = this.instance.getPosition();
pos.column += dx;
pos.lineNumber += dy;
this.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 {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.instance.setSelection(newSelection);
},
};
......@@ -27,9 +27,28 @@ function lineAfter(text, textarea) {
.split('\n')[0];
}
function convertMonacoSelectionToAceFormat(sel) {
return {
start: {
row: sel.startLineNumber,
column: sel.startColumn,
},
end: {
row: sel.endLineNumber,
column: sel.endColumn,
},
};
}
function getEditorSelectionRange(editor) {
return window.gon.features?.monacoBlobs
? convertMonacoSelectionToAceFormat(editor.getSelection())
: editor.getSelectionRange();
}
function editorBlockTagText(text, blockTag, selected, editor) {
const lines = text.split('\n');
const selectionRange = editor.getSelectionRange();
const selectionRange = getEditorSelectionRange(editor);
const shouldRemoveBlock =
lines[selectionRange.start.row - 1] === blockTag &&
lines[selectionRange.end.row + 1] === blockTag;
......@@ -90,8 +109,12 @@ function moveCursor({
const endPosition = startPosition + select.length;
return textArea.setSelectionRange(startPosition, endPosition);
} else if (editor) {
editor.navigateLeft(tag.length - tag.indexOf(select));
editor.getSelection().selectAWord();
if (window.gon.features?.monacoBlobs) {
editor.selectWithinSelection(select, tag);
} else {
editor.navigateLeft(tag.length - tag.indexOf(select));
editor.getSelection().selectAWord();
}
return;
}
}
......@@ -115,7 +138,11 @@ function moveCursor({
}
} else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
if (positionBetweenTags) {
editor.navigateLeft(tag.length);
if (window.gon.features?.monacoBlobs) {
editor.moveCursor(tag.length * -1);
} else {
editor.navigateLeft(tag.length);
}
}
}
}
......@@ -140,7 +167,7 @@ export function insertMarkdownText({
let textToInsert;
if (editor) {
const selectionRange = editor.getSelectionRange();
const selectionRange = getEditorSelectionRange(editor);
editorSelectionStart = selectionRange.start;
editorSelectionEnd = selectionRange.end;
......@@ -237,7 +264,11 @@ export function insertMarkdownText({
}
if (editor) {
editor.insert(textToInsert);
if (window.gon.features?.monacoBlobs) {
editor.replaceSelectedText(textToInsert, select);
} else {
editor.insert(textToInsert);
}
} else {
insertText(textArea, textToInsert);
}
......
import EditBlob from '~/blob_edit/edit_blob';
import EditorLite from '~/editor/editor_lite';
import MarkdownExtension from '~/editor/editor_markdown_ext';
jest.mock('~/editor/editor_lite');
jest.mock('~/editor/editor_markdown_ext');
describe('Blob Editing', () => {
beforeEach(() => {
setFixtures(
`<div class="js-edit-blob-form"><div id="file_path"></div><div id="iditor"></div><input id="file-content"></div>`,
);
});
const initEditor = (isMarkdown = false) => {
return new EditBlob({
isMarkdown,
monacoEnabled: true,
});
};
it('does not load MarkdownExtension by default', async () => {
await initEditor();
expect(EditorLite.prototype.use).not.toHaveBeenCalled();
});
it('loads MarkdownExtension only for the markdown files', async () => {
await initEditor(true);
expect(EditorLite.prototype.use).toHaveBeenCalledWith(MarkdownExtension);
});
});
import EditorLite from '~/editor/editor_lite';
import { Range, Position } from 'monaco-editor';
import EditorMarkdownExtension from '~/editor/editor_markdown_ext';
describe('Markdown Extension for Editor Lite', () => {
let editor;
let editorEl;
const firstLine = 'This is a';
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const filePath = 'foo.md';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
editor.instance.setSelection(selection);
};
const selectSecondString = () => setSelection(2, 1, 2, secondLine.length + 1); // select the whole second line
const selectSecondAndThirdLines = () => setSelection(2, 1, 3, thirdLine.length + 1); // select second and third lines
const selectionToString = () => editor.instance.getSelection().toString();
const positionToString = () => editor.instance.getPosition().toString();
beforeEach(() => {
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new EditorLite();
editor.createInstance({
el: editorEl,
blobPath: filePath,
blobContent: text,
});
editor.use(EditorMarkdownExtension);
});
afterEach(() => {
editor.instance.dispose();
editor.model.dispose();
editorEl.remove();
});
describe('getSelectedText', () => {
it('does not fail if there is no selection and returns the empty string', () => {
jest.spyOn(editor.instance, 'getSelection');
const resText = editor.getSelectedText();
expect(editor.instance.getSelection).toHaveBeenCalled();
expect(resText).toBe('');
});
it.each`
description | selection | expectedString
${'same-line'} | ${[1, 1, 1, firstLine.length + 1]} | ${firstLine}
${'two-lines'} | ${[1, 1, 2, secondLine.length + 1]} | ${`${firstLine}\n${secondLine}`}
${'multi-lines'} | ${[1, 1, 3, thirdLine.length + 1]} | ${text}
`('correctly returns selected text for $description', ({ selection, expectedString }) => {
setSelection(...selection);
const resText = editor.getSelectedText();
expect(resText).toBe(expectedString);
});
it('accepts selection object that serves as a source instead of current selection', () => {
selectSecondString();
const firstLineSelection = new Range(1, 1, 1, firstLine.length + 1);
const resText = editor.getSelectedText(firstLineSelection);
expect(resText).toBe(firstLine);
});
});
describe('replaceSelectedText', () => {
const expectedStr = 'foo';
it('replaces selected text with the supplied one', () => {
selectSecondString();
editor.replaceSelectedText(expectedStr);
expect(editor.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`);
});
it('prepends the supplied text if no text is selected', () => {
editor.replaceSelectedText(expectedStr);
expect(editor.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`);
});
it('replaces selection with empty string if no text is supplied', () => {
selectSecondString();
editor.replaceSelectedText();
expect(editor.getValue()).toBe(`${firstLine}\n\n${thirdLine}`);
});
it('puts cursor at the end of the new string and collapses selection by default', () => {
selectSecondString();
editor.replaceSelectedText(expectedStr);
expect(positionToString()).toBe(`(2,${expectedStr.length + 1})`);
expect(selectionToString()).toBe(
`[2,${expectedStr.length + 1} -> 2,${expectedStr.length + 1}]`,
);
});
it('puts cursor at the end of the new string and keeps selection if "select" is supplied', () => {
const select = 'url';
const complexReplacementString = `[${secondLine}](${select})`;
selectSecondString();
editor.replaceSelectedText(complexReplacementString, select);
expect(positionToString()).toBe(`(2,${complexReplacementString.length + 1})`);
expect(selectionToString()).toBe(`[2,1 -> 2,${complexReplacementString.length + 1}]`);
});
});
describe('moveCursor', () => {
const setPosition = endCol => {
const currentPos = new Position(2, endCol);
editor.instance.setPosition(currentPos);
};
it.each`
direction | condition | startColumn | shift | endPosition
${'left'} | ${'negative'} | ${secondLine.length + 1} | ${-1} | ${`(2,${secondLine.length})`}
${'left'} | ${'negative'} | ${secondLine.length} | ${secondLine.length * -1} | ${'(2,1)'}
${'right'} | ${'positive'} | ${1} | ${1} | ${'(2,2)'}
${'right'} | ${'positive'} | ${2} | ${secondLine.length} | ${`(2,${secondLine.length + 1})`}
${'up'} | ${'positive'} | ${1} | ${[0, -1]} | ${'(1,1)'}
${'top of file'} | ${'positive'} | ${1} | ${[0, -100]} | ${'(1,1)'}
${'down'} | ${'negative'} | ${1} | ${[0, 1]} | ${'(3,1)'}
${'end of file'} | ${'negative'} | ${1} | ${[0, 100]} | ${`(3,${thirdLine.length + 1})`}
${'end of line'} | ${'too large'} | ${1} | ${secondLine.length + 100} | ${`(2,${secondLine.length + 1})`}
${'start of line'} | ${'too low'} | ${1} | ${-100} | ${'(2,1)'}
`(
'moves cursor to the $direction if $condition supplied',
({ startColumn, shift, endPosition }) => {
setPosition(startColumn);
if (Array.isArray(shift)) {
editor.moveCursor(...shift);
} else {
editor.moveCursor(shift);
}
expect(positionToString()).toBe(endPosition);
},
);
});
describe('selectWithinSelection', () => {
it('scopes down current selection to supplied text', () => {
const selectedText = `${secondLine}\n${thirdLine}`;
const toSelect = 'string';
selectSecondAndThirdLines();
expect(selectionToString()).toBe(`[2,1 -> 3,${thirdLine.length + 1}]`);
editor.selectWithinSelection(toSelect, selectedText);
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
});
it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => {
jest.spyOn(editor, 'getSelectedText');
const toSelect = 'string';
selectSecondAndThirdLines();
editor.selectWithinSelection(toSelect);
expect(editor.getSelectedText).toHaveBeenCalled();
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
});
it('does nothing if no `toSelect` is supplied', () => {
selectSecondAndThirdLines();
const expectedPos = `(3,${thirdLine.length + 1})`;
const expectedSelection = `[2,1 -> 3,${thirdLine.length + 1}]`;
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
editor.selectWithinSelection();
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
});
it('does nothing if no selection is set in the editor', () => {
const expectedPos = '(1,1)';
const expectedSelection = '[1,1 -> 1,1]';
const toSelect = 'string';
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
editor.selectWithinSelection(toSelect);
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
editor.selectWithinSelection();
expect(positionToString()).toBe(expectedPos);
expect(selectionToString()).toBe(expectedSelection);
});
});
});
......@@ -232,19 +232,17 @@ describe('init markdown', () => {
beforeEach(() => {
editor = {
getSelectionRange: () => ({
getSelectionRange: jest.fn().mockReturnValue({
start: 0,
end: 0,
}),
getValue: () => 'this is text \n in two lines',
insert: () => {},
navigateLeft: () => {},
getValue: jest.fn().mockReturnValue('this is text \n in two lines'),
insert: jest.fn(),
navigateLeft: jest.fn(),
};
});
it('uses ace editor insert text when editor is passed in', () => {
jest.spyOn(editor, 'insert').mockReturnValue();
insertMarkdownText({
text: editor.getValue,
tag: '*',
......@@ -258,8 +256,6 @@ describe('init markdown', () => {
});
it('adds block tags on line above and below selection', () => {
jest.spyOn(editor, 'insert').mockReturnValue();
const selected = 'this text \n is multiple \n lines';
const text = `before \n ${selected} \n after`;
......@@ -276,8 +272,6 @@ describe('init markdown', () => {
});
it('uses ace editor to navigate back tag length when nothing is selected', () => {
jest.spyOn(editor, 'navigateLeft').mockReturnValue();
insertMarkdownText({
text: editor.getValue,
tag: '*',
......@@ -291,8 +285,6 @@ describe('init markdown', () => {
});
it('ace editor does not navigate back when there is selected text', () => {
jest.spyOn(editor, 'navigateLeft').mockReturnValue();
insertMarkdownText({
text: editor.getValue,
tag: '*',
......@@ -305,4 +297,96 @@ describe('init markdown', () => {
expect(editor.navigateLeft).not.toHaveBeenCalled();
});
});
describe('Editor Lite', () => {
let editor;
let origGon;
beforeEach(() => {
origGon = window.gon;
window.gon = {
features: {
monacoBlobs: true,
},
};
editor = {
getSelection: jest.fn().mockReturnValue({
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 2,
}),
getValue: jest.fn().mockReturnValue('this is text \n in two lines'),
selectWithinSelection: jest.fn(),
replaceSelectedText: jest.fn(),
moveCursor: jest.fn(),
};
});
afterEach(() => {
window.gon = origGon;
});
it('replaces selected text', () => {
insertMarkdownText({
text: editor.getValue,
tag: '*',
blockTag: null,
selected: '',
wrap: false,
editor,
});
expect(editor.replaceSelectedText).toHaveBeenCalled();
});
it('adds block tags on line above and below selection', () => {
const selected = 'this text \n is multiple \n lines';
const text = `before \n ${selected} \n after`;
insertMarkdownText({
text,
tag: '',
blockTag: '***',
selected,
wrap: true,
editor,
});
expect(editor.replaceSelectedText).toHaveBeenCalledWith(`***\n${selected}\n***\n`, undefined);
});
it('uses ace editor to navigate back tag length when nothing is selected', () => {
editor.getSelection = jest.fn().mockReturnValue({
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 1,
});
insertMarkdownText({
text: editor.getValue,
tag: '*',
blockTag: null,
selected: '',
wrap: true,
editor,
});
expect(editor.moveCursor).toHaveBeenCalledWith(-1);
});
it('ace editor does not navigate back when there is selected text', () => {
insertMarkdownText({
text: editor.getValue,
tag: '*',
blockTag: null,
selected: 'foobar',
wrap: true,
editor,
});
expect(editor.selectWithinSelection).not.toHaveBeenCalled();
});
});
});
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