Commit 866b7a96 authored by Denys Mishunov's avatar Denys Mishunov

Refactoring the markdown extension

parent 7c9516fb
...@@ -30,5 +30,6 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; ...@@ -30,5 +30,6 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; 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_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_ACTION_ID = 'markdown-preview';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
} from '../constants'; } from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base'; import { SourceEditorExtension } from './source_editor_extension_base';
...@@ -30,63 +31,76 @@ const getPreview = (text, projectPath = '') => { ...@@ -30,63 +31,76 @@ const getPreview = (text, projectPath = '') => {
}); });
}; };
export class EditorMarkdownExtension extends SourceEditorExtension { const setupDomElement = ({ injectToEl = null } = {}) => {
constructor({ instance, ...args } = {}) { const previewEl = document.createElement('div');
super({ instance, ...args }); previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
EditorMarkdownExtension.setupLivePreview(instance); previewEl.style.display = 'none';
if (injectToEl) {
injectToEl.appendChild(previewEl);
} }
return previewEl;
};
static setupPanelElement(injectToEl = null) { export class EditorMarkdownExtension extends SourceEditorExtension {
const previewEl = document.createElement('div'); constructor({ instance, projectPath, ...args } = {}) {
previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); super({ instance, ...args });
previewEl.style.display = 'none'; Object.assign(instance, {
if (injectToEl) { projectPath,
injectToEl.appendChild(previewEl); preview: {
} el: undefined,
return previewEl; action: undefined,
shown: false,
},
});
this.setupPreviewAction.call(instance);
} }
static togglePreviewLayout(editor) { static togglePreviewLayout() {
const currentLayout = editor.getLayoutInfo(); const { width, height } = this.getLayoutInfo();
const width = editor.preview const newWidth = this.preview.shown
? currentLayout.width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: currentLayout.width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
editor.layout({ width, height: currentLayout.height }); this.layout({ width: newWidth, height });
} }
static togglePreviewPanel(editor) { static togglePreviewPanel() {
const parentEl = editor.getDomNode().parentElement; const parentEl = this.getDomNode().parentElement;
const { previewEl } = editor; const { el: previewEl } = this.preview;
parentEl.classList.toggle('source-editor-preview'); parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
if (previewEl.style.display === 'none') { if (previewEl.style.display === 'none') {
// Show the preview panel // Show the preview panel
const fetchPreview = () => { this.fetchPreview();
getPreview(editor.getValue(), editor.projectPath)
.then((data) => {
previewEl.innerHTML = sanitize(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 { } else {
// Hide the preview panel // Hide the preview panel
previewEl.style.display = 'none'; previewEl.style.display = 'none';
editor.modelChangeListener.dispose();
} }
} }
static setupLivePreview(instance) { cleanup() {
if (!instance || instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; 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;
instance.addAction({ this.preview.action = this.addAction({
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
label: __('Preview Markdown'), label: __('Preview Markdown'),
keybindings: [ keybindings: [
...@@ -98,19 +112,36 @@ export class EditorMarkdownExtension extends SourceEditorExtension { ...@@ -98,19 +112,36 @@ export class EditorMarkdownExtension extends SourceEditorExtension {
// Method that will be executed when the action is triggered. // Method that will be executed when the action is triggered.
// @param ed The editor instance is passed in as a convenience // @param ed The editor instance is passed in as a convenience
run(e) { run(instance) {
e.togglePreview(); instance.togglePreview();
}, },
}); });
} }
togglePreview() { togglePreview() {
if (!this.previewEl) { if (!this.preview?.el) {
this.previewEl = EditorMarkdownExtension.setupPanelElement(this.getDomNode().parentElement); 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();
} }
EditorMarkdownExtension.togglePreviewLayout(this);
EditorMarkdownExtension.togglePreviewPanel(this); this.preview.shown = !this.preview?.shown;
this.preview = !this.preview;
this.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
this.setupPreviewAction();
} else {
this.cleanup();
}
});
} }
getSelectedText(selection = this.getSelection()) { getSelectedText(selection = this.getSelection()) {
......
import { Range, Position } from 'monaco-editor'; import { Range, Position } from 'monaco-editor';
import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
} from '~/editor/constants'; } from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
...@@ -20,11 +20,14 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -20,11 +20,14 @@ describe('Markdown Extension for Source Editor', () => {
let editor; let editor;
let instance; let instance;
let editorEl; let editorEl;
let panelSpy;
const projectPath = 'fooGroup/barProj';
const firstLine = 'This is a'; const firstLine = 'This is a';
const secondLine = 'multiline'; const secondLine = 'multiline';
const thirdLine = 'string with some **markup**'; const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const filePath = 'foo.md'; const filePath = 'foo.md';
const responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
...@@ -50,7 +53,8 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -50,7 +53,8 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: filePath, blobPath: filePath,
blobContent: text, blobContent: text,
}); });
editor.use(new EditorMarkdownExtension({ instance })); editor.use(new EditorMarkdownExtension({ instance, projectPath }));
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
}); });
afterEach(() => { afterEach(() => {
...@@ -58,11 +62,131 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -58,11 +62,131 @@ describe('Markdown Extension for Source Editor', () => {
editorEl.remove(); editorEl.remove();
}); });
describe('contextual menu action', () => { 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 () => {
axios.post.mockImplementation(() => Promise.resolve({ data: '<div>FooBar</div>' }));
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(() => {
axios.post.mockImplementation(() => Promise.resolve({ data: { body: responseData } }));
});
it('correctly fetches preview based on projectPath', async () => {
setData(projectPath, group, project);
await fetchPreview();
expect(axios.post).toHaveBeenCalledWith(`/${projectPath}/preview_markdown`, { text });
});
it('correctly fetches preview based on group and project data attributes', async () => {
setData(undefined, group, project);
await fetchPreview();
expect(axios.post).toHaveBeenCalledWith(`/${group}/${project}/preview_markdown`, { 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 () => {
axios.post.mockImplementation(() => Promise.reject());
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
});
});
describe('setupPreviewAction', () => {
it('adds the contextual menu action', () => { it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); 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', () => { it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation(); jest.spyOn(instance, 'togglePreview').mockImplementation();
...@@ -76,67 +200,85 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -76,67 +200,85 @@ describe('Markdown Extension for Source Editor', () => {
}); });
describe('togglePreview', () => { 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(() => { beforeEach(() => {
setWindowLocation(location());
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
jest.spyOn(EditorMarkdownExtension, 'togglePreviewLayout');
axios.post.mockImplementation(() => Promise.resolve({ data: responseData })); axios.post.mockImplementation(() => Promise.resolve({ data: responseData }));
}); });
afterEach(() => {
setWindowLocation(originalLocation);
});
it('toggles preview flag on instance', () => { it('toggles preview flag on instance', () => {
expect(instance.preview).toBeUndefined(); expect(instance.preview.shown).toBe(false);
instance.togglePreview(); instance.togglePreview();
expect(instance.preview).toBe(true); expect(instance.preview.shown).toBe(true);
instance.togglePreview(); instance.togglePreview();
expect(instance.preview).toBe(false); expect(instance.preview.shown).toBe(false);
}); });
describe('panel DOM element set up', () => { describe('model language changes', () => {
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
let cleanupSpy;
let actionSpy;
beforeEach(() => { beforeEach(() => {
jest.spyOn(EditorMarkdownExtension, 'setupPanelElement'); 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', () => { it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.previewEl).toBeUndefined(); expect(instance.preview.el).toBeUndefined();
instance.togglePreview(); instance.togglePreview();
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledWith(editorEl); expect(instance.preview.el).toBeDefined();
expect(instance.previewEl).toBeDefined(); expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
expect(instance.previewEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true, true,
); );
}); });
it('uses already set up preview DOM element on repeated calls', () => { it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview(); instance.togglePreview();
const origPreviewEl = instance.preview.el;
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledTimes(1);
const origPreviewEl = instance.previewEl;
instance.togglePreview(); instance.togglePreview();
expect(EditorMarkdownExtension.setupPanelElement).toHaveBeenCalledTimes(1); expect(instance.preview.el).toBe(origPreviewEl);
expect(instance.previewEl).toBe(origPreviewEl);
}); });
it('hides the preview DOM element by default', () => { it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation(); panelSpy.mockImplementation();
instance.togglePreview(); instance.togglePreview();
expect(instance.previewEl.style.display).toBe('none'); expect(instance.preview.el.style.display).toBe('none');
}); });
}); });
...@@ -156,37 +298,27 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -156,37 +298,27 @@ describe('Markdown Extension for Source Editor', () => {
describe('preview panel', () => { describe('preview panel', () => {
it('toggles preview CSS class on the editor', () => { it('toggles preview CSS class on the editor', () => {
expect(editorEl.classList.contains('source-editor-preview')).toBe(false); expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.togglePreview(); instance.togglePreview();
expect(editorEl.classList.contains('source-editor-preview')).toBe(true); expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
true,
);
instance.togglePreview(); instance.togglePreview();
expect(editorEl.classList.contains('source-editor-preview')).toBe(false); expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
}); });
it('toggles visibility of the preview DOM element', async () => { it('toggles visibility of the preview DOM element', async () => {
await togglePreview(); await togglePreview();
expect(instance.previewEl.style.display).toBe('block'); expect(instance.preview.el.style.display).toBe('block');
await togglePreview(); await togglePreview();
expect(instance.previewEl.style.display).toBe('none'); expect(instance.preview.el.style.display).toBe('none');
}); });
describe('hidden preview DOM element', () => { describe('hidden preview DOM element', () => {
it('shows error notification if fetching content fails', async () => {
axios.post.mockImplementation(() => Promise.reject());
await togglePreview();
expect(createFlash).toHaveBeenCalled();
});
it('fetches preview content and puts into the preview DOM element', async () => {
await togglePreview();
expect(instance.previewEl.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
await togglePreview();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('listens to model changes and re-fetches preview', async () => { it('listens to model changes and re-fetches preview', async () => {
expect(axios.post).not.toHaveBeenCalled(); expect(axios.post).not.toHaveBeenCalled();
await togglePreview(); await togglePreview();
......
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