Commit af408f37 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'split-markdown-serializer-and-markdown-deserializer' into 'master'

Separate Markdown Serializer and Deserializer in the Content Editor

See merge request gitlab-org/gitlab!81034
parents 31ab4415 9354eee3
import { TextSelection } from 'prosemirror-state';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
export class ContentEditor { export class ContentEditor {
constructor({ tiptapEditor, serializer, eventHub }) { constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
this._tiptapEditor = tiptapEditor; this._tiptapEditor = tiptapEditor;
this._serializer = serializer; this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub; this._eventHub = eventHub;
} }
...@@ -31,15 +34,22 @@ export class ContentEditor { ...@@ -31,15 +34,22 @@ export class ContentEditor {
} }
async setSerializedContent(serializedContent) { async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this; const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size);
try { try {
eventHub.$emit(LOADING_CONTENT_EVENT); eventHub.$emit(LOADING_CONTENT_EVENT);
const document = await serializer.deserialize({ const newDoc = await deserializer.deserialize({
schema: editor.schema, schema: editor.schema,
content: serializedContent, content: serializedContent,
}); });
editor.commands.setContent(document); if (newDoc) {
tr.setSelection(selection)
.replaceSelectionWith(newDoc, 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);
......
...@@ -55,6 +55,7 @@ import Video from '../extensions/video'; ...@@ -55,6 +55,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break'; import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor'; import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer'; import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const createTiptapEditor = ({ extensions = [], ...options } = {}) => const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
...@@ -138,7 +139,8 @@ export const createContentEditor = ({ ...@@ -138,7 +139,8 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions]; const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
return new ContentEditor({ tiptapEditor, serializer, eventHub }); return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
}; };
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
export default ({ render }) => {
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
* @returns A ProseMirror JSONDocument
*/
return {
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) return null;
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
body.append(document.createComment(content));
return ProseMirrorDOMParser.fromSchema(schema).parse(body);
},
};
};
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import { import {
MarkdownSerializer as ProseMirrorMarkdownSerializer, MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer, defaultMarkdownSerializer,
...@@ -237,31 +236,7 @@ const defaultSerializerConfig = { ...@@ -237,31 +236,7 @@ const defaultSerializerConfig = {
* that parses the Markdown and converts it into HTML. * that parses the Markdown and converts it into HTML.
* @returns a markdown serializer * @returns a markdown serializer
*/ */
export default ({ render = () => null, serializerConfig = {} } = {}) => ({ export default ({ serializerConfig = {} } = {}) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
* @returns A ProseMirror JSONDocument
*/
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) return null;
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
body.append(document.createComment(content));
const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state.toJSON();
},
/** /**
* Converts a ProseMirror JSONDocument based * Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown * on a ProseMirror schema into Markdown
......
...@@ -10,6 +10,7 @@ import { createTestEditor } from '../test_utils'; ...@@ -10,6 +10,7 @@ import { createTestEditor } from '../test_utils';
describe('content_editor/services/content_editor', () => { describe('content_editor/services/content_editor', () => {
let contentEditor; let contentEditor;
let serializer; let serializer;
let deserializer;
let eventHub; let eventHub;
beforeEach(() => { beforeEach(() => {
...@@ -17,8 +18,9 @@ describe('content_editor/services/content_editor', () => { ...@@ -17,8 +18,9 @@ describe('content_editor/services/content_editor', () => {
jest.spyOn(tiptapEditor, 'destroy'); jest.spyOn(tiptapEditor, 'destroy');
serializer = { deserialize: jest.fn() }; serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
eventHub = eventHubFactory(); eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub }); contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
}); });
describe('.dispose', () => { describe('.dispose', () => {
...@@ -33,7 +35,7 @@ describe('content_editor/services/content_editor', () => { ...@@ -33,7 +35,7 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => { describe('when setSerializedContent succeeds', () => {
beforeEach(() => { beforeEach(() => {
serializer.deserialize.mockResolvedValueOnce(''); deserializer.deserialize.mockResolvedValueOnce('');
}); });
it('emits loadingContent and loadingSuccess event in the eventHub', () => { it('emits loadingContent and loadingSuccess event in the eventHub', () => {
...@@ -54,7 +56,7 @@ describe('content_editor/services/content_editor', () => { ...@@ -54,7 +56,7 @@ describe('content_editor/services/content_editor', () => {
const error = 'error'; const error = 'error';
beforeEach(() => { beforeEach(() => {
serializer.deserialize.mockRejectedValueOnce(error); deserializer.deserialize.mockRejectedValueOnce(error);
}); });
it('emits loadingError event', async () => { it('emits loadingError event', async () => {
......
import createMarkdownDeserializer from '~/content_editor/services/markdown_deserializer';
import Bold from '~/content_editor/extensions/bold';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/services/markdown_deserializer', () => {
let renderMarkdown;
let doc;
let p;
let bold;
let tiptapEditor;
beforeEach(() => {
tiptapEditor = createTestEditor({
extensions: [Bold],
});
({
builders: { doc, p, bold },
} = createDocBuilder({
tiptapEditor,
names: {
bold: { markType: Bold.name },
},
}));
renderMarkdown = jest.fn();
});
it('transforms HTML returned by render function to a ProseMirror document', async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
const expectedDoc = doc(p(bold('Bold text')));
renderMarkdown.mockResolvedValueOnce('<p><strong>Bold text</strong></p>');
const result = await deserializer.deserialize({
content: 'content',
schema: tiptapEditor.schema,
});
expect(result.toJSON()).toEqual(expectedDoc.toJSON());
});
describe('when the render function returns an empty value', () => {
it('also returns null', async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
renderMarkdown.mockResolvedValueOnce(null);
expect(await deserializer.deserialize({ content: 'content' })).toBe(null);
});
});
});
...@@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; ...@@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core';
import BulletList from '~/content_editor/extensions/bullet_list'; import BulletList from '~/content_editor/extensions/bullet_list';
import ListItem from '~/content_editor/extensions/list_item'; import ListItem from '~/content_editor/extensions/list_item';
import Paragraph from '~/content_editor/extensions/paragraph'; import Paragraph from '~/content_editor/extensions/paragraph';
import markdownSerializer from '~/content_editor/services/markdown_serializer'; import markdownDeserializer from '~/content_editor/services/markdown_deserializer';
import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap'; import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap';
import { createTestEditor, createDocBuilder } from '../test_utils'; import { createTestEditor, createDocBuilder } from '../test_utils';
...@@ -53,9 +53,8 @@ const { ...@@ -53,9 +53,8 @@ const {
describe('content_editor/services/markdown_sourcemap', () => { describe('content_editor/services/markdown_sourcemap', () => {
it('gets markdown source for a rendered HTML element', async () => { it('gets markdown source for a rendered HTML element', async () => {
const deserialized = await markdownSerializer({ const deserialized = await markdownDeserializer({
render: () => BULLET_LIST_HTML, render: () => BULLET_LIST_HTML,
serializerConfig: {},
}).deserialize({ }).deserialize({
schema: tiptapEditor.schema, schema: tiptapEditor.schema,
content: BULLET_LIST_MARKDOWN, content: BULLET_LIST_MARKDOWN,
...@@ -76,6 +75,6 @@ describe('content_editor/services/markdown_sourcemap', () => { ...@@ -76,6 +75,6 @@ describe('content_editor/services/markdown_sourcemap', () => {
), ),
); );
expect(deserialized).toEqual(expected.toJSON()); expect(deserialized.toJSON()).toEqual(expected.toJSON());
}); });
}); });
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