mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-24 03:31:47 +08:00
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:
parent
1e562ec7da
commit
74bfdc5bef
@ -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.
|
||||
},
|
||||
})
|
||||
```
|
||||
|
@ -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 <strong>important</strong>` 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 <strong>important</strong>` 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:
|
||||
|
@ -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, {
|
||||
|
@ -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) {
|
||||
|
@ -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() === '<>') {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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(() => {
|
||||
|
245
tests/cypress/integration/core/createNodeFromContent.spec.ts
Normal file
245
tests/cypress/integration/core/createNodeFromContent.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
177
tests/cypress/integration/core/onContentError.spec.ts
Normal file
177
tests/cypress/integration/core/onContentError.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user