diff --git a/src/components/typist-editor.tsx b/src/components/typist-editor.tsx index 783d07f1..b47b87c1 100644 --- a/src/components/typist-editor.tsx +++ b/src/components/typist-editor.tsx @@ -8,8 +8,8 @@ import { ExtraEditorCommands } from '../extensions/core/extra-editor-commands/ex import { ViewEventHandlers, ViewEventHandlersOptions } from '../extensions/core/view-event-handlers' import { isMultilineDocument, isPlainTextDocument } from '../helpers/schema' import { useEditor } from '../hooks/use-editor' -import { createHTMLSerializer } from '../serializers/html/html' -import { createMarkdownSerializer } from '../serializers/markdown/markdown' +import { getHTMLSerializerInstance } from '../serializers/html/html' +import { getMarkdownSerializerInstance } from '../serializers/markdown/markdown' import { getAllNodesAttributesByType, resolveContentSelection } from './typist-editor.helper' @@ -241,7 +241,6 @@ const TypistEditor = forwardRef(function Typ const allExtensions = useMemo( function initializeExtensions() { return [ - // Custom core extensions ...(placeholder ? [ Placeholder.configure({ @@ -271,13 +270,13 @@ const TypistEditor = forwardRef(function Typ const htmlSerializer = useMemo( function initializeHTMLSerializer() { - return createHTMLSerializer(schema) + return getHTMLSerializerInstance(schema) }, [schema], ) const markdownSerializer = useMemo( function initializeMarkdownSerializer() { - return createMarkdownSerializer(schema) + return getMarkdownSerializerInstance(schema) }, [schema], ) diff --git a/src/extensions/core/extra-editor-commands/commands/insert-markdown-content.ts b/src/extensions/core/extra-editor-commands/commands/insert-markdown-content.ts index 148c871c..d093834d 100644 --- a/src/extensions/core/extra-editor-commands/commands/insert-markdown-content.ts +++ b/src/extensions/core/extra-editor-commands/commands/insert-markdown-content.ts @@ -2,7 +2,7 @@ import { RawCommands } from '@tiptap/core' import { DOMParser } from 'prosemirror-model' import { parseHtmlToElement } from '../../../../helpers/dom' -import { createHTMLSerializer } from '../../../../serializers/html/html' +import { getHTMLSerializerInstance } from '../../../../serializers/html/html' import type { ParseOptions } from 'prosemirror-model' @@ -38,7 +38,7 @@ function insertMarkdownContent( // Check if the transaction should be dispatched // ref: https://tiptap.dev/api/commands#dry-run-for-commands if (dispatch) { - const htmlContent = createHTMLSerializer(editor.schema).serialize(markdown) + const htmlContent = getHTMLSerializerInstance(editor.schema).serialize(markdown) // Inserts the HTML content into the editor while preserving the current selection tr.replaceSelection( diff --git a/src/extensions/shared/copy-markdown-source.ts b/src/extensions/shared/copy-markdown-source.ts index 2f9f05ba..783e187a 100644 --- a/src/extensions/shared/copy-markdown-source.ts +++ b/src/extensions/shared/copy-markdown-source.ts @@ -1,6 +1,6 @@ import { Extension, getHTMLFromFragment } from '@tiptap/core' -import { createMarkdownSerializer } from '../../serializers/markdown/markdown' +import { getMarkdownSerializerInstance } from '../../serializers/markdown/markdown' /** * The options available to customize the `CopyMarkdownSource` extension. @@ -35,7 +35,7 @@ const CopyMarkdownSource = Extension.create({ ) // Serialize the selected content HTML to Markdown - const markdownContent = createMarkdownSerializer(editor.schema).serialize( + const markdownContent = getMarkdownSerializerInstance(editor.schema).serialize( getHTMLFromFragment(nodeSelection.content, editor.schema), ) diff --git a/src/helpers/schema.test.ts b/src/helpers/schema.test.ts index 145181fe..e57c72d8 100644 --- a/src/helpers/schema.test.ts +++ b/src/helpers/schema.test.ts @@ -3,7 +3,7 @@ import { getSchema } from '@tiptap/core' import { PlainTextKit } from '../extensions/plain-text/plain-text-kit' import { RichTextKit } from '../extensions/rich-text/rich-text-kit' -import { isMultilineDocument, isPlainTextDocument } from './schema' +import { computeSchemaId, isMultilineDocument, isPlainTextDocument } from './schema' describe('Helper: Schema', () => { describe('#isMultilineDocument', () => { @@ -57,4 +57,12 @@ describe('Helper: Schema', () => { expect(isPlainTextDocument(getSchema([RichTextKit]))).toBe(false) }) }) + + describe('#computeSchemaId', () => { + test('returns a string ID that matches the given editor schema', () => { + expect(computeSchemaId(getSchema([RichTextKit]))).toBe( + 'link,bold,code,italic,boldAndItalics,strike,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text', + ) + }) + }) }) diff --git a/src/helpers/schema.ts b/src/helpers/schema.ts index a5bf29b8..aacca6ae 100644 --- a/src/helpers/schema.ts +++ b/src/helpers/schema.ts @@ -22,4 +22,15 @@ function isPlainTextDocument(schema: Schema): boolean { return Boolean(schema.topNodeType.spec.content?.startsWith('paragraph')) } -export { isMultilineDocument, isPlainTextDocument } +/** + * Computes a string ID that identifies a given editor schema which can be used for object mapping. + * + * @param schema The current editor document schema. + * + * @returns A string ID matching the editor schema. + */ +function computeSchemaId(schema: Schema) { + return [...Object.keys(schema.marks), ...Object.keys(schema.nodes)].join() +} + +export { computeSchemaId, isMultilineDocument, isPlainTextDocument } diff --git a/src/index.ts b/src/index.ts index a26107f0..994de81a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,8 +29,11 @@ export type { } from './factories/create-suggestion-extension' export { createSuggestionExtension } from './factories/create-suggestion-extension' export { isMultilineDocument, isPlainTextDocument } from './helpers/schema' -export { createHTMLSerializer } from './serializers/html/html' -export { createMarkdownSerializer } from './serializers/markdown/markdown' +export { createHTMLSerializer, getHTMLSerializerInstance } from './serializers/html/html' +export { + createMarkdownSerializer, + getMarkdownSerializerInstance, +} from './serializers/markdown/markdown' export { canInsertNodeAt } from './utilities/can-insert-node-at' export { canInsertSuggestion } from './utilities/can-insert-suggestion' export type { AnyConfig, Editor as CoreEditor, EditorEvents, MarkRange, Range } from '@tiptap/core' diff --git a/src/serializers/html/html.test.ts b/src/serializers/html/html.test.ts index 49f76cbe..5016ed51 100644 --- a/src/serializers/html/html.test.ts +++ b/src/serializers/html/html.test.ts @@ -6,7 +6,7 @@ import { PlainTextKit } from '../../extensions/plain-text/plain-text-kit' import { RichTextKit } from '../../extensions/rich-text/rich-text-kit' import { createSuggestionExtension } from '../../factories/create-suggestion-extension' -import { createHTMLSerializer } from './html' +import { createHTMLSerializer, getHTMLSerializerInstance } from './html' import type { HTMLSerializerReturnType } from './html' @@ -242,6 +242,26 @@ const MARKDOWN_INPUT_TABLES = `| Syntax | Description | | Paragraph | Text | And more |` describe('HTML Serializer', () => { + describe('Singleton Instances', () => { + describe('when the editor schema for two HTML serializers are the same', () => { + test('`getHTMLSerializerInstance` returns the same instance', () => { + const htmlSerializerA = getHTMLSerializerInstance(getSchema([PlainTextKit])) + const htmlSerializerB = getHTMLSerializerInstance(getSchema([PlainTextKit])) + + expect(htmlSerializerA).toBe(htmlSerializerB) + }) + }) + + describe('when the editor schema for two HTML serializers are NOT the same', () => { + test('`getHTMLSerializerInstance` returns different instances', () => { + const htmlSerializerA = getHTMLSerializerInstance(getSchema([PlainTextKit])) + const htmlSerializerB = getHTMLSerializerInstance(getSchema([RichTextKit])) + + expect(htmlSerializerA).not.toBe(htmlSerializerB) + }) + }) + }) + describe('Plain-text Document', () => { describe('with default extensions', () => { let htmlSerializer: HTMLSerializerReturnType diff --git a/src/serializers/html/html.ts b/src/serializers/html/html.ts index 17f48c32..ab9d7215 100644 --- a/src/serializers/html/html.ts +++ b/src/serializers/html/html.ts @@ -2,7 +2,7 @@ import { escape, kebabCase } from 'lodash-es' import { marked } from 'marked' import { REGEX_LINE_BREAKS } from '../../constants/regular-expressions' -import { isPlainTextDocument } from '../../helpers/schema' +import { computeSchemaId, isPlainTextDocument } from '../../helpers/schema' import { buildSuggestionSchemaPartialRegex } from '../../helpers/serializer' import { checkbox } from './extensions/checkbox' @@ -29,6 +29,13 @@ type HTMLSerializerReturnType = { serialize: (markdown: string) => string } +/** + * The type for the object that holds multiple HTML serializer instances. + */ +type HTMLSerializerInstanceById = { + [id: string]: HTMLSerializerReturnType +} + /** * Sensible default options to initialize the Marked parser with. * @@ -135,6 +142,28 @@ function createHTMLSerializer(schema: Schema): HTMLSerializerReturnType { } } -export { createHTMLSerializer, INITIAL_MARKED_OPTIONS } +/** + * Object that holds multiple HTML serializer instances based on a given ID. + */ +const htmlSerializerInstanceById: HTMLSerializerInstanceById = {} + +/** + * Returns a singleton instance of a HTML serializer based on the provided editor schema. + * + * @param schema The editor schema connected to the HTML serializer instance. + * + * @returns The HTML serializer instance for the given editor schema. + */ +function getHTMLSerializerInstance(schema: Schema) { + const id = computeSchemaId(schema) + + if (!htmlSerializerInstanceById[id]) { + htmlSerializerInstanceById[id] = createHTMLSerializer(schema) + } + + return htmlSerializerInstanceById[id] +} + +export { createHTMLSerializer, getHTMLSerializerInstance, INITIAL_MARKED_OPTIONS } export type { HTMLSerializerReturnType } diff --git a/src/serializers/markdown/markdown.test.ts b/src/serializers/markdown/markdown.test.ts index 1c3f9f40..e2d4ae8b 100644 --- a/src/serializers/markdown/markdown.test.ts +++ b/src/serializers/markdown/markdown.test.ts @@ -7,7 +7,7 @@ import { PlainTextKit } from '../../extensions/plain-text/plain-text-kit' import { RichTextKit } from '../../extensions/rich-text/rich-text-kit' import { createSuggestionExtension } from '../../factories/create-suggestion-extension' -import { createMarkdownSerializer } from './markdown' +import { createMarkdownSerializer, getMarkdownSerializerInstance } from './markdown' import type { MarkdownSerializerReturnType } from './markdown' @@ -214,6 +214,26 @@ const HTML_INPUT_PONCTUATION_CHARACTERS = `

