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.
This commit is contained in:
Nick Perez 2024-06-04 09:32:54 +02:00 committed by GitHub
parent 1e562ec7da
commit 74bfdc5bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 606 additions and 18 deletions

View File

@ -33,6 +33,8 @@ The editor isnt 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.
},
})
```

View File

@ -9,7 +9,7 @@ Unlike many other editors, Tiptap is based on a [schema](https://prosemirror.net
This schema is *very* strict. You cant 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 <strong>important</strong>` into Tiptap, but dont have any extension that handles `strong` tags, youll only see `This is important` without the strong tags.
Let me give you one example: If you paste something like `This is <strong>important</strong>` into Tiptap, but dont have any extension that handles `strong` tags, youll 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 youll work with the provided extensions only, you dont have to care that much about the schema. If youre building your own extensions, its probably helpful to understand how the schema works. Lets look at the most simple schema for a typical ProseMirror editor:

View File

@ -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<EditorEvents> {
@ -69,6 +74,7 @@ export class Editor extends EventEmitter<EditorEvents> {
enableInputRules: true,
enablePasteRules: true,
enableCoreExtensions: true,
enableContentCheck: false,
onBeforeCreate: () => null,
onCreate: () => null,
onUpdate: () => null,
@ -77,6 +83,7 @@ export class Editor extends EventEmitter<EditorEvents> {
onFocus: () => null,
onBlur: () => null,
onDestroy: () => null,
onContentError: ({ error }) => { throw error },
}
constructor(options: Partial<EditorOptions> = {}) {
@ -87,6 +94,7 @@ export class Editor extends EventEmitter<EditorEvents> {
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<EditorEvents> {
* 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, {

View File

@ -22,7 +22,7 @@ export class EventEmitter<T extends Record<string, any>> {
return this
}
protected emit<EventName extends StringKeyOf<T>>(event: EventName, ...args: CallbackType<T, EventName>): this {
public emit<EventName extends StringKeyOf<T>>(event: EventName, ...args: CallbackType<T, EventName>): this {
const callbacks = this.callbacks[event]
if (callbacks) {

View File

@ -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
}
// dont dispatch an empty fragment because this can lead to strange errors
if (content.toString() === '<>') {

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -39,6 +39,15 @@ export type MaybeThisParameterType<T> = Exclude<T, Primitive> 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

View File

@ -28,6 +28,7 @@ export const useEditor = (options: Partial<EditorOptions> = {}, deps: Dependency
onSelectionUpdate,
onTransaction,
onUpdate,
onContentError,
} = options
const onBeforeCreateRef = useRef(onBeforeCreate)
@ -38,6 +39,7 @@ export const useEditor = (options: Partial<EditorOptions> = {}, 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<EditorOptions> = {}, 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(() => {

View File

@ -0,0 +1,245 @@
/// <reference types="cypress" />
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 = '<p>Example Text</p>'
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 = '<p>Example Text</p>'
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 = '<non-existing-node-type>Example Text</non-existing-node-type>'
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 = '<non-existing-node-type>Example Text</non-existing-node-type>'
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')
})
})

View File

@ -0,0 +1,177 @@
/// <reference types="cypress" />
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)
})
})