diff --git a/.changeset/smart-rockets-divide.md b/.changeset/smart-rockets-divide.md new file mode 100644 index 00000000000..4bea0af2c0c --- /dev/null +++ b/.changeset/smart-rockets-divide.md @@ -0,0 +1,6 @@ +--- +"@tiptap/core": patch +"@tiptap/extension-placeholder": patch +--- + +This addresses an issue with `isNodeEmpty` function where it was also comparing node attributes and finding mismatches on actually empty nodes. This helps placeholders find empty content correctly diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index 8b621e7f2f9..cd04cafc224 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -39,6 +39,7 @@ import { isFunction } from './utilities/isFunction.js' export * as extensions from './extensions/index.js' +// @ts-ignore export interface TiptapEditorHTMLElement extends HTMLElement { editor?: Editor } @@ -340,6 +341,7 @@ export class Editor extends EventEmitter { // Let’s store the editor instance in the DOM element. // So we’ll have access to it for tests. + // @ts-ignore const dom = this.view.dom as TiptapEditorHTMLElement dom.editor = this diff --git a/packages/core/src/helpers/isNodeEmpty.ts b/packages/core/src/helpers/isNodeEmpty.ts index a12511add0a..5d7a777d4e6 100644 --- a/packages/core/src/helpers/isNodeEmpty.ts +++ b/packages/core/src/helpers/isNodeEmpty.ts @@ -1,11 +1,41 @@ import { Node as ProseMirrorNode } from '@tiptap/pm/model' -export function isNodeEmpty(node: ProseMirrorNode): boolean { - const defaultContent = node.type.createAndFill(node.attrs) +/** + * Returns true if the given node is empty. + * When `checkChildren` is true (default), it will also check if all children are empty. + */ +export function isNodeEmpty( + node: ProseMirrorNode, + { checkChildren }: { checkChildren: boolean } = { checkChildren: true }, +): boolean { + if (node.isText) { + return !node.text + } + + if (node.content.childCount === 0) { + return true + } - if (!defaultContent) { + if (node.isLeaf) { return false } - return node.eq(defaultContent) + if (checkChildren) { + let hasSameContent = true + + node.content.forEach(childNode => { + if (hasSameContent === false) { + // Exit early for perf + return + } + + if (!isNodeEmpty(childNode)) { + hasSameContent = false + } + }) + + return hasSameContent + } + + return false } diff --git a/tests/cypress/integration/core/isNodeEmpty.spec.ts b/tests/cypress/integration/core/isNodeEmpty.spec.ts new file mode 100644 index 00000000000..7db88996d68 --- /dev/null +++ b/tests/cypress/integration/core/isNodeEmpty.spec.ts @@ -0,0 +1,183 @@ +/// + +import { getSchema, isNodeEmpty } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Image from '@tiptap/extension-image' +import StarterKit from '@tiptap/starter-kit' + +const schema = getSchema([StarterKit]) +const modifiedSchema = getSchema([StarterKit.configure({ document: false }), Document.extend({ content: 'heading block*' })]) +const imageSchema = getSchema([StarterKit.configure({ document: false }), Document.extend({ content: 'image block*' }), Image]) + +describe('isNodeEmpty', () => { + describe('with default schema', () => { + it('should return false when text has content', () => { + const node = schema.nodeFromJSON({ type: 'text', text: 'Hello world!' }) + + expect(isNodeEmpty(node)).to.eq(false) + }) + + it('should return false when a paragraph has text', () => { + const node = schema.nodeFromJSON({ + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world!' }], + }) + + expect(isNodeEmpty(node)).to.eq(false) + }) + + it('should return true when a paragraph has no content', () => { + const node = schema.nodeFromJSON({ + type: 'paragraph', + content: [], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('should return true when a paragraph has additional attrs & no content', () => { + const node = schema.nodeFromJSON({ + type: 'paragraph', + content: [], + attrs: { + id: 'test', + }, + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('should return true when a paragraph has additional marks & no content', () => { + const node = schema.nodeFromJSON({ + type: 'paragraph', + content: [], + attrs: { + id: 'test', + }, + marks: [{ type: 'bold' }], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('should return false when a document has text', () => { + const node = schema.nodeFromJSON({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world!' }], + }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(false) + }) + it('should return true when a document has an empty paragraph', () => { + const node = schema.nodeFromJSON({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + }) + + describe('with modified schema', () => { + it('should return false when a document has a filled heading', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { + type: 'heading', + content: [ + { type: 'text', text: 'Hello world!' }, + ], + }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(false) + }) + + it('should return false when a document has a filled paragraph', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'heading' }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello world!' }, + ], + }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(false) + }) + + it('should return true when a document has an empty heading', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'heading', content: [] }, + { type: 'paragraph', content: [] }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('should return true when a document has an empty heading with attrs', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'heading', content: [], attrs: { level: 2 } }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('should return true when a document has an empty heading & paragraph', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'heading', content: [] }, + { type: 'paragraph', content: [] }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + it('should return true when a document has an empty heading & paragraph with attributes', () => { + const node = modifiedSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'heading', content: [], attrs: { id: 'test' } }, + { type: 'paragraph', content: [], attrs: { id: 'test' } }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + + it('can handle an image node', () => { + const node = imageSchema.nodeFromJSON({ + type: 'doc', + content: [ + { type: 'image', attrs: { src: 'https://examples.com' } }, + { type: 'heading', content: [] }, + ], + }) + + expect(isNodeEmpty(node)).to.eq(true) + }) + }) +})