\\' text \\'

\\~ text \\~

` describe('Markdown Serializer', () => { + describe('Singleton Instances', () => { + describe('when the editor schema for two Markdown serializers are the same', () => { + test('`getMarkdownSerializerInstance` returns the same instance', () => { + const markdownSerializerA = getMarkdownSerializerInstance(getSchema([PlainTextKit])) + const markdownSerializerB = getMarkdownSerializerInstance(getSchema([PlainTextKit])) + + expect(markdownSerializerA).toBe(markdownSerializerB) + }) + }) + + describe('when the editor schema for two Markdown serializers are NOT the same', () => { + test('`getMarkdownSerializerInstance` returns different instances', () => { + const markdownSerializerA = getMarkdownSerializerInstance(getSchema([PlainTextKit])) + const markdownSerializerB = getMarkdownSerializerInstance(getSchema([RichTextKit])) + + expect(markdownSerializerA).not.toBe(markdownSerializerB) + }) + }) + }) + describe('Plain-text Document', () => { describe('with default extensions', () => { let markdownSerializer: MarkdownSerializerReturnType @@ -640,17 +660,21 @@ See the section on [\`code\`](#code).`, }) describe('without custom extensions', () => { - const markdownSerializer = createMarkdownSerializer( - getSchema([ - RichTextKit.configure({ - bulletList: false, - image: false, - listItem: false, - orderedList: false, - strike: false, - }), - ]), - ) + let markdownSerializer: MarkdownSerializerReturnType + + beforeEach(() => { + markdownSerializer = createMarkdownSerializer( + getSchema([ + RichTextKit.configure({ + bulletList: false, + image: false, + listItem: false, + orderedList: false, + strike: false, + }), + ]), + ) + }) test('ordered lists Markdown output is correct', () => { expect(markdownSerializer.serialize(HTML_INPUT_ORDERED_LISTS)).toBe(`1. First item diff --git a/src/serializers/markdown/markdown.ts b/src/serializers/markdown/markdown.ts index e3a79c01..c0113db8 100644 --- a/src/serializers/markdown/markdown.ts +++ b/src/serializers/markdown/markdown.ts @@ -1,7 +1,7 @@ import Turndown from 'turndown' import { REGEX_PUNCTUATION } from '../../constants/regular-expressions' -import { isPlainTextDocument } from '../../helpers/schema' +import { computeSchemaId, isPlainTextDocument } from '../../helpers/schema' import { image } from './plugins/image' import { listItem } from './plugins/list-item' @@ -26,6 +26,13 @@ type MarkdownSerializerReturnType = { serialize: (html: string) => string } +/** + * The type for the object that holds multiple Markdown serializer instances. + */ +type MarkdownSerializerInstanceById = { + [id: string]: MarkdownSerializerReturnType +} + /** * The bullet list marker for both standard and task list items. */ @@ -187,6 +194,28 @@ function createMarkdownSerializer(schema: Schema): MarkdownSerializerReturnType } } -export { BULLET_LIST_MARKER, createMarkdownSerializer } +/** + * Object that holds multiple Markdown serializer instances based on a given ID. + */ +const markdownSerializerInstanceById: MarkdownSerializerInstanceById = {} + +/** + * Returns a singleton instance of a Markdown serializer based on the provided editor schema. + * + * @param schema The editor schema connected to the Markdown serializer instance. + * + * @returns The Markdown serializer instance for the given editor schema. + */ +function getMarkdownSerializerInstance(schema: Schema) { + const id = computeSchemaId(schema) + + if (!markdownSerializerInstanceById[id]) { + markdownSerializerInstanceById[id] = createMarkdownSerializer(schema) + } + + return markdownSerializerInstanceById[id] +} + +export { BULLET_LIST_MARKER, createMarkdownSerializer, getMarkdownSerializerInstance } export type { MarkdownSerializerReturnType } diff --git a/stories/documentation/reference/serializers.md b/stories/documentation/reference/serializers.md new file mode 100644 index 00000000..f80237a2 --- /dev/null +++ b/stories/documentation/reference/serializers.md @@ -0,0 +1,13 @@ +# Serializers + +Unfortunately, Tiptap doesn't support Markdown as an input or output format, and while support for Markdown was considered, the Tiptap team decided against it (more information [here](https://tiptap.dev/guide/output#not-an-option-markdown)). However, at Doist, Markdown is a must, and that's why we implemented both an HTML and Markdown serializer to convert between both formats. + +Although the serializers are mostly meant to be used internally by the `TypistEditor` component and/or internal extensions, it's sometimes useful to have access to the same serializers externally for custom extensions. With that in mind, the `create*Serializer` and `get*Serializer` methods are publicly exported for both the HTML and Markdown serializers. + +## `get*Serializer` + +This function is the one everyone should be using most of the time because once a serializer is created for the first time, it will be cached and reused the next time this function is called. You shouldn't worry about using this function for multiple editors loaded with different extensions because the cache mechanism caches multiple serializers based on the given editor `schema`. + +## `create*Serializer` + +This function is the one that actually creates the serializer instance, and while it's used internally by the `get*Serializer` function, it's also available for public comsumption in the event of a very specific use case where it might be useful. Most of the time you should not need to call this function directly, but you should know that a new serializer instance will be created every time you do call this function directly, and you may incur in a small performance penalty. diff --git a/stories/documentation/reference/serializers.story.mdx b/stories/documentation/reference/serializers.story.mdx new file mode 100644 index 00000000..50aae551 --- /dev/null +++ b/stories/documentation/reference/serializers.story.mdx @@ -0,0 +1,17 @@ +import { Meta } from '@storybook/addon-docs' + +import { MarkdownRenderer } from '../../components/markdown-renderer.tsx' + +import rawSerializers from './serializers.md?raw' + + + +