Commit 9354eee3 authored by Enrique Alcantara's avatar Enrique Alcantara

Separate Markdown Serializer and Deserializers

Split the Markdown Serializer and Deserializer
concerns in different modules. This allows us
to reuse any of these concerns in other parts
of the Content Editor without having to provide
the dependencies expected by both modules
at the same time
parent 84d6de14
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