Commit 4b18a032 authored by Denys Mishunov's avatar Denys Mishunov Committed by Phil Hughes

Introduced Markdown extension for Editor Lite

The extension is responsible for supporting the work with Markdown
toolbar in Editor Lite
parent e037c1db
......@@ -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