From 74bfdc5befe9492aff76e58b78af2417c6a6d8e1 Mon Sep 17 00:00:00 2001 From: Nick Perez Date: Tue, 4 Jun 2024 09:32:54 +0200 Subject: [PATCH] feat: error handling of invalid content for a schema (#5178) This change introduces two new top-level options to the editor: `enableContentCheck` & `onContentError` for dealing with content supplied that does not match the prose-mirror schema generated by the set of tiptap extensions. `enableContentCheck` allows the app developer to opt into the behavior to check for invalid schemas (this change is otherwise backwards compatible). When true, this will try to parse the document, and any content that does not match the schema will emit a `contentError` which can be listened to via the `onContentError` callback. --- docs/api/events.md | 12 + docs/api/schema.md | 2 +- packages/core/src/Editor.ts | 49 +++- packages/core/src/EventEmitter.ts | 2 +- packages/core/src/commands/insertContentAt.ts | 32 ++- packages/core/src/commands/setContent.ts | 24 +- packages/core/src/helpers/createDocument.ts | 7 +- .../core/src/helpers/createNodeFromContent.ts | 44 +++- packages/core/src/types.ts | 21 ++ packages/react/src/useEditor.ts | 9 + .../core/createNodeFromContent.spec.ts | 245 ++++++++++++++++++ .../integration/core/onContentError.spec.ts | 177 +++++++++++++ 12 files changed, 606 insertions(+), 18 deletions(-) create mode 100644 tests/cypress/integration/core/createNodeFromContent.spec.ts create mode 100644 tests/cypress/integration/core/onContentError.spec.ts diff --git a/docs/api/events.md b/docs/api/events.md index 35d7f760b..63b849bc9 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -33,6 +33,8 @@ The editor isn’t focused anymore. ### destroy The editor is being destroyed. +### contentError +The content does not match the schema. ## Register event listeners There are three ways to register event listeners. @@ -66,6 +68,9 @@ const editor = new Editor({ onDestroy() { // The editor is being destroyed. }, + onContentError({ editor, error, disableCollaboration }) { + // The editor content does not match the schema. + }, }) ``` @@ -105,6 +110,10 @@ editor.on('blur', ({ editor, event }) => { editor.on('destroy', () => { // The editor is being destroyed. }) + +editor.on('contentError', ({ editor, error, disableCollaboration }) => { + // The editor content does not match the schema. +}) ``` #### Unbind event listeners @@ -153,5 +162,8 @@ const CustomExtension = Extension.create({ onDestroy() { // The editor is being destroyed. }, + onContentError({ editor, error, disableCollaboration }) { + // The editor content does not match the schema. + }, }) ``` diff --git a/docs/api/schema.md b/docs/api/schema.md index 7597bfc62..3591a76e5 100644 --- a/docs/api/schema.md +++ b/docs/api/schema.md @@ -9,7 +9,7 @@ Unlike many other editors, Tiptap is based on a [schema](https://prosemirror.net This schema is *very* strict. You can’t use any HTML element or attribute that is not defined in your schema. -Let me give you one example: If you paste something like `This is important` into Tiptap, but don’t have any extension that handles `strong` tags, you’ll only see `This is important` – without the strong tags. +Let me give you one example: If you paste something like `This is important` into Tiptap, but don’t have any extension that handles `strong` tags, you’ll only see `This is important` – without the strong tags. If you want to know when this happens, you can listen to the [`contentError`](/api/events#contenterror) event after enabling the `enableContentCheck` option. ## How a schema looks like When you’ll work with the provided extensions only, you don’t have to care that much about the schema. If you’re building your own extensions, it’s probably helpful to understand how the schema works. Let’s look at the most simple schema for a typical ProseMirror editor: diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index e461b3cd8..64308814b 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -1,5 +1,8 @@ import { - MarkType, NodeType, Schema, + MarkType, + Node as ProseMirrorNode, + NodeType, + Schema, } from '@tiptap/pm/model' import { EditorState, Plugin, PluginKey, Transaction, @@ -36,8 +39,10 @@ import { isFunction } from './utilities/isFunction.js' export * as extensions from './extensions/index.js' -export interface HTMLElement { - editor?: Editor +declare global { + interface HTMLElement { + editor?: Editor; + } } export class Editor extends EventEmitter { @@ -69,6 +74,7 @@ export class Editor extends EventEmitter { enableInputRules: true, enablePasteRules: true, enableCoreExtensions: true, + enableContentCheck: false, onBeforeCreate: () => null, onCreate: () => null, onUpdate: () => null, @@ -77,6 +83,7 @@ export class Editor extends EventEmitter { onFocus: () => null, onBlur: () => null, onDestroy: () => null, + onContentError: ({ error }) => { throw error }, } constructor(options: Partial = {}) { @@ -87,6 +94,7 @@ export class Editor extends EventEmitter { this.createSchema() this.on('beforeCreate', this.options.onBeforeCreate) this.emit('beforeCreate', { editor: this }) + this.on('contentError', this.options.onContentError) this.createView() this.injectCSS() this.on('create', this.options.onCreate) @@ -276,7 +284,40 @@ export class Editor extends EventEmitter { * Creates a ProseMirror view. */ private createView(): void { - const doc = createDocument(this.options.content, this.schema, this.options.parseOptions) + let doc: ProseMirrorNode + + try { + doc = createDocument( + this.options.content, + this.schema, + this.options.parseOptions, + { errorOnInvalidContent: this.options.enableContentCheck }, + ) + } catch (e) { + if (!(e instanceof Error) || !['[tiptap error]: Invalid JSON content', '[tiptap error]: Invalid HTML content'].includes(e.message)) { + // Not the content error we were expecting + throw e + } + this.emit('contentError', { + editor: this, + error: e as Error, + disableCollaboration: () => { + // To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension + this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration') + + // Restart the initialization process by recreating the extension manager with the new set of extensions + this.createExtensionManager() + }, + }) + + // Content is invalid, but attempt to create it anyway, stripping out the invalid parts + doc = createDocument( + this.options.content, + this.schema, + this.options.parseOptions, + { errorOnInvalidContent: false }, + ) + } const selection = resolveFocusPosition(doc, this.options.autofocus) this.view = new EditorView(this.options.element, { diff --git a/packages/core/src/EventEmitter.ts b/packages/core/src/EventEmitter.ts index 09ff510b8..595374bab 100644 --- a/packages/core/src/EventEmitter.ts +++ b/packages/core/src/EventEmitter.ts @@ -22,7 +22,7 @@ export class EventEmitter> { return this } - protected emit>(event: EventName, ...args: CallbackType): this { + public emit>(event: EventName, ...args: CallbackType): this { const callbacks = this.callbacks[event] if (callbacks) { diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index 97232a12e..8bf289dd5 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -35,8 +35,21 @@ declare module '@tiptap/core' { * Whether to update the selection after inserting the content. */ updateSelection?: boolean + + /** + * Whether to apply input rules after inserting the content. + */ applyInputRules?: boolean + + /** + * Whether to apply paste rules after inserting the content. + */ applyPasteRules?: boolean + + /** + * Whether to throw an error if the content is invalid. + */ + errorOnInvalidContent?: boolean }, ) => ReturnType } @@ -57,12 +70,19 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, ...options, } - const content = createNodeFromContent(value, editor.schema, { - parseOptions: { - preserveWhitespace: 'full', - ...options.parseOptions, - }, - }) + let content: Fragment | ProseMirrorNode + + try { + content = createNodeFromContent(value, editor.schema, { + parseOptions: { + preserveWhitespace: 'full', + ...options.parseOptions, + }, + errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, + }) + } catch (e) { + return false + } // don’t dispatch an empty fragment because this can lead to strange errors if (content.toString() === '<>') { diff --git a/packages/core/src/commands/setContent.ts b/packages/core/src/commands/setContent.ts index 36fd75c3c..cd26fa6f8 100644 --- a/packages/core/src/commands/setContent.ts +++ b/packages/core/src/commands/setContent.ts @@ -1,4 +1,4 @@ -import { ParseOptions } from '@tiptap/pm/model' +import { Fragment, Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model' import { createDocument } from '../helpers/createDocument.js' import { Content, RawCommands } from '../types.js' @@ -30,14 +30,32 @@ declare module '@tiptap/core' { * @default {} */ parseOptions?: ParseOptions, + /** + * Options for `setContent`. + */ + options?: { + /** + * Whether to throw an error if the content is invalid. + */ + errorOnInvalidContent?: boolean + }, ) => ReturnType } } } -export const setContent: RawCommands['setContent'] = (content, emitUpdate = false, parseOptions = {}) => ({ tr, editor, dispatch }) => { +export const setContent: RawCommands['setContent'] = (content, emitUpdate = false, parseOptions = {}, options = {}) => ({ tr, editor, dispatch }) => { const { doc } = tr - const document = createDocument(content, editor.schema, parseOptions) + + let document: Fragment | ProseMirrorNode + + try { + document = createDocument(content, editor.schema, parseOptions, { + errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, + }) + } catch (e) { + return false + } if (dispatch) { tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', !emitUpdate) diff --git a/packages/core/src/helpers/createDocument.ts b/packages/core/src/helpers/createDocument.ts index df80a36a8..44bcc669d 100644 --- a/packages/core/src/helpers/createDocument.ts +++ b/packages/core/src/helpers/createDocument.ts @@ -14,6 +14,11 @@ export function createDocument( content: Content, schema: Schema, parseOptions: ParseOptions = {}, + options: { errorOnInvalidContent?: boolean } = {}, ): ProseMirrorNode { - return createNodeFromContent(content, schema, { slice: false, parseOptions }) as ProseMirrorNode + return createNodeFromContent(content, schema, { + slice: false, + parseOptions, + errorOnInvalidContent: options.errorOnInvalidContent, + }) as ProseMirrorNode } diff --git a/packages/core/src/helpers/createNodeFromContent.ts b/packages/core/src/helpers/createNodeFromContent.ts index f44d07ff4..86de3943d 100644 --- a/packages/core/src/helpers/createNodeFromContent.ts +++ b/packages/core/src/helpers/createNodeFromContent.ts @@ -12,6 +12,7 @@ import { elementFromString } from '../utilities/elementFromString.js' export type CreateNodeFromContentOptions = { slice?: boolean parseOptions?: ParseOptions + errorOnInvalidContent?: boolean } /** @@ -46,6 +47,10 @@ export function createNodeFromContent( return schema.nodeFromJSON(content) } catch (error) { + if (options.errorOnInvalidContent) { + throw new Error('[tiptap error]: Invalid JSON content', { cause: error as Error }) + } + console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) return createNodeFromContent('', schema, options) @@ -53,11 +58,46 @@ export function createNodeFromContent( } if (isTextContent) { - const parser = DOMParser.fromSchema(schema) + let schemaToUse = schema + let hasInvalidContent = false - return options.slice + // Only ever check for invalid content if we're supposed to throw an error + if (options.errorOnInvalidContent) { + schemaToUse = new Schema({ + topNode: schema.spec.topNode, + marks: schema.spec.marks, + // Prosemirror's schemas are executed such that: the last to execute, matches last + // This means that we can add a catch-all node at the end of the schema to catch any content that we don't know how to handle + nodes: schema.spec.nodes.append({ + __tiptap__private__unknown__catch__all__node: { + content: 'inline*', + group: 'block', + parseDOM: [ + { + tag: '*', + getAttrs: () => { + // If this is ever called, we know that the content has something that we don't know how to handle in the schema + hasInvalidContent = true + return null + }, + }, + ], + }, + }), + }) + } + + const parser = DOMParser.fromSchema(schemaToUse) + + const response = options.slice ? parser.parseSlice(elementFromString(content), options.parseOptions).content : parser.parse(elementFromString(content), options.parseOptions) + + if (options.errorOnInvalidContent && hasInvalidContent) { + throw new Error('[tiptap error]: Invalid HTML content') + } + + return response } return createNodeFromContent('', schema, options) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 291cf3579..4ce217f7d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,15 @@ export type MaybeThisParameterType = Exclude extends (...args: export interface EditorEvents { beforeCreate: { editor: Editor } create: { editor: Editor } + contentError: { + editor: Editor, + error: Error, + /** + * If called, will re-initialize the editor with the collaboration extension removed. + * This will prevent syncing back deletions of content not present in the current schema. + */ + disableCollaboration: () => void + } update: { editor: Editor; transaction: Transaction } selectionUpdate: { editor: Editor; transaction: Transaction } transaction: { editor: Editor; transaction: Transaction } @@ -67,8 +76,20 @@ export interface EditorOptions { enableInputRules: EnableRules enablePasteRules: EnableRules enableCoreExtensions: boolean + /** + * If `true`, the editor will check the content for errors on initialization. + * Emitting the `contentError` event if the content is invalid. + * Which can be used to show a warning or error message to the user. + * @default false + */ + enableContentCheck: boolean onBeforeCreate: (props: EditorEvents['beforeCreate']) => void onCreate: (props: EditorEvents['create']) => void + /** + * Called when the editor encounters an error while parsing the content. + * Only enabled if `enableContentCheck` is `true`. + */ + onContentError: (props: EditorEvents['contentError']) => void onUpdate: (props: EditorEvents['update']) => void onSelectionUpdate: (props: EditorEvents['selectionUpdate']) => void onTransaction: (props: EditorEvents['transaction']) => void diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index c14114f8c..b962238ae 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -28,6 +28,7 @@ export const useEditor = (options: Partial = {}, deps: Dependency onSelectionUpdate, onTransaction, onUpdate, + onContentError, } = options const onBeforeCreateRef = useRef(onBeforeCreate) @@ -38,6 +39,7 @@ export const useEditor = (options: Partial = {}, deps: Dependency const onSelectionUpdateRef = useRef(onSelectionUpdate) const onTransactionRef = useRef(onTransaction) const onUpdateRef = useRef(onUpdate) + const onContentErrorRef = useRef(onContentError) // This effect will handle updating the editor instance // when the event handlers change. @@ -101,6 +103,13 @@ export const useEditor = (options: Partial = {}, deps: Dependency onUpdateRef.current = onUpdate } + + if (onContentError) { + editorRef.current.off('contentError', onContentErrorRef.current) + editorRef.current.on('contentError', onContentError) + + onContentErrorRef.current = onContentError + } }, [onBeforeCreate, onBlur, onCreate, onDestroy, onFocus, onSelectionUpdate, onTransaction, onUpdate, editorRef.current]) useEffect(() => { diff --git a/tests/cypress/integration/core/createNodeFromContent.spec.ts b/tests/cypress/integration/core/createNodeFromContent.spec.ts new file mode 100644 index 000000000..7f092fd50 --- /dev/null +++ b/tests/cypress/integration/core/createNodeFromContent.spec.ts @@ -0,0 +1,245 @@ +/// + +import { createNodeFromContent, getSchemaByResolvedExtensions } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' + +describe('createNodeFromContent', () => { + it('creates a fragment from a schema and HTML content', () => { + const content = '

Example Text

' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ])) + + expect(fragment.toJSON()).to.deep.eq([{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }]) + }) + + it('if `errorOnInvalidContent` is true, creates a fragment from a schema and HTML content', () => { + const content = '

Example Text

' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + + expect(fragment.toJSON()).to.deep.eq([{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }]) + }) + + it('creates a fragment from a schema and JSON content', () => { + const content = { + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + } + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ])) + + expect(fragment.toJSON()).to.deep.eq({ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }) + }) + + it('if `errorOnInvalidContent` is true, creates a fragment from a schema and JSON content', () => { + const content = { + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + } + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + + expect(fragment.toJSON()).to.deep.eq({ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }) + }) + + it('creates a fragment from a schema and JSON array of content', () => { + const content = [{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }, { + type: 'paragraph', + content: [{ + type: 'text', + text: 'More Text', + }], + }] + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ])) + + expect(fragment.toJSON()).to.deep.eq([{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }, { + type: 'paragraph', + content: [{ + type: 'text', + text: 'More Text', + }], + }]) + }) + + it('if `errorOnInvalidContent` is true, creates a fragment from a schema and JSON array of content', () => { + const content = [{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }, { + type: 'paragraph', + content: [{ + type: 'text', + text: 'More Text', + }], + }] + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + + expect(fragment.toJSON()).to.deep.eq([{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }, { + type: 'paragraph', + content: [{ + type: 'text', + text: 'More Text', + }], + }]) + }) + + it('returns empty content when a schema does not have matching node types for JSON content', () => { + const content = { + type: 'non-existing-node-type', + content: [{ + type: 'text', + text: 'Example Text', + }], + } + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ])) + + expect(fragment.toJSON()).to.deep.eq(null) + }) + + it('returns empty content when a schema does not have matching node types for HTML content', () => { + const content = 'Example Text' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ])) + + expect(fragment.toJSON()).to.deep.eq([{ type: 'text', text: 'Example Text' }]) + }) + + it('if `errorOnInvalidContent` is true, will throw an error when a schema does not have matching node types for HTML content', () => { + const content = 'Example Text' + + expect(() => { + createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + }).to.throw('[tiptap error]: Invalid HTML content') + }) + + it('if `errorOnInvalidContent` is true, will throw an error when a schema does not have matching node types for JSON content', () => { + const content = { + type: 'non-existing-node-type', + content: [{ + type: 'text', + text: 'Example Text', + }], + } + + expect(() => { + createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + }).to.throw('[tiptap error]: Invalid JSON content') + }) + + it('if `errorOnInvalidContent` is true, will throw an error when a schema does not have matching mark types for JSON content', () => { + const content = { + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + marks: [{ + type: 'non-existing-mark-type', + }], + }], + } + + expect(() => { + createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { errorOnInvalidContent: true }) + }).to.throw('[tiptap error]: Invalid JSON content') + }) +}) diff --git a/tests/cypress/integration/core/onContentError.spec.ts b/tests/cypress/integration/core/onContentError.spec.ts new file mode 100644 index 000000000..1d78e68f6 --- /dev/null +++ b/tests/cypress/integration/core/onContentError.spec.ts @@ -0,0 +1,177 @@ +/// + +import { Editor, Extension } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' + +describe('onContentError', () => { + it('does not emit a contentError on invalid content (by default)', () => { + const json = { + invalid: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text], + onContentError: () => { + expect(false).to.eq(true) + }, + }) + + expect(editor.getText()).to.eq('') + }) + it('does not emit a contentError on invalid content (when enableContentCheck = false)', () => { + const json = { + invalid: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text], + enableContentCheck: false, + onContentError: () => { + expect(false).to.eq(true) + }, + }) + + expect(editor.getText()).to.eq('') + }) + it('emits a contentError on invalid content (when enableContentCheck = true)', done => { + const json = { + invalid: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text], + enableContentCheck: true, + onContentError: ({ error }) => { + expect(error.message).to.eq('[tiptap error]: Invalid JSON content') + done() + }, + }) + + expect(editor.getText()).to.eq('') + }) + + it('does not emit a contentError on valid content', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text], + enableContentCheck: true, + onContentError: () => { + expect(false).to.eq(true) + }, + }) + + expect(editor.getText()).to.eq('Example Text') + }) + + it('removes the collaboration extension when has invalid content (when enableContentCheck = true)', () => { + const json = { + invalid: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })], + enableContentCheck: true, + onContentError: args => { + args.disableCollaboration() + expect(args.editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined) + }, + }) + + expect(editor.getText()).to.eq('') + expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined) + }) + + it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })], + enableContentCheck: true, + onContentError: () => { + expect(true).to.eq(false) + }, + }) + + expect(editor.getText()).to.eq('Example Text') + expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.not.eq(undefined) + }) +})