Commit 2fffee26 authored by Denys Mishunov's avatar Denys Mishunov

Markdown Live preview for Source Editor

The extension now allows for the live-preview of markdown files
while editing

Changelog: added
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68020
parent 32fdecd7
......@@ -18,7 +18,7 @@ export default class EditBlob {
if (this.options.isMarkdown) {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(new MarkdownExtension());
this.editor.use(new MarkdownExtension({ instance: this.editor }));
addEditorMarkdownListeners(this.editor);
})
.catch((e) =>
......
......@@ -28,3 +28,7 @@ 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 = 'preview-panel';
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 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,
} from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (content) => {
const url = window.location.href.replace('edit', 'preview');
return axios
.post(url, {
content,
})
.then(({ data }) => {
return data;
});
};
export class EditorMarkdownExtension extends SourceEditorExtension {
constructor({ instance, ...args } = {}) {
super({ instance, ...args });
EditorMarkdownExtension.setupLivePreview(instance);
}
static setupPanelElement(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;
}
static togglePreviewLayout(editor) {
const currentLayout = editor.getLayoutInfo();
const width = editor.preview
? currentLayout.width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: currentLayout.width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
editor.layout({ width, height: currentLayout.height });
}
static togglePreviewPanel(editor) {
const parentEl = editor.getDomNode().parentElement;
const { previewEl } = editor;
parentEl.classList.toggle('source-editor-preview');
if (previewEl.style.display === 'none') {
// Show the preview panel
const fetchPreview = () => {
getPreview(editor.getValue())
.then((data) => {
previewEl.innerHTML = data;
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
previewEl.style.display = 'block';
})
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
};
fetchPreview();
Object.assign(editor, {
modelChangeListener: editor.onDidChangeModelContent(
debounce(fetchPreview.bind(editor), 250),
),
});
} else {
// Hide the preview panel
previewEl.style.display = 'none';
editor.modelChangeListener.dispose();
}
}
static setupLivePreview(instance) {
if (!instance || instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
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(e) {
e.togglePreview();
},
});
}
togglePreview() {
if (!this.previewEl) {
this.previewEl = EditorMarkdownExtension.setupPanelElement(this.getDomNode().parentElement);
}
EditorMarkdownExtension.togglePreviewLayout(this);
EditorMarkdownExtension.togglePreviewPanel(this);
this.preview = !this.preview;
}
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');
......
......@@ -25,6 +25,14 @@
height: 500px;
}
.source-editor-preview {
@include gl-display-flex;
.preview-panel {
@include gl-overflow-scroll;
}
}
.monaco-editor.gl-source-editor {
.margin-view-overlays {
.line-numbers {
......
import { Range, Position } from 'monaco-editor';
import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
} 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('axios');
jest.mock('~/flash');
describe('Markdown Extension for Source Editor', () => {
let editor;
......@@ -31,7 +45,7 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: filePath,
blobContent: text,
});
editor.use(new EditorMarkdownExtension());
editor.use(new EditorMarkdownExtension({ instance }));
});
afterEach(() => {
......@@ -39,6 +53,183 @@ describe('Markdown Extension for Source Editor', () => {
editorEl.remove();
});
describe('contextual menu action', () => {
it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
});
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', () => {
const originalLocation = window.location.href;
const location = (action = 'edit') => {
return `https://dev.null/fooGroup/barProj/-/${action}/master/foo.md`;
};
const responseData = '<div>FooBar</div>';
let panelSpy;
beforeEach(() => {
setWindowLocation(location());
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
jest.spyOn(EditorMarkdownExtension, 'togglePreviewLayout');
axios.post.mockImplementation(() => Promise.resolve({ data: responseData }));
});
afterEach(() => {
setWindowLocation(originalLocation);
});
it('toggles preview flag on instance', () => {
expect(instance.preview).toBeUndefined();
instance.togglePreview();
expect(instance.preview).toBe(true);
instance.togglePreview();
expect(instance.preview).toBe(false);
});
describe('panel DOM element set up', () => {
beforeEach(() => {
jest.spyOn(EditorMarkdownExtension, 'setupPanelElement');
});
it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.previewEl).toBeUndefined();
instance.togglePreview();
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledWith(editorEl);
expect(instance.previewEl).toBeDefined();
expect(instance.previewEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true,
);
});
it('uses already set up preview DOM element on repeated calls', () => {
instance.togglePreview();
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledTimes(1);
const origPreviewEl = instance.previewEl;
instance.togglePreview();
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledTimes(1);
expect(instance.previewEl).toBe(origPreviewEl);
});
it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation();
instance.togglePreview();
expect(instance.previewEl.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('source-editor-preview')).toBe(false);
instance.togglePreview();
expect(editorEl.classList.contains('source-editor-preview')).toBe(true);
instance.togglePreview();
expect(editorEl.classList.contains('source-editor-preview')).toBe(false);
});
it('toggles visibility of the preview DOM element', async () => {
instance.togglePreview();
await waitForPromises();
expect(instance.previewEl.style.display).toBe('block');
instance.togglePreview();
await waitForPromises();
expect(instance.previewEl.style.display).toBe('none');
});
describe('hidden preview DOM element', () => {
it('shows error notification if fetching content fails', async () => {
axios.post.mockImplementation(() => Promise.reject());
instance.togglePreview();
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
it('fetches preview content and puts into the preview DOM element', async () => {
instance.togglePreview();
await waitForPromises();
expect(instance.previewEl.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
instance.togglePreview();
await waitForPromises();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('listens to model changes and re-fetches preview', async () => {
expect(axios.post).not.toHaveBeenCalled();
instance.togglePreview();
await waitForPromises();
expect(axios.post).toHaveBeenCalledTimes(1);
instance.setValue('New Value');
await waitForPromises();
expect(axios.post).toHaveBeenCalledTimes(2);
});
it('stores disposable listener for model changes', async () => {
expect(instance.modelChangeListener).toBeUndefined();
instance.togglePreview();
await waitForPromises();
expect(instance.modelChangeListener).toBeDefined();
});
});
describe('already visible preview', () => {
beforeEach(async () => {
instance.togglePreview();
await waitForPromises();
jest.clearAllMocks();
});
it('does not re-fetch the preview', () => {
instance.togglePreview();
expect(axios.post).not.toHaveBeenCalled();
});
it('disposes the model change event listener', () => {
const disposeSpy = jest.fn();
instance.modelChangeListener = {
dispose: disposeSpy,
};
instance.togglePreview();
expect(disposeSpy).toHaveBeenCalled();
});
});
});
});
describe('getSelectedText', () => {
it('does not fail if there is no selection and returns the empty string', () => {
jest.spyOn(instance, 'getSelection');
......
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