Commit 69faf526 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'revert-reduce-bundle-size-content-editor' into 'master'

Revert reduce bundle size of the Content Editor

See merge request gitlab-org/gitlab!83145
parents 7894813a adfdfeb4
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core'; import { lowlight } from 'lowlight/lib/all';
import { isFunction } from 'lodash';
const extractLanguage = (element) => element.getAttribute('lang'); const extractLanguage = (element) => element.getAttribute('lang');
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
const loadLanguageFromInputRule = (languageLoader) => (match) => {
const language = match[1];
if (isFunction(languageLoader?.loadLanguages)) {
languageLoader.loadLanguages([language]);
}
return {
language,
};
};
export default CodeBlockLowlight.extend({ export default CodeBlockLowlight.extend({
isolating: true, isolating: true,
addOptions() {
return {
...this.parent?.(),
languageLoader: {},
};
},
addAttributes() { addAttributes() {
return { return {
language: { language: {
...@@ -40,22 +18,6 @@ export default CodeBlockLowlight.extend({ ...@@ -40,22 +18,6 @@ export default CodeBlockLowlight.extend({
}, },
}; };
}, },
addInputRules() {
const { languageLoader } = this.options;
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
];
},
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
'pre', 'pre',
...@@ -66,4 +28,6 @@ export default CodeBlockLowlight.extend({ ...@@ -66,4 +28,6 @@ export default CodeBlockLowlight.extend({
['code', {}, 0], ['code', {}, 0],
]; ];
}, },
}).configure({
lowlight,
}); });
export default class CodeBlockLanguageLoader {
constructor(lowlight) {
this.lowlight = lowlight;
}
isLanguageLoaded(language) {
return this.lowlight.registered(language);
}
loadLanguagesFromDOM(domTree) {
const languages = [];
domTree.querySelectorAll('pre').forEach((preElement) => {
languages.push(preElement.getAttribute('lang'));
});
return this.loadLanguages(languages);
}
loadLanguages(languageList = []) {
const loaders = languageList
.filter((languageName) => !this.isLanguageLoaded(languageName))
.map((languageName) => {
return import(
/* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}`
)
.then(({ default: language }) => {
this.lowlight.registerLanguage(languageName, language);
})
.catch(() => false);
});
return Promise.all(loaders);
}
}
...@@ -3,12 +3,11 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro ...@@ -3,12 +3,11 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
export class ContentEditor { export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
this._tiptapEditor = tiptapEditor; this._tiptapEditor = tiptapEditor;
this._serializer = serializer; this._serializer = serializer;
this._deserializer = deserializer; this._deserializer = deserializer;
this._eventHub = eventHub; this._eventHub = eventHub;
this._languageLoader = languageLoader;
} }
get tiptapEditor() { get tiptapEditor() {
...@@ -35,35 +34,23 @@ export class ContentEditor { ...@@ -35,35 +34,23 @@ export class ContentEditor {
} }
async setSerializedContent(serializedContent) { async setSerializedContent(serializedContent) {
const { const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
_tiptapEditor: editor,
_deserializer: deserializer,
_eventHub: eventHub,
_languageLoader: languageLoader,
} = this;
const { doc, tr } = editor.state; const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size); const selection = TextSelection.create(doc, 0, doc.content.size);
try { try {
eventHub.$emit(LOADING_CONTENT_EVENT); eventHub.$emit(LOADING_CONTENT_EVENT);
const result = await deserializer.deserialize({ const { document } = await deserializer.deserialize({
schema: editor.schema, schema: editor.schema,
content: serializedContent, content: serializedContent,
}); });
if (Object.keys(result).length === 0) { if (document) {
return; tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
} }
const { document, dom } = result;
await languageLoader.loadLanguagesFromDOM(dom);
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
eventHub.$emit(LOADING_SUCCESS_EVENT); eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) { } catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e); eventHub.$emit(LOADING_ERROR_EVENT, e);
......
import { Editor } from '@tiptap/vue-2'; import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory'; import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment'; import Attachment from '../extensions/attachment';
...@@ -59,7 +58,6 @@ import { ContentEditor } from './content_editor'; ...@@ -59,7 +58,6 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer'; import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer'; import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import CodeBlockLanguageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) => const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({ new Editor({
...@@ -85,7 +83,6 @@ export const createContentEditor = ({ ...@@ -85,7 +83,6 @@ export const createContentEditor = ({
const eventHub = eventHubFactory(); const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio, Audio,
...@@ -94,7 +91,7 @@ export const createContentEditor = ({ ...@@ -94,7 +91,7 @@ export const createContentEditor = ({
BulletList, BulletList,
Code, Code,
ColorChip, ColorChip,
CodeBlockHighlight.configure({ lowlight, languageLoader }), CodeBlockHighlight,
DescriptionItem, DescriptionItem,
DescriptionList, DescriptionList,
Details, Details,
...@@ -108,7 +105,7 @@ export const createContentEditor = ({ ...@@ -108,7 +105,7 @@ export const createContentEditor = ({
FootnoteDefinition, FootnoteDefinition,
FootnoteReference, FootnoteReference,
FootnotesSection, FootnotesSection,
Frontmatter.configure({ lowlight }), Frontmatter,
Gapcursor, Gapcursor,
HardBreak, HardBreak,
Heading, Heading,
...@@ -147,5 +144,5 @@ export const createContentEditor = ({ ...@@ -147,5 +144,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig }); const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
}; };
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; import { createTestEditor } from '../test_utils';
const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
<code> <code>
...@@ -12,78 +12,34 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language ...@@ -12,78 +12,34 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
describe('content_editor/extensions/code_block_highlight', () => { describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture; let parsedCodeBlockHtmlFixture;
let tiptapEditor; let tiptapEditor;
let doc;
let codeBlock;
let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => { beforeEach(() => {
languageLoader = { loadLanguages: jest.fn() }; tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
tiptapEditor = createTestEditor({ parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
extensions: [CodeBlockHighlight.configure({ languageLoader })],
});
({ tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
builders: { doc, codeBlock },
} = createDocBuilder({
tiptapEditor,
names: {
codeBlock: { nodeType: CodeBlockHighlight.name },
},
}));
}); });
describe('when parsing HTML', () => { it('extracts language and params attributes from Markdown API output', () => {
beforeEach(() => { const language = preElement().getAttribute('lang');
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
});
it('extracts language and params attributes from Markdown API output', () => {
const language = preElement().getAttribute('lang');
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
});
});
it('adds code, highlight, and js-syntax-highlight to code block element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
});
it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
}); });
}); });
describe.each` it('adds code, highlight, and js-syntax-highlight to code block element', () => {
inputRule const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
${'```'}
${'~~~'}
`('when typing $inputRule input rule', ({ inputRule }) => {
const language = 'javascript';
beforeEach(() => {
triggerNodeInputRule({
tiptapEditor,
inputRuleText: `${inputRule}${language} `,
});
});
it('creates a new code block and loads related language', () => { expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
const expectedDoc = doc(codeBlock({ language })); });
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); it('adds content-editor-code-block class to the pre element', () => {
}); const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
it('loads language when language loader is available', () => { expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]);
});
}); });
}); });
import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
describe('content_editor/services/code_block_language_loader', () => {
let languageLoader;
let lowlight;
beforeEach(() => {
lowlight = {
languages: [],
registerLanguage: jest
.fn()
.mockImplementation((language) => lowlight.languages.push(language)),
registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
};
languageLoader = new CodeBlockLanguageBlocker(lowlight);
});
describe('loadLanguages', () => {
it('loads highlight.js language packages identified by a list of languages', async () => {
const languages = ['javascript', 'ruby'];
await languageLoader.loadLanguages(languages);
languages.forEach((language) => {
expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
});
});
describe('when language is already registered', () => {
it('does not load the language again', async () => {
const languages = ['javascript'];
await languageLoader.loadLanguages(languages);
await languageLoader.loadLanguages(languages);
expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1);
});
});
});
describe('loadLanguagesFromDOM', () => {
it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => {
const parser = new DOMParser();
const { body } = parser.parseFromString(
`
<pre lang="javascript"></pre>
<pre lang="ruby"></pre>
`,
'text/html',
);
await languageLoader.loadLanguagesFromDOM(body);
expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
});
});
describe('isLanguageLoaded', () => {
it('returns true when a language is registered', async () => {
const language = 'javascript';
expect(languageLoader.isLanguageLoaded(language)).toBe(false);
await languageLoader.loadLanguages([language]);
expect(languageLoader.isLanguageLoaded(language)).toBe(true);
});
});
});
...@@ -11,7 +11,6 @@ describe('content_editor/services/content_editor', () => { ...@@ -11,7 +11,6 @@ describe('content_editor/services/content_editor', () => {
let contentEditor; let contentEditor;
let serializer; let serializer;
let deserializer; let deserializer;
let languageLoader;
let eventHub; let eventHub;
let doc; let doc;
let p; let p;
...@@ -28,15 +27,8 @@ describe('content_editor/services/content_editor', () => { ...@@ -28,15 +27,8 @@ describe('content_editor/services/content_editor', () => {
serializer = { deserialize: jest.fn() }; serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() };
languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory(); eventHub = eventHubFactory();
contentEditor = new ContentEditor({ contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
tiptapEditor,
serializer,
deserializer,
eventHub,
languageLoader,
});
}); });
describe('.dispose', () => { describe('.dispose', () => {
...@@ -51,12 +43,10 @@ describe('content_editor/services/content_editor', () => { ...@@ -51,12 +43,10 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => { describe('when setSerializedContent succeeds', () => {
let document; let document;
const dom = {};
const testMarkdown = '**bold text**';
beforeEach(() => { beforeEach(() => {
document = doc(p('document')); document = doc(p('document'));
deserializer.deserialize.mockResolvedValueOnce({ document, dom }); deserializer.deserialize.mockResolvedValueOnce({ document });
}); });
it('emits loadingContent and loadingSuccess event in the eventHub', () => { it('emits loadingContent and loadingSuccess event in the eventHub', () => {
...@@ -69,20 +59,14 @@ describe('content_editor/services/content_editor', () => { ...@@ -69,20 +59,14 @@ describe('content_editor/services/content_editor', () => {
expect(loadingContentEmitted).toBe(true); expect(loadingContentEmitted).toBe(true);
}); });
contentEditor.setSerializedContent(testMarkdown); contentEditor.setSerializedContent('**bold text**');
}); });
it('sets the deserialized document in the tiptap editor object', async () => { it('sets the deserialized document in the tiptap editor object', async () => {
await contentEditor.setSerializedContent(testMarkdown); await contentEditor.setSerializedContent('**bold text**');
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
}); });
it('passes deserialized DOM document to language loader', async () => {
await contentEditor.setSerializedContent(testMarkdown);
expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
});
}); });
describe('when setSerializedContent fails', () => { describe('when setSerializedContent fails', () => {
......
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