Commit e7135c97 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '280798-markdown-live-preview' into 'master'

Markdown Live preview for Source Editor

See merge request gitlab-org/gitlab!68020
parents dfa611f0 54febab7
import $ from 'jquery';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import SourceEditor from '~/editor/source_editor';
import { getBlobLanguage } from '~/editor/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
......@@ -16,16 +17,7 @@ export default class EditBlob {
this.configureMonacoEditor();
if (this.options.isMarkdown) {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(new MarkdownExtension());
addEditorMarkdownListeners(this.editor);
})
.catch((e) =>
createFlash({
message: `${BLOB_EDITOR_ERROR}: ${e}`,
}),
);
this.fetchMarkdownExtension();
}
this.initModePanesAndLinks();
......@@ -34,12 +26,30 @@ export default class EditBlob {
this.editor.focus();
}
fetchMarkdownExtension() {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(
new MarkdownExtension({ instance: this.editor, projectPath: this.options.projectPath }),
);
this.hasMarkdownExtension = true;
addEditorMarkdownListeners(this.editor);
})
.catch((e) =>
createFlash({
message: `${BLOB_EDITOR_ERROR}: ${e}`,
}),
);
}
configureMonacoEditor() {
const editorEl = document.getElementById('editor');
const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
const fileContentEl = document.getElementById('file-content');
const form = document.querySelector('.js-edit-blob-form');
this.hasMarkdownExtension = false;
const rootEditor = new SourceEditor();
this.editor = rootEditor.createInstance({
......@@ -51,6 +61,12 @@ export default class EditBlob {
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
const newLang = getBlobLanguage(fileNameEl.value);
if (newLang === 'markdown') {
if (!this.hasMarkdownExtension) {
this.fetchMarkdownExtension();
}
}
});
form.addEventListener('submit', () => {
......
......@@ -28,3 +28,8 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
// '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview';
export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width
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,
} from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (text, projectPath = '') => {
let url;
if (projectPath) {
url = `/${projectPath}/preview_markdown`;
} else {
const { group, project } = document.body.dataset;
url = `/${group}/${project}/preview_markdown`;
}
return axios
.post(url, {
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 EditorMarkdownExtension extends SourceEditorExtension {
constructor({ instance, projectPath, ...args } = {}) {
super({ instance, ...args });
Object.assign(instance, {
projectPath,
preview: {
el: undefined,
action: undefined,
shown: false,
},
});
this.setupPreviewAction.call(instance);
}
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() {
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.projectPath)
.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;
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,
// 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();
},
});
}
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.modelChangeListener = this.onDidChangeModelContent(
debounce(this.fetchPreview.bind(this), 250),
);
} else {
this.modelChangeListener.dispose();
}
this.preview.shown = !this.preview?.shown;
this.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
this.setupPreviewAction();
} else {
this.cleanup();
}
});
}
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');
......
......@@ -25,6 +25,17 @@
height: 500px;
}
.source-editor-preview {
@include gl-display-flex;
.md {
@include gl-overflow-scroll;
@include gl-px-6;
@include gl-py-4;
@include gl-w-full;
}
}
.monaco-editor.gl-source-editor {
.margin-view-overlays {
.line-numbers {
......
import MockAdapter from 'axios-mock-adapter';
import { Range, Position } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
} from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
jest.mock('~/syntax_highlight');
jest.mock('~/flash');
describe('Markdown Extension for Source Editor', () => {
let editor;
let instance;
let editorEl;
let panelSpy;
let mockAxios;
const projectPath = 'fooGroup/barProj';
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 responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
......@@ -22,7 +40,13 @@ describe('Markdown Extension for Source Editor', () => {
const selectionToString = () => instance.getSelection().toString();
const positionToString = () => instance.getPosition().toString();
const togglePreview = async () => {
instance.togglePreview();
await waitForPromises();
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
......@@ -31,12 +55,313 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: filePath,
blobContent: text,
});
editor.use(new EditorMarkdownExtension());
editor.use(new EditorMarkdownExtension({ instance, projectPath }));
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
});
afterEach(() => {
instance.dispose();
editorEl.remove();
mockAxios.restore();
});
it('sets up the instance', () => {
expect(instance.preview).toEqual({
el: undefined,
action: expect.any(Object),
shown: false,
});
expect(instance.projectPath).toBe(projectPath);
});
describe('cleanup', () => {
beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
instance.cleanup();
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
});
it('toggles the `shown` flag', () => {
expect(instance.preview.shown).toBe(true);
instance.cleanup();
expect(instance.preview.shown).toBe(false);
});
it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.preview;
const parentEl = previewEl.parentElement;
expect(previewEl).toBeVisible();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles the layout only if the preview is visible', () => {
const { width } = instance.getLayoutInfo();
expect(instance.preview.shown).toBe(true);
instance.cleanup();
const { width: newWidth } = instance.getLayoutInfo();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
instance.cleanup();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
});
describe('fetchPreview', () => {
const group = 'foo';
const project = 'bar';
const setData = (path, g, p) => {
instance.projectPath = path;
document.body.setAttribute('data-group', g);
document.body.setAttribute('data-project', p);
};
const fetchPreview = async () => {
instance.fetchPreview();
await waitForPromises();
};
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('correctly fetches preview based on projectPath', async () => {
setData(projectPath, group, project);
await fetchPreview();
expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`);
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
});
it('correctly fetches preview based on group and project data attributes', async () => {
setData(undefined, group, project);
await fetchPreview();
expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`);
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
});
it('puts the fetched content into the preview DOM element', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(instance.preview.el.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('catches the errors when fetching the preview', async () => {
mockAxios.onPost().reply(500);
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
});
});
describe('setupPreviewAction', () => {
it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
});
it('does not set up action if one already exists', () => {
jest.spyOn(instance, 'addAction').mockImplementation();
instance.setupPreviewAction();
expect(instance.addAction).not.toHaveBeenCalled();
});
it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation();
expect(instance.togglePreview).not.toHaveBeenCalled();
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
action.run();
expect(instance.togglePreview).toHaveBeenCalled();
});
});
describe('togglePreview', () => {
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('toggles preview flag on instance', () => {
expect(instance.preview.shown).toBe(false);
instance.togglePreview();
expect(instance.preview.shown).toBe(true);
instance.togglePreview();
expect(instance.preview.shown).toBe(false);
});
describe('model language changes', () => {
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
it('cleans up when switching away from markdown', async () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('re-enables the action when switching back to markdown', () => {
instance.updateModelLanguage(plaintextPath);
jest.clearAllMocks();
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).toHaveBeenCalled();
});
it('does not re-enable the action if we do not change the language', () => {
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
});
describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined();
instance.togglePreview();
expect(instance.preview.el).toBeDefined();
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true,
);
});
it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview();
const origPreviewEl = instance.preview.el;
instance.togglePreview();
expect(instance.preview.el).toBe(origPreviewEl);
});
it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation();
instance.togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
});
describe('preview layout setup', () => {
it('sets correct preview layout', () => {
jest.spyOn(instance, 'layout');
const { width, height } = instance.getLayoutInfo();
instance.togglePreview();
expect(instance.layout).toHaveBeenCalledWith({
width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
height,
});
});
});
describe('preview panel', () => {
it('toggles preview CSS class on the editor', () => {
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
true,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles visibility of the preview DOM element', async () => {
await togglePreview();
expect(instance.preview.el.style.display).toBe('block');
await togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
describe('hidden preview DOM element', () => {
it('listens to model changes and re-fetches preview', async () => {
expect(mockAxios.history.post).toHaveLength(0);
await togglePreview();
expect(mockAxios.history.post).toHaveLength(1);
instance.setValue('New Value');
await waitForPromises();
expect(mockAxios.history.post).toHaveLength(2);
});
it('stores disposable listener for model changes', async () => {
expect(instance.modelChangeListener).toBeUndefined();
await togglePreview();
expect(instance.modelChangeListener).toBeDefined();
});
});
describe('already visible preview', () => {
beforeEach(async () => {
await togglePreview();
mockAxios.resetHistory();
});
it('does not re-fetch the preview', () => {
instance.togglePreview();
expect(mockAxios.history.post).toHaveLength(0);
});
it('disposes the model change event listener', () => {
const disposeSpy = jest.fn();
instance.modelChangeListener = {
dispose: disposeSpy,
};
instance.togglePreview();
expect(disposeSpy).toHaveBeenCalled();
});
});
});
});
describe('getSelectedText', () => {
......
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