From a963d4806f03c5c0f54e0bd8abccfafba098119d Mon Sep 17 00:00:00 2001 From: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:30:45 +0200 Subject: [PATCH 1/6] Revert "revert: undo unintended NodeView getPos changes (#8105)" (#8106) This reverts commit 3a942d8120067b88e251b692c7e32c11713cb292. --- .changeset/fix-nodeview-getpos-throw.md | 5 + .../core/__tests__/nodeViewGetPos.spec.ts | 40 +++ packages/core/src/NodeView.ts | 9 +- .../react/src/ReactNodeViewRenderer.spec.ts | 264 ++++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-nodeview-getpos-throw.md create mode 100644 packages/core/__tests__/nodeViewGetPos.spec.ts create mode 100644 packages/react/src/ReactNodeViewRenderer.spec.ts diff --git a/.changeset/fix-nodeview-getpos-throw.md b/.changeset/fix-nodeview-getpos-throw.md new file mode 100644 index 0000000000..5ea77b9813 --- /dev/null +++ b/.changeset/fix-nodeview-getpos-throw.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Node view `getPos()` now returns `undefined` instead of throwing when the position cannot be resolved yet, for example when React 19 renders a node view component while the editor view is still updating. diff --git a/packages/core/__tests__/nodeViewGetPos.spec.ts b/packages/core/__tests__/nodeViewGetPos.spec.ts new file mode 100644 index 0000000000..9ff4e0286a --- /dev/null +++ b/packages/core/__tests__/nodeViewGetPos.spec.ts @@ -0,0 +1,40 @@ +import { NodeView } from '@tiptap/core' +import type { NodeViewRendererProps } from '@tiptap/core' +import { describe, expect, it } from 'vitest' + +const createProps = (getPos: () => number | undefined) => { + return { + editor: {}, + extension: {}, + node: {}, + decorations: [], + innerDecorations: {}, + view: {}, + HTMLAttributes: {}, + getPos, + } as unknown as NodeViewRendererProps +} + +describe('NodeView getPos', () => { + it('returns the position from prosemirror', () => { + const nodeView = new NodeView( + null, + createProps(() => 7), + ) + + expect(nodeView.getPos()).toBe(7) + }) + + it('returns undefined instead of throwing while the view tree is mid-update', () => { + // prosemirror-view's posBeforeChild throws a TypeError when the node + // view desc is not attached to its parent yet. + const nodeView = new NodeView( + null, + createProps(() => { + throw new TypeError("Cannot read properties of undefined (reading 'size')") + }), + ) + + expect(nodeView.getPos()).toBeUndefined() + }) +}) diff --git a/packages/core/src/NodeView.ts b/packages/core/src/NodeView.ts index 5f50c7fe24..ab461cbc2f 100644 --- a/packages/core/src/NodeView.ts +++ b/packages/core/src/NodeView.ts @@ -51,7 +51,14 @@ export class NodeView< this.innerDecorations = props.innerDecorations this.view = props.view this.HTMLAttributes = props.HTMLAttributes - this.getPos = props.getPos + this.getPos = () => { + // ProseMirror throws while this node view is not attached to its parent yet. + try { + return props.getPos() + } catch { + return undefined + } + } this.mount() } diff --git a/packages/react/src/ReactNodeViewRenderer.spec.ts b/packages/react/src/ReactNodeViewRenderer.spec.ts new file mode 100644 index 0000000000..c1f27cae68 --- /dev/null +++ b/packages/react/src/ReactNodeViewRenderer.spec.ts @@ -0,0 +1,264 @@ +import { act, render } from '@testing-library/react' +import { Editor, Node } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import React, { useEffect, useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' + +import { EditorContent } from './EditorContent.js' +import { NodeViewContent } from './NodeViewContent.js' +import { NodeViewWrapper } from './NodeViewWrapper.js' +import { ReactNodeViewRenderer } from './ReactNodeViewRenderer.js' +import type { ReactNodeViewProps } from './types.js' + +const renderedPositions: Array = [] +const renderErrors: unknown[] = [] +const bumpHandles = new Map void>() +const explodingIds = new Set() + +// Optional hook run inside the click handler before the replace commands. +let beforeReplace: (() => void) | undefined + +const ContainerComponent = (props: ReactNodeViewProps) => { + const [, setTick] = useState(0) + const id = props.node.attrs.id as string + + useEffect(() => { + bumpHandles.set(id, () => setTick(tick => tick + 1)) + setTick(tick => tick + 1) + + return () => { + bumpHandles.delete(id) + } + }, [id]) + + if (explodingIds.has(id)) { + throw new Error(`render bomb in container ${id}`) + } + + // Calling getPos in the render path is what crashes with React 19. + try { + renderedPositions.push(props.getPos()) + } catch (error) { + renderErrors.push(error) + throw error + } + + const replaceSelf = () => { + beforeReplace?.() + + // setState first so React has pending sync work when the + // transactions below run. + setTick(tick => tick + 1) + + const pos = props.getPos() + + if (typeof pos !== 'number') { + return + } + + // The first transaction destroys this node view while its component is + // still mounted. The second one constructs a new node view, whose + // ReactRenderer flushSync then re-renders pending components while + // ProseMirror's view tree is mid-update. + props.editor.commands.deleteRange({ from: pos, to: pos + props.node.nodeSize }) + props.editor.commands.insertContentAt(0, { + type: 'container', + attrs: { id: 'fresh' }, + content: [{ type: 'item', content: [{ type: 'paragraph' }] }], + }) + } + + return React.createElement( + NodeViewWrapper, + null, + React.createElement('button', { + type: 'button', + 'data-testid': `replace-${id}`, + onClick: replaceSelf, + }), + React.createElement(NodeViewContent), + ) +} + +const ItemComponent = () => { + return React.createElement(NodeViewWrapper, null, React.createElement(NodeViewContent)) +} + +const Container = Node.create({ + name: 'container', + group: 'block', + content: 'item+', + + addAttributes() { + return { + id: { default: null }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="container"]' }] + }, + + renderHTML() { + return ['div', { 'data-type': 'container' }, 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(ContainerComponent) + }, +}) + +const Item = Node.create({ + name: 'item', + group: 'block', + content: 'paragraph+', + + parseHTML() { + return [{ tag: 'div[data-type="item"]' }] + }, + + renderHTML() { + return ['div', { 'data-type': 'item' }, 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(ItemComponent) + }, +}) + +const createEditorWithContainers = () => { + return new Editor({ + extensions: [Document, Paragraph, Text, Container, Item], + content: + '

first

' + + '

second

', + }) +} + +const flushMicrotasks = async () => { + await act(async () => { + await Promise.resolve() + }) +} + +const clickReplace = (id: string) => { + document + .querySelector(`[data-testid="replace-${id}"]`)! + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) +} + +describe('ReactNodeViewRenderer', () => { + afterEach(() => { + renderedPositions.length = 0 + renderErrors.length = 0 + bumpHandles.clear() + explodingIds.clear() + beforeReplace = undefined + document.body.innerHTML = '' + }) + + it('renders nested node views and resolves getPos during render', async () => { + const editor = createEditorWithContainers() + const { container } = render(React.createElement(EditorContent, { editor })) + + await flushMicrotasks() + + expect(container.querySelector('[data-node-view-wrapper]')).not.toBeNull() + expect(renderedPositions.length).toBeGreaterThan(0) + expect(renderedPositions).toContain(0) + + editor.destroy() + }) + + it('resolves getPos to undefined while the view desc is detached mid-update', async () => { + const editor = createEditorWithContainers() + const { container } = render(React.createElement(EditorContent, { editor })) + + await flushMicrotasks() + + // Recreate the state ProseMirror's view tree goes through while it + // updates: the desc has a parent but is not in parent.children yet. + // getPos then walks past the end of the children array and throws. + const desc = (container.querySelector('.react-renderer') as any).pmViewDesc + const siblings = desc.parent.children + + siblings.splice(siblings.indexOf(desc), 1) + + renderedPositions.length = 0 + + await act(async () => { + bumpHandles.get('a')?.() + }) + + // put the tree back so teardown works on a consistent view + siblings.unshift(desc) + + expect(renderErrors).toEqual([]) + expect(renderedPositions).toEqual([undefined]) + + editor.destroy() + }) + + it('does not crash when node views are created while React has pending updates', async () => { + const editor = createEditorWithContainers() + const { container } = render(React.createElement(EditorContent, { editor })) + + await flushMicrotasks() + + expect(() => clickReplace('b')).not.toThrow() + + await flushMicrotasks() + + // getPos must not blow up inside the synchronous React flush. + expect(renderErrors).toEqual([]) + + // The editor and the React tree must stay intact and usable. + expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh') + expect(container.querySelector('.tiptap')).not.toBeNull() + expect( + editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }), + ).toBe(true) + + editor.destroy() + }) + + it('keeps the editor intact when a component throws during the synchronous flush', async () => { + const editor = createEditorWithContainers() + + render(React.createElement(EditorContent, { editor })) + + await flushMicrotasks() + + // Make container "a" throw on its next render and give it a pending + // update, so the new node view's flushSync renders it mid-transaction. + beforeReplace = () => { + explodingIds.add('a') + bumpHandles.get('a')?.() + } + + let clickError: unknown + + try { + clickReplace('b') + } catch (error) { + clickError = error + } + + await flushMicrotasks() + + // React 19 reports the render error via onUncaughtError instead of + // rethrowing, so nothing may propagate into the ProseMirror transaction. + expect(clickError).toBeUndefined() + + // The ProseMirror transaction must complete even though a component + // render threw during the flush. + expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh') + expect( + editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }), + ).toBe(true) + + editor.destroy() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index c52a172907..64af9778ca 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -92,6 +92,6 @@ export default defineConfig({ }, esbuild: { jsx: 'automatic', - jsxImportSource: '@tiptap/core', + jsxImportSource: 'react', }, }) From bd47229f2556116c277dad8e3b9e7ad0f160769b Mon Sep 17 00:00:00 2001 From: Alex Casillas Date: Wed, 22 Jul 2026 13:51:20 +0200 Subject: [PATCH 2/6] fix(vue-3): use NodeViewContent element as contentDOM instead of nesting a wrapper (#7950) (#8107) * fix(vue-3): use NodeViewContent element as contentDOM instead of nesting a wrapper (#7950) `` nested a placeholder wrapper div inside the tbody, which is invalid HTML (tbody may only contain tr) and broke tables. The nodeViewContentRef callback now adopts the mounted element as the real contentDOM instead of appending the placeholder inside it. * Update packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts * Update packages/vue-3/src/VueNodeViewRenderer.ts * Update packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts --------- Co-authored-by: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> --- ...e-nodeview-content-tbody-quiet-lake-fox.md | 5 +++ .../__tests__/VueNodeViewRenderer.spec.ts | 38 ++++++++++++++++++- packages/vue-3/src/VueNodeViewRenderer.ts | 14 ++++--- 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-vue-nodeview-content-tbody-quiet-lake-fox.md diff --git a/.changeset/fix-vue-nodeview-content-tbody-quiet-lake-fox.md b/.changeset/fix-vue-nodeview-content-tbody-quiet-lake-fox.md new file mode 100644 index 0000000000..b39d558ae0 --- /dev/null +++ b/.changeset/fix-vue-nodeview-content-tbody-quiet-lake-fox.md @@ -0,0 +1,5 @@ +--- +"@tiptap/vue-3": patch +--- + +Fix `` (and similar restricted-content elements) rendering with an extra wrapper `
` nested inside them, which broke tables in Vue node views. Note: keep `` mounted (use `v-show`, not `v-if`) — conditionally remounting it can leave ProseMirror attached to the old element. diff --git a/packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts b/packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts index cd403b3b9b..34c4420037 100644 --- a/packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts +++ b/packages/vue-3/__tests__/VueNodeViewRenderer.spec.ts @@ -30,6 +30,13 @@ const ComponentWithContent = defineComponent({ components: { NodeViewWrapper, NodeViewContent }, }) +const ComponentWithTbodyContent = defineComponent({ + name: 'WithTbodyContent', + props: nodeViewProps, + template: '', + components: { NodeViewWrapper, NodeViewContent }, +}) + const LeafComponent = defineComponent({ name: 'LeafComponent', props: nodeViewProps, @@ -138,16 +145,43 @@ describe('VueNodeViewRenderer contentDOM', () => { expect(content!.textContent).toContain('Hello World') }) + it('adopts as the contentDOM instead of nesting a wrapper div inside it', async () => { + await withEditor('Hello World', ComponentWithTbodyContent) + const tbody = el!.querySelector('tbody[data-node-view-content]') + expect(tbody).toBeTruthy() + // A may only contain elements, so the placeholder wrapper div + // must never be nested inside it. + expect(tbody!.querySelector('[data-node-view-content-vue]')).toBeFalsy() + expect(tbody!.children.length).toBe(0) + expect(tbody!.textContent).toContain('Hello World') + }) + it('does not throw on destroy', async () => { await withEditor('Hello World', ComponentWithoutContent) expect(() => editor!.destroy()).not.toThrow() }) - it('use custom content dom element tag', async () => { + // Uses `` as `contentDOM`; `contentDOMElementTag` only defines the fallback when none is rendered. + it('ignores contentDOMElementTag when a real is present', async () => { await withEditor('Hello World', ComponentWithContent, false, { contentDOMElementTag: 'custom-content-dom', }) - const content = el!.querySelector('custom-content-dom') + expect(el!.querySelector('custom-content-dom')).toBeFalsy() + const content = el!.querySelector('[data-node-view-content]') expect(content).toBeTruthy() + expect(content!.tagName.toLowerCase()).toBe('div') + expect(content!.textContent).toContain('Hello World') + }) + + it('use custom content dom element tag as the fallback when there is no ', async () => { + await withEditor('Hello World', ComponentWithoutContent, false, { + contentDOMElementTag: 'custom-content-dom', + }) + // The fallback placeholder stays detached (see "does not thrash the DOM" above), + // so we assert on the internal contentDOM rather than querying the live DOM. + const wrapperDesc = (el!.querySelector('[data-node-view-wrapper]') as any)?.pmViewDesc + const contentDOM = wrapperDesc?.spec?.contentDOM as HTMLElement | undefined + expect(contentDOM).toBeTruthy() + expect(contentDOM!.tagName.toLowerCase()).toBe('custom-content-dom') }) }) diff --git a/packages/vue-3/src/VueNodeViewRenderer.ts b/packages/vue-3/src/VueNodeViewRenderer.ts index 7f29220c83..97fb211baa 100644 --- a/packages/vue-3/src/VueNodeViewRenderer.ts +++ b/packages/vue-3/src/VueNodeViewRenderer.ts @@ -162,12 +162,16 @@ class VueNodeView extends NodeView { - if (!this.contentDOMElement) return - - if (el && el.firstChild !== this.contentDOMElement) { - // NodeViewContent mounted: move the contentDOMElement inside it - el.appendChild(this.contentDOMElement) + if (!el || el === this.contentDOMElement) return + + // Adopt this stable element as contentDOM, preserving existing children. + // Do not conditionally remount it; use v-show instead of v-if). + if (this.contentDOMElement) { + while (this.contentDOMElement.firstChild) { + el.appendChild(this.contentDOMElement.firstChild) + } } + this.contentDOMElement = el }) return (this.component as any).setup?.(reactiveProps, { From 51f45b6b3193519cd0890c6180f70e82661042e0 Mon Sep 17 00:00:00 2001 From: Alex Casillas Date: Wed, 22 Jul 2026 13:51:56 +0200 Subject: [PATCH 3/6] fix(core): seed editor state from fallback doc so onContentError is usable (#7493) (#8108) createDoc() now parses the stripped fallback document and seeds this.editorState with it before emitting contentError, so editor.commands can be safely called from the handler on initial load. The constructor only builds editorState from initialDoc when it wasn't already seeded by the content-error path. --- ...enterror-editor-usable-quiet-lake-drift.md | 5 ++ .../core/__tests__/onContentError.spec.ts | 75 +++++++++++++++++++ packages/core/src/Editor.ts | 40 +++++++--- .../__tests__/filterInvalidContent.spec.ts | 27 +++++++ 4 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-oncontenterror-editor-usable-quiet-lake-drift.md diff --git a/.changeset/fix-oncontenterror-editor-usable-quiet-lake-drift.md b/.changeset/fix-oncontenterror-editor-usable-quiet-lake-drift.md new file mode 100644 index 0000000000..13805d57f1 --- /dev/null +++ b/.changeset/fix-oncontenterror-editor-usable-quiet-lake-drift.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Fixed `onContentError` throwing when calling `editor.commands` from inside the handler on initial load with invalid content. The editor now has a usable state (seeded from the stripped fallback document) before `onContentError` fires. diff --git a/packages/core/__tests__/onContentError.spec.ts b/packages/core/__tests__/onContentError.spec.ts index 1d5cbafe60..b02f356794 100644 --- a/packages/core/__tests__/onContentError.spec.ts +++ b/packages/core/__tests__/onContentError.spec.ts @@ -208,4 +208,79 @@ describe('onContentError', () => { expect(editor.getText()).toBe('Example Text') expect(editor.storage.collaboration.isDisabled).toBe(false) }) + + it('does not throw when calling setContent(valid) from the onContentError handler', () => { + const json = { + invalid: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], + } + + const validJson = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Recovered', + }, + ], + }, + ], + } + + let editor: Editor | undefined + + expect(() => { + editor = new Editor({ + content: json, + extensions: [Document, Paragraph, Text], + enableContentCheck: true, + onContentError: ({ editor: contentErrorEditor }) => { + contentErrorEditor.commands.setContent(validJson) + }, + }) + }).not.toThrow() + + expect(editor?.getText()).toBe('Recovered') + }) + + // Invalid HTML whose fallback keeps a distinguishable "keepme" paragraph, so these + // tests fail if the fallback doc is ever discarded rather than preserved. + const invalidHtmlWithSalvageableText = '

keepme

' + + it('preserves the fallback content when the handler runs a non-setContent command', () => { + const editor = new Editor({ + content: invalidHtmlWithSalvageableText, + extensions: [Document, Paragraph, Text], + enableContentCheck: true, + onContentError: ({ editor: contentErrorEditor }) => { + contentErrorEditor.commands.setTextSelection(0) + }, + }) + + expect(editor.getText()).toBe('keepme') + }) + + it('keeps the stripped fallback doc when the handler does nothing', () => { + const editor = new Editor({ + content: invalidHtmlWithSalvageableText, + extensions: [Document, Paragraph, Text], + enableContentCheck: true, + onContentError: () => {}, + }) + + expect(editor.getText()).toBe('keepme') + }) }) diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index 779d5d9946..2e0579a798 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -143,14 +143,17 @@ export class Editor extends EventEmitter { this.on('delete', this.options.onDelete) const initialDoc = this.createDoc() - const selection = resolveFocusPosition(initialDoc, this.options.autofocus) - // Set editor state immediately, so that it's available independently from the view - this.editorState = EditorState.create({ - doc: initialDoc, - schema: this.schema, - selection: selection || undefined, - }) + // createDoc() already seeds editorState from the fallback doc on a content error + if (!this.editorState) { + const selection = resolveFocusPosition(initialDoc, this.options.autofocus) + + this.editorState = EditorState.create({ + doc: initialDoc, + schema: this.schema, + selection: selection || undefined, + }) + } if (this.options.element) { this.mount(this.options.element) @@ -497,6 +500,24 @@ export class Editor extends EventEmitter { // Not the content error we were expecting throw e } + + // Content is invalid, but attempt to create it anyway, stripping out the invalid parts + const fallbackDoc = createDocument( + this.options.content, + this.schema, + this.options.parseOptions, + { + errorOnInvalidContent: false, + }, + ) + + // Seed editorState with the fallback doc so a handler can safely use `editor.commands` + this.editorState = EditorState.create({ + doc: fallbackDoc, + schema: this.schema, + selection: resolveFocusPosition(fallbackDoc, this.options.autofocus) || undefined, + }) + this.emit('contentError', { editor: this, error: e as Error, @@ -518,10 +539,7 @@ export class Editor extends EventEmitter { }, }) - // 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, - }) + return this.editorState.doc } return doc } diff --git a/packages/extension-collaboration/__tests__/filterInvalidContent.spec.ts b/packages/extension-collaboration/__tests__/filterInvalidContent.spec.ts index 1139f18c85..e232d5b55c 100644 --- a/packages/extension-collaboration/__tests__/filterInvalidContent.spec.ts +++ b/packages/extension-collaboration/__tests__/filterInvalidContent.spec.ts @@ -152,4 +152,31 @@ describe('filterInvalidContent', () => { disableCollab!() expect(destroySpy).toHaveBeenCalled() }) + + it('recovers from invalid initial content via disableCollaboration + setContent (#7493)', () => { + const ydoc = new Y.Doc() + + el = document.createElement('div') + document.body.appendChild(el) + + let ranHandler = false + + expect(() => { + editor = new Editor({ + element: el as HTMLElement, + extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })], + content: '

keepme

', + enableContentCheck: true, + onContentError: ({ editor: contentErrorEditor, disableCollaboration }) => { + ranHandler = true + disableCollaboration() + contentErrorEditor.commands.setContent('

recovered

') + }, + }) + }).not.toThrow() + + expect(ranHandler).toBe(true) + expect(editor!.storage.collaboration).toBeUndefined() + expect(editor!.getText()).toBe('recovered') + }) }) From 094bdcde0ffb3507defb0b57cdf3686f7a02b5f0 Mon Sep 17 00:00:00 2001 From: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:06:51 +0200 Subject: [PATCH 4/6] feat(extension-ruby-text): add RubyText extension (#8031) * feat(extension-ruby-text): add RubyText extension Introduce @tiptap/extension-ruby-text for HTML ruby annotations (furigana). Store the annotation as a mark attribute on the base text, render a non-editable element, and allow click-to-edit. Include React and Vue demos, unit tests, and e2e tests. * fix(ruby-text): handle null and empty annotations consistently Use strict null checks for the rt attribute to preserve an empty annotation while skipping the rt element for null. Do not parse ruby elements without an rt child as a mark. Apply configured HTMLAttributes to the mark view onCreate. * test(demos): fix RubyText annotation submission Use the demo test selector and remove unreliable arrow navigation coverage. * fix(ruby-text): support non-editable editor mode Add editable state checks to ruby text plugin to prevent inline editing. Update tests and remove unused HTML attribute parsing. * feat(ruby-text): enhance RubyText extension with custom annotation editor support Add functionality to replace the default annotation editor with a custom element via the `renderAnnotationEditor` option. Update tests to cover new editor behaviors, including handling unchanged submissions and focusing on custom inputs. --- .../2026-07-12-add-ruby-text-extension.md | 5 + SCOPES.md | 1 + demos/src/Marks/RubyText/React/index.html | 0 demos/src/Marks/RubyText/React/index.jsx | 69 +++ demos/src/Marks/RubyText/React/styles.scss | 24 + demos/src/Marks/RubyText/Vue/index.html | 0 demos/src/Marks/RubyText/Vue/index.vue | 93 ++++ demos/src/Marks/RubyText/index.spec.ts | 71 +++ packages/extension-ruby-text/CHANGELOG.md | 7 + packages/extension-ruby-text/README.md | 20 + .../__tests__/ruby-text.spec.ts | 489 ++++++++++++++++++ packages/extension-ruby-text/package.json | 55 ++ packages/extension-ruby-text/src/index.ts | 5 + .../src/ruby-text-decoration-plugin.ts | 295 +++++++++++ packages/extension-ruby-text/src/ruby-text.ts | 174 +++++++ packages/extension-ruby-text/tsup.config.ts | 11 + pnpm-lock.yaml | 9 + 17 files changed, 1328 insertions(+) create mode 100644 .changeset/2026-07-12-add-ruby-text-extension.md create mode 100644 demos/src/Marks/RubyText/React/index.html create mode 100644 demos/src/Marks/RubyText/React/index.jsx create mode 100644 demos/src/Marks/RubyText/React/styles.scss create mode 100644 demos/src/Marks/RubyText/Vue/index.html create mode 100644 demos/src/Marks/RubyText/Vue/index.vue create mode 100644 demos/src/Marks/RubyText/index.spec.ts create mode 100644 packages/extension-ruby-text/CHANGELOG.md create mode 100644 packages/extension-ruby-text/README.md create mode 100644 packages/extension-ruby-text/__tests__/ruby-text.spec.ts create mode 100644 packages/extension-ruby-text/package.json create mode 100644 packages/extension-ruby-text/src/index.ts create mode 100644 packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts create mode 100644 packages/extension-ruby-text/src/ruby-text.ts create mode 100644 packages/extension-ruby-text/tsup.config.ts diff --git a/.changeset/2026-07-12-add-ruby-text-extension.md b/.changeset/2026-07-12-add-ruby-text-extension.md new file mode 100644 index 0000000000..8962718e71 --- /dev/null +++ b/.changeset/2026-07-12-add-ruby-text-extension.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-ruby-text': minor +--- + +Add official RubyText extension for HTML ruby text annotations with non-editable annotations and mark-based document storage. The click-to-edit annotation editor can be replaced with a custom element via the `renderAnnotationEditor` option. diff --git a/SCOPES.md b/SCOPES.md index 6c8405ea12..e6745a1ed2 100644 --- a/SCOPES.md +++ b/SCOPES.md @@ -58,6 +58,7 @@ - extension-strike - extension-subscript - extension-superscript +- extension-ruby-text - extension-table - extension-table-of-contents - extension-text diff --git a/demos/src/Marks/RubyText/React/index.html b/demos/src/Marks/RubyText/React/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/demos/src/Marks/RubyText/React/index.jsx b/demos/src/Marks/RubyText/React/index.jsx new file mode 100644 index 0000000000..c7c719e667 --- /dev/null +++ b/demos/src/Marks/RubyText/React/index.jsx @@ -0,0 +1,69 @@ +// fallow-ignore-file unused-file +import './styles.scss' + +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import RubyText from '@tiptap/extension-ruby-text' +import Text from '@tiptap/extension-text' +import { EditorContent, useEditor } from '@tiptap/react' +import React from 'react' + +export default () => { + const [rt, setRt] = React.useState('') + + const editor = useEditor({ + extensions: [Document, Paragraph, Text, RubyText], + content: ` +

東京とうきょうは日本の首都です。

+

漢字かんじの上にルビを表示できます。

+ `, + onSelectionUpdate: ({ editor: currentEditor }) => { + setRt(currentEditor.getAttributes('rubyText').rt ?? '') + }, + }) + + if (!editor) { + return null + } + + return ( + <> +
+
{ + event.preventDefault() + editor.chain().focus().setRubyText({ rt }).run() + }} + > + + + +
+
+ + + + ) +} diff --git a/demos/src/Marks/RubyText/React/styles.scss b/demos/src/Marks/RubyText/React/styles.scss new file mode 100644 index 0000000000..22998e43e9 --- /dev/null +++ b/demos/src/Marks/RubyText/React/styles.scss @@ -0,0 +1,24 @@ +// fallow-ignore-file unused-file +.tiptap { + :first-child { + margin-top: 0; + } + + ruby { + ruby-position: over; + } + + rt { + cursor: text; + pointer-events: auto; + + input { + font: inherit; + margin: 0; + } + } +} + +.control-group input { + margin-left: 0.5rem; +} diff --git a/demos/src/Marks/RubyText/Vue/index.html b/demos/src/Marks/RubyText/Vue/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/demos/src/Marks/RubyText/Vue/index.vue b/demos/src/Marks/RubyText/Vue/index.vue new file mode 100644 index 0000000000..1767439e30 --- /dev/null +++ b/demos/src/Marks/RubyText/Vue/index.vue @@ -0,0 +1,93 @@ + + + + + + diff --git a/demos/src/Marks/RubyText/index.spec.ts b/demos/src/Marks/RubyText/index.spec.ts new file mode 100644 index 0000000000..fdb5129dab --- /dev/null +++ b/demos/src/Marks/RubyText/index.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test' + +import { getEditor, setEditorContent } from '../../../test/helpers.js' + +const demoName = 'RubyText' +const frameworkPaths = ['React', 'Vue'] +const demoPath = '/src/Marks' + +test.describe(`${demoPath}/${demoName}`, () => { + frameworkPaths.forEach(frameworkPath => { + const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/` + + test.describe(`${frameworkPath}`, () => { + test.beforeEach(async ({ page }) => { + await page.goto(fullDemoPath) + }) + + test('renders the annotation as non-editable text', async ({ page }) => { + await setEditorContent(page, '

漢字かんじ

') + + const rt = page.locator('.tiptap rt').first() + + await expect(rt).toHaveText('かんじ') + await expect(rt).toHaveAttribute('contenteditable', 'false') + }) + + test('edits an annotation inline', async ({ page }) => { + await setEditorContent(page, '

漢字かんじ

') + + const editor = await getEditor(page) + const rt = page.locator('.tiptap rt').first() + + await rt.click() + const input = rt.locator('input') + await input.fill('かんじ(新)') + await input.press('Enter') + + expect(await editor.evaluate((el: any) => el.editor.getHTML())).toContain('かんじ(新)') + await expect(rt).toHaveText('かんじ(新)') + }) + + test('submitting ruby text applies annotation to selection', async ({ page }) => { + await setEditorContent(page, '

東京

') + + const editor = await getEditor(page) + + await editor.click() + await page.keyboard.press('Home') + await page.keyboard.press('Shift+ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await expect + .poll(() => + editor.evaluate( + (el: any) => el.editor.state.selection.to - el.editor.state.selection.from, + ), + ) + .toBe(2) + + const rubyTextInput = page.locator('[data-test-id="ruby-text-input"]') + + await rubyTextInput.fill('とうきょう') + await rubyTextInput.press('Enter') + + const html = await editor.evaluate((el: any) => el.editor.getHTML()) + expect(html).toContain('とうきょう') + expect(html).toContain('東京') + }) + }) + }) +}) diff --git a/packages/extension-ruby-text/CHANGELOG.md b/packages/extension-ruby-text/CHANGELOG.md new file mode 100644 index 0000000000..9505c77aa5 --- /dev/null +++ b/packages/extension-ruby-text/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log + +## 3.27.3 + +### Minor Changes + +- Add official RubyText extension for HTML ruby text annotations with directly editable annotations and reliable cursor navigation diff --git a/packages/extension-ruby-text/README.md b/packages/extension-ruby-text/README.md new file mode 100644 index 0000000000..e5088714b7 --- /dev/null +++ b/packages/extension-ruby-text/README.md @@ -0,0 +1,20 @@ +# @tiptap/extension-ruby-text + +[![Version](https://img.shields.io/npm/v/@tiptap/extension-ruby-text.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-ruby-text) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-ruby-text.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-ruby-text.svg)](https://www.npmjs.com/package/@tiptap/extension-ruby-text) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) + +## Introduction + +Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_. + +This package adds support for [HTML ruby text](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ruby) (reading guides for CJK text). It is not related to the Ruby programming language. + +## Official Documentation + +Documentation can be found on the [Tiptap website](https://tiptap.dev). + +## License + +Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md). diff --git a/packages/extension-ruby-text/__tests__/ruby-text.spec.ts b/packages/extension-ruby-text/__tests__/ruby-text.spec.ts new file mode 100644 index 0000000000..d0b9e7d83d --- /dev/null +++ b/packages/extension-ruby-text/__tests__/ruby-text.spec.ts @@ -0,0 +1,489 @@ +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { afterEach, describe, expect, it } from 'vitest' + +import { RubyText, type RubyTextOptions } from '../src/ruby-text.js' + +let container: HTMLElement | undefined + +function createEditor(content = '

', options: Partial = {}) { + const element = document.createElement('div') + document.body.appendChild(element) + container = element + + return new Editor({ + element, + extensions: [Document, Paragraph, Text, RubyText.configure(options)], + content, + }) +} + +describe('RubyText', () => { + let editor: Editor + + afterEach(() => { + editor?.destroy() + container?.remove() + container = undefined + }) + + it('stores base text in the document and annotation in mark attributes', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '漢字', + marks: [{ type: 'rubyText', attrs: { rt: 'かんじ' } }], + }, + ], + }, + ], + }) + }) + + it('sets, toggles, and unsets the ruby mark', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + expect(editor.isActive('rubyText')).toBe(true) + + editor.commands.selectAll() + editor.commands.toggleRubyText({ rt: 'かんじ' }) + expect(editor.isActive('rubyText')).toBe(false) + + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + editor.commands.selectAll() + editor.commands.unsetRubyText() + expect(editor.getHTML()).toBe('

漢字

') + }) + + it('updates the annotation on an existing mark', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ(新)' }) + + expect(editor.getHTML()).toContain('かんじ(新)') + }) + + it('renders a non-editable rt element', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + + expect(editor.getHTML()).toContain('かんじ') + expect(rt.contentEditable).toBe('false') + expect(rt.parentElement?.tagName).toBe('RUBY') + }) + + it('applies configured HTML attributes to the mark view', () => { + editor = createEditor('

漢字

', { HTMLAttributes: { class: 'ruby-text' } }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + expect(editor.view.dom.querySelector('ruby')?.className).toBe('ruby-text') + }) + + it('preserves an empty annotation', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: '' }) + + expect(editor.getHTML()).not.toContain('data-rt') + expect(editor.getHTML()).toContain('') + }) + + it('does not render an annotation when it is null', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: null }) + + expect(editor.getHTML()).toBe('

漢字

') + }) + + it('updates an annotation after submitting its inline editor', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + const input = rt.querySelector('input') as HTMLInputElement + input.value = 'かんじ(新)' + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + + expect(editor.getHTML()).toContain('かんじ(新)') + expect(editor.view.dom.querySelector('ruby > rt')?.textContent).toBe('かんじ(新)') + expect(editor.state.selection.from).toBe(3) + }) + + it('closes the editor when submitting an unchanged annotation', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + const input = rt.querySelector('input') as HTMLInputElement + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + + expect(rt.querySelector('input')).toBeNull() + expect(rt.textContent).toBe('かんじ') + expect(editor.state.selection.from).toBe(3) + }) + + it('does not open an inline editor when click editing is disabled', () => { + editor = createEditor('

漢字

', { allowClickToEdit: false }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(rt.querySelector('input')).toBeNull() + }) + + it('does not open an inline editor when the editor is not editable', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + editor.setEditable(false) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(rt.querySelector('input')).toBeNull() + expect(editor.getHTML()).toContain('かんじ') + }) + + it('discards an annotation edit when dismissed', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + const input = rt.querySelector('input') as HTMLInputElement + input.value = 'かんじ(新)' + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })) + + expect(editor.getHTML()).toContain('かんじ') + expect(editor.getHTML()).not.toContain('かんじ(新)') + expect(rt.textContent).toBe('かんじ') + expect(editor.state.selection.from).toBe(3) + }) + + it('discards an annotation edit when it loses focus', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + const input = rt.querySelector('input') as HTMLInputElement + input.value = 'かんじ(新)' + input.blur() + + expect(editor.getHTML()).toContain('かんじ') + expect(editor.getHTML()).not.toContain('かんじ(新)') + expect(rt.textContent).toBe('かんじ') + expect(editor.state.selection.from).toBe(3) + }) + + it('focuses the default input after opening the inline editor', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(document.activeElement).toBe(rt.querySelector('input')) + }) + + it('ignores Enter while an IME composition is in progress', () => { + editor = createEditor('

漢字

') + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + const input = rt.querySelector('input') as HTMLInputElement + input.value = 'かんじ(新)' + input.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Enter', isComposing: true }), + ) + + expect(editor.getHTML()).not.toContain('かんじ(新)') + expect(rt.querySelector('input')).toBe(input) + }) + + it('renders a custom annotation editor instead of the default input', () => { + editor = createEditor('

漢字

', { + renderAnnotationEditor: () => { + const button = document.createElement('button') + button.dataset.testid = 'custom-editor' + return button + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(rt.querySelector('[data-testid="custom-editor"]')).not.toBeNull() + expect(rt.querySelector('input')).toBeNull() + }) + + it('focuses the autofocus control inside a custom editor', () => { + editor = createEditor('

漢字

', { + renderAnnotationEditor: () => { + const wrapper = document.createElement('span') + const input = document.createElement('input') + input.autofocus = true + wrapper.appendChild(input) + return wrapper + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(document.activeElement).toBe(rt.querySelector('input')) + }) + + it('passes the annotation and editor instance to the custom editor', () => { + let receivedAnnotation: string | undefined + let receivedEditor: Editor | undefined + + editor = createEditor('

漢字

', { + renderAnnotationEditor: ({ annotation, editor: editorInstance }) => { + receivedAnnotation = annotation + receivedEditor = editorInstance + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(receivedAnnotation).toBe('かんじ') + expect(receivedEditor).toBe(editor) + }) + + it('updates the annotation when a custom editor submits a value', () => { + let submit: ((value: string) => void) | undefined + + editor = createEditor('

漢字

', { + renderAnnotationEditor: props => { + submit = props.submit + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + submit?.('かんじ(新)') + + expect(editor.getHTML()).toContain('かんじ(新)') + expect(editor.view.dom.querySelector('ruby > rt')?.textContent).toBe('かんじ(新)') + expect(editor.state.selection.from).toBe(3) + }) + + it('restores the annotation when a custom editor dismisses', () => { + let dismiss: (() => void) | undefined + + editor = createEditor('

漢字

', { + renderAnnotationEditor: props => { + dismiss = props.dismiss + const span = document.createElement('span') + span.dataset.testid = 'custom-editor' + return span + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + dismiss?.() + + expect(editor.getHTML()).toContain('かんじ') + expect(rt.textContent).toBe('かんじ') + expect(rt.querySelector('[data-testid="custom-editor"]')).toBeNull() + expect(editor.state.selection.from).toBe(3) + }) + + it('ignores submit and dismiss after the editor is closed', () => { + let submit: ((value: string) => void) | undefined + let dismiss: (() => void) | undefined + let updates = 0 + + editor = createEditor('

漢字

', { + renderAnnotationEditor: props => { + submit = props.submit + dismiss = props.dismiss + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + editor.on('update', () => { + updates += 1 + }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + submit?.('かんじ(新)') + submit?.('かんじ(無視)') + dismiss?.() + + expect(updates).toBe(1) + expect(editor.getHTML()).toContain('かんじ(新)') + expect(editor.getHTML()).not.toContain('かんじ(無視)') + }) + + it('dismisses instead of submitting when the editor became non-editable', () => { + let submit: ((value: string) => void) | undefined + + editor = createEditor('

漢字

', { + renderAnnotationEditor: props => { + submit = props.submit + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + editor.setEditable(false) + + expect(submit).toBeDefined() + submit?.('かんじ(新)') + + expect(editor.getHTML()).toContain('かんじ') + expect(editor.getHTML()).not.toContain('かんじ(新)') + }) + + it('ignores submit when the document changed while editing', () => { + let submit: ((value: string) => void) | undefined + + editor = createEditor('

漢字

', { + renderAnnotationEditor: props => { + submit = props.submit + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(submit).toBeDefined() + + // Shift the ruby range so the captured submit becomes stale. + editor.commands.insertContentAt(1, 'あ') + submit?.('かんじ(新)') + + expect(editor.getHTML()).toContain('かんじ') + expect(editor.getHTML()).not.toContain('かんじ(新)') + }) + + it('does not invoke the custom editor when click editing is disabled', () => { + let invoked = false + + editor = createEditor('

漢字

', { + allowClickToEdit: false, + renderAnnotationEditor: () => { + invoked = true + return document.createElement('span') + }, + }) + editor.commands.selectAll() + editor.commands.setRubyText({ rt: 'かんじ' }) + + const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement + rt.click() + + expect(invoked).toBe(false) + }) + + it('parses ruby HTML with rb and rt elements', () => { + editor = createEditor('

漢字かんじ

') + + expect(editor.getJSON()).toMatchObject({ + content: [ + { + content: [ + { + text: '漢字', + marks: [{ type: 'rubyText', attrs: { rt: 'かんじ' } }], + }, + ], + }, + ], + }) + }) + + it('parses native ruby HTML without an rb element', () => { + editor = createEditor('

東京とうきょうは日本の首都です。

') + + expect(editor.getJSON()).toMatchObject({ + content: [ + { + content: [ + { + text: '東京', + marks: [{ type: 'rubyText', attrs: { rt: 'とうきょう' } }], + }, + { + text: 'は日本の首都です。', + }, + ], + }, + ], + }) + }) + + it('does not parse ruby HTML without an annotation as a mark', () => { + editor = createEditor('

漢字

') + + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '漢字' }], + }, + ], + }) + }) +}) diff --git a/packages/extension-ruby-text/package.json b/packages/extension-ruby-text/package.json new file mode 100644 index 0000000000..9d6943a901 --- /dev/null +++ b/packages/extension-ruby-text/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tiptap/extension-ruby-text", + "version": "3.27.3", + "description": "HTML ruby text annotation extension for tiptap", + "keywords": [ + "tiptap", + "tiptap extension", + "ruby-text", + "furigana", + "cjk" + ], + "homepage": "https://tiptap.dev", + "bugs": { + "url": "https://github.com/ueberdosis/tiptap/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ueberdosis/tiptap", + "directory": "packages/extension-ruby-text" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "files": [ + "src", + "dist" + ], + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "@tiptap/core": "workspace:^", + "@tiptap/pm": "workspace:^" + }, + "peerDependencies": { + "@tiptap/core": "workspace:*", + "@tiptap/pm": "workspace:*" + } +} diff --git a/packages/extension-ruby-text/src/index.ts b/packages/extension-ruby-text/src/index.ts new file mode 100644 index 0000000000..6669b98798 --- /dev/null +++ b/packages/extension-ruby-text/src/index.ts @@ -0,0 +1,5 @@ +import { RubyText } from './ruby-text.js' + +export * from './ruby-text.js' + +export default RubyText diff --git a/packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts b/packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts new file mode 100644 index 0000000000..32f5656bd7 --- /dev/null +++ b/packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts @@ -0,0 +1,295 @@ +import type { Editor } from '@tiptap/core' +import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view' + +const rubyTextDecorationPluginKey = new PluginKey('rubyTextDecoration') + +// Ends a widget's edit session on destroy, so stale submit/dismiss calls do nothing. +const editSessions = new WeakMap void>() + +interface RubyTextRange { + from: number + to: number + mark: Mark +} + +export interface RubyTextAnnotationEditorProps { + /** + * The current annotation value (`''` when the mark has an empty annotation). + */ + annotation: string + /** + * Applies the given value as the new annotation and closes the editor. + * Calling it more than once (or after `dismiss`) is a no-op. + */ + submit: (value: string) => void + /** + * Closes the editor without changing the annotation. + * Calling it more than once (or after `submit`) is a no-op. + */ + dismiss: () => void + /** + * The editor instance. + */ + editor: Editor +} + +export interface RubyTextDecorationPluginOptions { + editor: Editor + allowClickToEdit: boolean + renderAnnotationEditor?: (props: RubyTextAnnotationEditorProps) => HTMLElement +} + +// Enter and Escape during IME composition must not close the editor. +// Safari reports some IME keydowns only via the deprecated keyCode 229. +function isImeEvent(event: KeyboardEvent) { + const legacyKeyCode = (event as { keyCode?: number }).keyCode + return event.isComposing || legacyKeyCode === 229 +} + +/** Default annotation editor: a plain text input. Enter submits, Escape or blur dismisses. */ +function defaultRenderAnnotationEditor({ + annotation, + submit, + dismiss, +}: RubyTextAnnotationEditorProps) { + const input = document.createElement('input') + input.type = 'text' + input.value = annotation + input.size = Math.max(annotation.length, 1) + input.setAttribute('aria-label', 'Ruby text annotation') + + input.addEventListener('focus', () => input.select(), { once: true }) + input.addEventListener('blur', () => dismiss()) + input.addEventListener('keydown', event => { + if (isImeEvent(event)) { + return + } + + if (event.key === 'Escape') { + event.preventDefault() + dismiss() + } + + if (event.key === 'Enter') { + event.preventDefault() + submit(input.value) + } + }) + + return input +} + +function createRtElement( + annotation: string, + range: RubyTextRange, + rubyTextType: MarkType, + view: EditorView, + options: RubyTextDecorationPluginOptions, +) { + const rt = document.createElement('rt') + rt.contentEditable = 'false' + rt.textContent = annotation + let editing = false + + const restoreEditorFocus = () => { + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, range.to))) + view.focus() + } + + if (!options.allowClickToEdit || !view.editable) { + return rt + } + + editSessions.set(rt, () => { + editing = false + }) + + const dismiss = () => { + if (!editing) { + return + } + + editing = false + rt.textContent = annotation + restoreEditorFocus() + } + + const submit = (value: string) => { + if (!editing) { + return + } + + // An unchanged value produces no doc change, so the widget would not + // re-render and the editor element would stay mounted. + if (!view.editable || value === annotation) { + dismiss() + return + } + + editing = false + const transaction = view.state.tr.addMark( + range.from, + range.to, + rubyTextType.create({ ...range.mark.attrs, rt: value }), + ) + + view.dispatch(transaction.setSelection(TextSelection.create(transaction.doc, range.to))) + view.focus() + } + + const openEditor = () => { + const renderEditor = options.renderAnnotationEditor ?? defaultRenderAnnotationEditor + const element = renderEditor({ annotation, submit, dismiss, editor: options.editor }) + + rt.replaceChildren(element) + + // Browsers ignore autofocus on inserted nodes, so we focus it manually. + const focusTarget = element.querySelector('[autofocus]') ?? element + focusTarget.focus() + } + + rt.addEventListener('click', () => { + if (editing || !view.editable) { + return + } + + editing = true + openEditor() + }) + + return rt +} + +/** Gets the mark of ruby text from the given node, if it exists. */ +function getRubyTextMark(node: ProseMirrorNode, rubyTextType: MarkType) { + return node.isText ? node.marks.find(candidate => candidate.type === rubyTextType) : undefined +} + +/** Adds a ruby text range to an existing array of ranges. */ +function addRange(ranges: RubyTextRange[], range: RubyTextRange | null) { + if (range) { + ranges.push(range) + } +} + +/** Can the current range be extended with the given mark at the given position? */ +function canExtendRange(range: RubyTextRange | null, mark: Mark, pos: number) { + return range?.to === pos && range.mark.eq(mark) +} + +/** + * Gets the next ruby text range based on the current node and position. + * @param ranges The array of ruby text ranges to add to. + * @param range The current ruby text range being built. + * @param node The current ProseMirror node being processed. + * @param pos The position of the current node in the document. + * @param rubyTextType The MarkType for ruby text. + * @returns The updated ruby text range, or null if the current node does not have a ruby text mark. + */ +function getNextRange( + ranges: RubyTextRange[], + range: RubyTextRange | null, + node: ProseMirrorNode, + pos: number, + rubyTextType: MarkType, +) { + const mark = getRubyTextMark(node, rubyTextType) + + if (!mark) { + addRange(ranges, range) + return null + } + + if (range && canExtendRange(range, mark, pos)) { + range.to += node.nodeSize + return range + } + + addRange(ranges, range) + return { from: pos, to: pos + node.nodeSize, mark } +} + +function getRubyTextRanges(doc: ProseMirrorNode, rubyTextType: MarkType) { + const ranges: RubyTextRange[] = [] + let range: RubyTextRange | null = null + + doc.descendants((node, pos) => { + range = getNextRange(ranges, range, node, pos, rubyTextType) + }) + addRange(ranges, range) + + return ranges +} + +// Events from the annotation editor that ProseMirror must not handle itself. +const STOPPED_EVENT_TYPES = new Set([ + 'click', + 'dblclick', + 'contextmenu', + 'mousedown', + 'mouseup', + 'pointerdown', + 'pointerup', + 'touchstart', + 'touchend', + 'keydown', + 'keypress', + 'keyup', + 'input', + 'beforeinput', + 'compositionstart', + 'compositionupdate', + 'compositionend', + 'paste', + 'cut', + 'copy', + 'focus', + 'blur', + 'focusin', + 'focusout', +]) + +function createDecorations( + doc: ProseMirrorNode, + rubyTextType: MarkType, + options: RubyTextDecorationPluginOptions, +) { + const decorations = getRubyTextRanges(doc, rubyTextType).map(range => { + const annotation = range.mark.attrs.rt ?? '' + + return Decoration.widget( + range.to, + view => createRtElement(annotation, range, rubyTextType, view, options), + { + key: `ruby-text-${range.from}-${range.to}-${annotation}`, + marks: [range.mark], + side: 1, + stopEvent: event => options.allowClickToEdit && STOPPED_EVENT_TYPES.has(event.type), + destroy: node => editSessions.get(node)?.(), + }, + ) + }) + + return DecorationSet.create(doc, decorations) +} + +export function RubyTextDecorationPlugin( + rubyTextType: MarkType, + options: RubyTextDecorationPluginOptions, +) { + return new Plugin({ + key: rubyTextDecorationPluginKey, + state: { + init: (_, state) => createDecorations(state.doc, rubyTextType, options), + apply: (transaction, decorations) => + transaction.docChanged + ? createDecorations(transaction.doc, rubyTextType, options) + : decorations.map(transaction.mapping, transaction.doc), + }, + props: { + decorations: state => rubyTextDecorationPluginKey.getState(state), + }, + }) +} diff --git a/packages/extension-ruby-text/src/ruby-text.ts b/packages/extension-ruby-text/src/ruby-text.ts new file mode 100644 index 0000000000..071c3026d9 --- /dev/null +++ b/packages/extension-ruby-text/src/ruby-text.ts @@ -0,0 +1,174 @@ +import { Mark, mergeAttributes } from '@tiptap/core' + +import type { RubyTextAnnotationEditorProps } from './ruby-text-decoration-plugin.js' +import { RubyTextDecorationPlugin } from './ruby-text-decoration-plugin.js' + +export type { RubyTextAnnotationEditorProps } + +export interface RubyTextOptions { + /** + * HTML attributes to add to the ruby element. + * @default {} + */ + HTMLAttributes: Record + /** + * Whether clicking an annotation opens an inline editor. + * @default true + */ + allowClickToEdit: boolean + /** + * Renders a custom element used as the inline annotation editor. + * The returned element is mounted inside the `rt` element. A descendant + * with the `autofocus` attribute is focused, otherwise the element itself. + * When not set, a plain text input is rendered. + * @default undefined + */ + renderAnnotationEditor?: (props: RubyTextAnnotationEditorProps) => HTMLElement +} + +export interface RubyTextAttributes { + /** + * The ruby text annotation rendered in the HTML `rt` element. + */ + rt: string | null +} + +declare module '@tiptap/core' { + interface Commands { + rubyText: { + /** + * Set a ruby text mark with an annotation on the current selection. + * @example editor.commands.setRubyText({ rt: 'かんじ' }) + */ + setRubyText: (attributes: RubyTextAttributes) => ReturnType + /** + * Toggle a ruby text mark on the current selection. + * @example editor.commands.toggleRubyText({ rt: 'かんじ' }) + */ + toggleRubyText: (attributes: RubyTextAttributes) => ReturnType + /** + * Remove the ruby text mark from the current selection. + * @example editor.commands.unsetRubyText() + */ + unsetRubyText: () => ReturnType + } + } +} + +/** + * This extension adds support for HTML ruby text annotations. + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ruby + */ +export const RubyText = Mark.create({ + name: 'rubyText', + + inclusive: false, + + addOptions() { + return { + HTMLAttributes: {}, + allowClickToEdit: true, + renderAnnotationEditor: undefined, + } + }, + + addAttributes() { + return { + rt: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'ruby', + contentElement: (node: HTMLElement) => { + const rb = node.querySelector('rb') + + if (rb) { + return rb + } + + const surrogate = document.createElement('span') + + Array.from(node.childNodes).forEach(child => { + if (child.nodeName !== 'RT' && child.nodeName !== 'RP') { + surrogate.appendChild(child.cloneNode(true)) + } + }) + + return surrogate + }, + getAttrs: (node: HTMLElement) => { + const rt = node.querySelector('rt') + + if (!rt) { + return false + } + + return { rt: rt.textContent ?? '' } + }, + }, + ] + }, + + renderHTML({ HTMLAttributes, mark }) { + return [ + 'ruby', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ['rb', 0], + ...(mark.attrs.rt == null ? [] : [['rt', { contenteditable: 'false' }, mark.attrs.rt]]), + ] + }, + + addCommands() { + return { + setRubyText: + attributes => + ({ commands }) => { + return commands.setMark(this.name, attributes) + }, + toggleRubyText: + attributes => + ({ commands }) => { + return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + }, + unsetRubyText: + () => + ({ commands }) => { + return commands.unsetMark(this.name, { extendEmptyMarkRange: true }) + }, + } + }, + + addProseMirrorPlugins() { + return [ + RubyTextDecorationPlugin(this.type, { + editor: this.editor, + allowClickToEdit: this.options.allowClickToEdit, + renderAnnotationEditor: this.options.renderAnnotationEditor, + }), + ] + }, + + addMarkView() { + return ({ HTMLAttributes }) => { + const ruby = document.createElement('ruby') + + Object.entries(mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)).forEach( + ([key, value]) => { + if (value != null) { + ruby.setAttribute(key, String(value)) + } + }, + ) + + return { + dom: ruby, + contentDOM: ruby, + } + } + }, +}) diff --git a/packages/extension-ruby-text/tsup.config.ts b/packages/extension-ruby-text/tsup.config.ts new file mode 100644 index 0000000000..03b7c8d0b6 --- /dev/null +++ b/packages/extension-ruby-text/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + tsconfig: '../../tsconfig.build.json', + outDir: 'dist', + dts: true, + clean: true, + sourcemap: true, + format: ['esm', 'cjs'], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9504f13710..9bc607c836 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -834,6 +834,15 @@ importers: specifier: workspace:^ version: link:../core + packages/extension-ruby-text: + devDependencies: + '@tiptap/core': + specifier: workspace:^ + version: link:../core + '@tiptap/pm': + specifier: workspace:^ + version: link:../pm + packages/extension-strike: devDependencies: '@tiptap/core': From 093573ab9b9e119269e094353f8bdb8d19fa0ce5 Mon Sep 17 00:00:00 2001 From: Alex Casillas Date: Wed, 22 Jul 2026 14:19:16 +0200 Subject: [PATCH 5/6] fix(extension-table): backfill empty cells on slice parse (#6237) (#8103) Empty / elements throw RangeError: Invalid content for node tableCell/tableHeader: <> when parsed via insertContent/insertContentAt, because the slice-parse path (DOMParser.parseSlice) does not backfill required content the way setContent's full-document parse does. Add a getContent-based parse rule (only matching empty elements, via getAttrs) to tableCell and tableHeader that backfills a single empty paragraph, sharing the logic through a new fillEmptyCellContent helper. Non-empty cells keep using the original, unmodified default parse rule. --- ...-table-cell-slice-parse-quiet-fox-glade.md | 5 + .../__tests__/tableCellEmptyContent.spec.ts | 226 ++++++++++++++++++ .../extension-table/src/cell/table-cell.ts | 11 +- .../src/header/table-header.ts | 11 +- .../src/utils/fillEmptyCellContent.ts | 25 ++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-empty-table-cell-slice-parse-quiet-fox-glade.md create mode 100644 packages/extension-table/__tests__/tableCellEmptyContent.spec.ts create mode 100644 packages/extension-table/src/utils/fillEmptyCellContent.ts diff --git a/.changeset/fix-empty-table-cell-slice-parse-quiet-fox-glade.md b/.changeset/fix-empty-table-cell-slice-parse-quiet-fox-glade.md new file mode 100644 index 0000000000..94d2c9c7f4 --- /dev/null +++ b/.changeset/fix-empty-table-cell-slice-parse-quiet-fox-glade.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-table': patch +--- + +Fix inserting a table with an empty cell or header (e.g. via `insertContent`/`insertContentAt`) throwing `RangeError: Invalid content for node tableCell/tableHeader: <>`. Empty ``/`` elements are now backfilled with the cell's default block content, matching the behavior you already get from `setContent`. diff --git a/packages/extension-table/__tests__/tableCellEmptyContent.spec.ts b/packages/extension-table/__tests__/tableCellEmptyContent.spec.ts new file mode 100644 index 0000000000..77579f3be1 --- /dev/null +++ b/packages/extension-table/__tests__/tableCellEmptyContent.spec.ts @@ -0,0 +1,226 @@ +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Heading from '@tiptap/extension-heading' +import Paragraph from '@tiptap/extension-paragraph' +import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' +import Text from '@tiptap/extension-text' +import { afterEach, describe, expect, it } from 'vitest' + +/** Regression tests for empty ``/`` insertion — https://github.com/ueberdosis/tiptap/issues/6237 */ +describe('extension table empty cell/header parsing', () => { + const editorElClass = 'tiptap' + let editor: Editor | null = null + + const createEditorEl = () => { + const editorEl = document.createElement('div') + + editorEl.classList.add(editorElClass) + document.body.appendChild(editorEl) + return editorEl + } + const getEditorEl = () => document.querySelector(`.${editorElClass}`) + + const createTableEditor = () => + new Editor({ + element: createEditorEl(), + extensions: [ + Document, + Text, + Paragraph, + TableCell, + TableHeader, + TableRow, + Table.configure({ + resizable: true, + }), + ], + content: '

', + }) + + afterEach(() => { + editor?.destroy() + editor = null + getEditorEl()?.remove() + }) + + it('inserts a table with an empty cell without throwing, backfilling an empty paragraph', () => { + editor = createTableEditor() + + expect(() => { + editor?.commands.insertContentAt(0, '
') + }).not.toThrow() + + const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(cellNode?.type).toBe('tableCell') + expect(cellNode?.content).toEqual([{ type: 'paragraph' }]) + }) + + it('inserts a table with an empty header cell without throwing, backfilling an empty paragraph', () => { + editor = createTableEditor() + + expect(() => { + editor?.commands.insertContentAt(0, '
') + }).not.toThrow() + + const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(headerNode?.type).toBe('tableHeader') + expect(headerNode?.content).toEqual([{ type: 'paragraph' }]) + }) + + it('inserts an empty cell via insertContent without throwing', () => { + editor = createTableEditor() + + expect(() => { + editor?.commands.insertContent('
') + }).not.toThrow() + + const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(cellNode?.type).toBe('tableCell') + expect(cellNode?.content).toEqual([{ type: 'paragraph' }]) + }) + + it('inserts an empty header cell via insertContent without throwing', () => { + editor = createTableEditor() + + expect(() => { + editor?.commands.insertContent('
') + }).not.toThrow() + + const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(headerNode?.type).toBe('tableHeader') + expect(headerNode?.content).toEqual([{ type: 'paragraph' }]) + }) + + it('backfills a whitespace-only cell when preserveWhitespace is false', () => { + editor = createTableEditor() + + expect(() => { + editor?.commands.insertContentAt(0, '
', { + parseOptions: { preserveWhitespace: false }, + }) + }).not.toThrow() + + const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(cellNode?.content).toEqual([{ type: 'paragraph' }]) + }) + + it.each([ + ['default (no parseOptions)', undefined], + ['preserveWhitespace: true', { preserveWhitespace: true as const }], + ['preserveWhitespace: "full"', { preserveWhitespace: 'full' as const }], + ])('backfills a whitespace-only cell identically to setContent — %s', (_label, parseOptions) => { + const tableHTML = '
' + + // setContent collapses a spaces-only cell too, so no whitespace is lost. + const setContentEditor = createTableEditor() + setContentEditor.commands.setContent(tableHTML) + const reference = setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content + setContentEditor.destroy() + + editor = createTableEditor() + expect(() => { + editor?.commands.insertContentAt(0, tableHTML, parseOptions ? { parseOptions } : undefined) + }).not.toThrow() + + const cellContent = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content + + expect(cellContent).toEqual(reference) + expect(cellContent).toEqual([{ type: 'paragraph' }]) + }) + + it('produces the same empty cell as setContent does', () => { + const tableHTML = '
' + + const setContentEditor = createTableEditor() + setContentEditor.commands.setContent(tableHTML) + const setContentCell = + setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content + setContentEditor.destroy() + + editor = createTableEditor() + editor.commands.insertContentAt(0, tableHTML) + const insertContentCell = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content + + expect(insertContentCell).toEqual(setContentCell) + expect(insertContentCell).toEqual([{ type: 'paragraph' }]) + }) + + it('preserves a non-breaking-space cell instead of treating it as empty', () => { + editor = createTableEditor() + + // trim() would strip the NBSP; it must survive as text, not be backfilled. + editor.commands.insertContentAt(0, '
 
') + + const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(cellNode?.type).toBe('tableCell') + expect(cellNode?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: ' ' }] }, + ]) + }) + + it('backfills using the schema block when no paragraph node exists', () => { + const el = createEditorEl() + // A valid schema whose only block is Heading (no Paragraph). + const headingOnlyEditor = new Editor({ + element: el, + extensions: [Document, Text, Heading, TableCell, TableHeader, TableRow, Table], + content: '

x

', + }) + + expect(() => { + headingOnlyEditor.commands.insertContentAt(0, '
') + }).not.toThrow() + + const cellNode = headingOnlyEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + // Fill uses the schema's block (heading here), not a hard-coded paragraph. + expect(cellNode?.content?.[0]?.type).toBe('heading') + + headingOnlyEditor.destroy() + el.remove() + }) + + it('keeps non-empty cell content intact (regression)', () => { + editor = createTableEditor() + + editor.commands.insertContentAt(0, '
hello
') + + const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(cellNode?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + ]) + }) + + it('keeps non-empty header content intact (regression)', () => { + editor = createTableEditor() + + editor.commands.insertContentAt(0, '
hello
') + + const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0] + + expect(headerNode?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + ]) + }) + + it('still parses colspan/colwidth on a multi-cell table (regression)', () => { + editor = createTableEditor() + + editor.commands.insertContentAt( + 0, + '
NameDescription
', + ) + + const row = editor.getJSON().content?.[0]?.content?.[0] + + expect(row?.content?.[0]?.attrs?.colwidth).toEqual([200]) + expect(row?.content?.[1]?.attrs?.colspan).toBe(2) + }) +}) diff --git a/packages/extension-table/src/cell/table-cell.ts b/packages/extension-table/src/cell/table-cell.ts index 0ba1702a77..2ead19ff19 100644 --- a/packages/extension-table/src/cell/table-cell.ts +++ b/packages/extension-table/src/cell/table-cell.ts @@ -4,6 +4,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' +import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' export interface TableCellOptions { /** @@ -50,7 +51,15 @@ export const TableCell = Node.create({ isolating: true, parseHTML() { - return [{ tag: 'td' }] + return [ + { + // Backfill empty cells; non-empty cells fall through to the rule below. + tag: 'td', + getAttrs: node => (isEmptyCellElement(node) ? {} : false), + getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]), + }, + { tag: 'td' }, + ] }, renderHTML({ HTMLAttributes }) { diff --git a/packages/extension-table/src/header/table-header.ts b/packages/extension-table/src/header/table-header.ts index 5416a8a523..fb5ce90603 100644 --- a/packages/extension-table/src/header/table-header.ts +++ b/packages/extension-table/src/header/table-header.ts @@ -4,6 +4,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' +import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' export interface TableHeaderOptions { /** @@ -50,7 +51,15 @@ export const TableHeader = Node.create({ isolating: true, parseHTML() { - return [{ tag: 'th' }] + return [ + { + // Backfill empty cells; non-empty cells fall through to the rule below. + tag: 'th', + getAttrs: node => (isEmptyCellElement(node) ? {} : false), + getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]), + }, + { tag: 'th' }, + ] }, renderHTML({ HTMLAttributes }) { diff --git a/packages/extension-table/src/utils/fillEmptyCellContent.ts b/packages/extension-table/src/utils/fillEmptyCellContent.ts new file mode 100644 index 0000000000..486df0a56a --- /dev/null +++ b/packages/extension-table/src/utils/fillEmptyCellContent.ts @@ -0,0 +1,25 @@ +import type { NodeType } from '@tiptap/pm/model' +import { Fragment } from '@tiptap/pm/model' + +// ASCII whitespace only, so a non-breaking-space cell is not treated as empty. +const COLLAPSIBLE_WHITESPACE = /[ \t\r\n\f]+/g + +/** Whether a cell/header element has no child elements and only collapsible whitespace. */ +export function isEmptyCellElement(element: HTMLElement): boolean { + if (element.children.length > 0) { + return false + } + + return (element.textContent ?? '').replace(COLLAPSIBLE_WHITESPACE, '') === '' +} + +/** Builds a cell/header's minimal `block+` content, derived from the node type. */ +export function fillEmptyCellContent(cellType: NodeType): Fragment { + const filled = cellType.createAndFill() + + if (!filled) { + throw new Error(`[tiptap error]: "${cellType.name}" has no default content to backfill.`) + } + + return filled.content +} From 9af2f2b1e934e1d09e650207e3ba6b14e4fbb70e Mon Sep 17 00:00:00 2001 From: Sravanth Date: Wed, 22 Jul 2026 13:58:37 +0100 Subject: [PATCH 6/6] fix(markdown): recover blank lines absorbed into block token raw (#8008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(markdown): recover blank lines absorbed into block token raw marked's heading, table and html-block tokenizers match a trailing `\n+`, so the blank lines that follow such a block are absorbed into the token's own `raw` and no standalone `space` token is emitted. Empty paragraphs were only reconstructed from `space` tokens, so those blank lines were dropped on parse — eroding one blank line per parse/serialize round-trip in editors that persist content as markdown. Normalize the marked token stream before parsing: `extractAbsorbedBlankLines` splits an absorbed trailing blank-line run back into an explicit `space` token, so the existing single reconstruction path handles every block uniformly. Adds a regression spec and a changeset. * test(markdown): cover html-block blank-line recovery * Update fix-markdown-block-token-blank-line-erosion.md * Update block-token-blank-line.spec.ts * Refactor comments for clarity in utils.ts * Update packages/markdown/src/MarkdownManager.ts Co-authored-by: Arnau Gómez Farell * Update packages/markdown/src/MarkdownManager.ts * Update packages/markdown/src/utils.ts * test(markdown): fix   paragraph round-trip assertion --------- Co-authored-by: bdbch <6538827+bdbch@users.noreply.github.com> Co-authored-by: Arnau Gómez Farell --- ...markdown-block-token-blank-line-erosion.md | 5 + .../__tests__/block-token-blank-line.spec.ts | 92 +++++++++++++++++++ packages/markdown/src/MarkdownManager.ts | 10 +- packages/markdown/src/utils.ts | 54 ++++++++--- 4 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-markdown-block-token-blank-line-erosion.md create mode 100644 packages/markdown/__tests__/block-token-blank-line.spec.ts diff --git a/.changeset/fix-markdown-block-token-blank-line-erosion.md b/.changeset/fix-markdown-block-token-blank-line-erosion.md new file mode 100644 index 0000000000..e67ed835e0 --- /dev/null +++ b/.changeset/fix-markdown-block-token-blank-line-erosion.md @@ -0,0 +1,5 @@ +--- +'@tiptap/markdown': patch +--- + +Fixed blank lines being dropped after block elements (headings, tables, etc.) when parsing markdown. Blank lines were being absorbed into the block token instead of being preserved, causing content to lose a blank line on each parse/serialize cycle. diff --git a/packages/markdown/__tests__/block-token-blank-line.spec.ts b/packages/markdown/__tests__/block-token-blank-line.spec.ts new file mode 100644 index 0000000000..6b28eb0e7c --- /dev/null +++ b/packages/markdown/__tests__/block-token-blank-line.spec.ts @@ -0,0 +1,92 @@ +import { Blockquote } from '@tiptap/extension-blockquote' +import { Document } from '@tiptap/extension-document' +import { Heading } from '@tiptap/extension-heading' +import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' +import { Text } from '@tiptap/extension-text' +import { MarkdownManager } from '@tiptap/markdown' +import { beforeEach, describe, expect, it } from 'vitest' + +describe('Blank lines after a block token', () => { + let markdownManager: MarkdownManager + + beforeEach(() => { + markdownManager = new MarkdownManager({ + extensions: [ + Document, + Paragraph, + Text, + Heading, + Blockquote, + BulletList, + OrderedList, + ListItem, + Table, + TableRow, + TableHeader, + TableCell, + ], + }) + }) + + // Heading tokenizer absorbs trailing blank lines into the token's raw, + // so we recover them here to prevent losing a blank line per parse cycle. + it('preserves a blank line between a heading and the following text', () => { + const doc = markdownManager.parse('# Title\n\n\n\nBody') + + expect(doc.content).toEqual([ + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }, + ]) + }) + + it('does not create an empty paragraph when a heading is directly followed by text', () => { + const doc = markdownManager.parse('# Title\n\nBody') + + expect(doc.content).toEqual([ + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }, + ]) + }) + + it('round-trips blank lines after a heading without eroding them', () => { + const markdown = '# Title\n\n\n\nBody' + + const once = markdownManager.serialize(markdownManager.parse(markdown)) + const twice = markdownManager.serialize(markdownManager.parse(once)) + + // Stable across repeated cycles (no erosion). + expect(twice).toBe(once) + }) + + // A lone ` ` paragraph is an empty-paragraph marker, not verbatim + // content, so it normalizes to blank-line spacing (see paragraph.spec.ts). + // What must hold is that the blank line is not eroded across cycles. + it('normalizes an explicit   paragraph without eroding the blank line', () => { + const once = markdownManager.serialize(markdownManager.parse('# Title\n\n \n\nBody')) + const twice = markdownManager.serialize(markdownManager.parse(once)) + + expect(once).toBe('# Title\n\n\n\nBody') + expect(twice).toBe(once) + }) + + // Tables absorb trailing blank lines the same way. + it('preserves a blank line between a table and the following text', () => { + const doc = markdownManager.parse('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n\n\nAfter') + const kinds = (doc.content ?? []).map(node => node.type) + + expect(kinds).toEqual(['table', 'paragraph', 'paragraph']) + expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] }) + }) + + // HTML blocks absorb trailing blank lines the same way. + it('preserves a blank line after a raw HTML block (parsed as paragraph)', () => { + const doc = markdownManager.parse('
test
\n\n\n\nAfter') + const kinds = (doc.content ?? []).map(node => node.type) + + expect(kinds).toEqual(['paragraph', 'paragraph', 'paragraph']) + expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] }) + }) +}) diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 5d6256f921..d6f31168c5 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -25,6 +25,7 @@ import { type Lexer, type Token, type TokenizerExtension, type TokenizerThis, ma import { closeMarksBeforeNode, + extractAbsorbedBlankLines, findMarksToClose, findMarksToCloseAtEnd, findMarksToOpen, @@ -365,7 +366,12 @@ export class MarkdownManager { tokens: MarkdownToken[], parseImplicitEmptyParagraphs = false, ): JSONContent[] { - const nonSpaceTokenIndexes = tokens.reduce((indexes, token, index) => { + // Normalize absorbed blank lines into `space` tokens so they survive round-trips. + const normalizedTokens = parseImplicitEmptyParagraphs + ? extractAbsorbedBlankLines(tokens) + : tokens + + const nonSpaceTokenIndexes = normalizedTokens.reduce((indexes, token, index) => { if (token.type !== 'space') { indexes.push(index) } @@ -376,7 +382,7 @@ export class MarkdownManager { let previousNonSpaceTokenIndex = -1 let nextNonSpaceTokenPointer = 0 - return tokens.flatMap((token, index) => { + return normalizedTokens.flatMap((token, index) => { while ( nextNonSpaceTokenPointer < nonSpaceTokenIndexes.length && nonSpaceTokenIndexes[nextNonSpaceTokenPointer] < index diff --git a/packages/markdown/src/utils.ts b/packages/markdown/src/utils.ts index 3d2fd13875..7660780fb9 100644 --- a/packages/markdown/src/utils.ts +++ b/packages/markdown/src/utils.ts @@ -4,6 +4,42 @@ import type { Fragment, Node } from '@tiptap/pm/model' import type { ContentType } from './types.js' +/** + * Matches a run of two or more consecutive line breaks (a blank line) at the + * end of a string, allowing horizontal whitespace between the breaks. + * + * @example + * TRAILING_BLANK_LINES.test('paragraph\n \n') + * // => true + */ +const TRAILING_BLANK_LINES = /\n[^\S\n]*(?:\n[^\S\n]*)+$/ + +/** + * Extracts blank lines absorbed into marked token `raw` back into explicit + * `space` tokens so they're handled uniformly by the reconstruction path. + * @param tokens The marked token stream to normalize. + * @returns A new token array with absorbed blank lines as `space` tokens. + */ +export function extractAbsorbedBlankLines(tokens: MarkdownToken[]): MarkdownToken[] { + return tokens.flatMap((token, index) => { + // A following `space` token already carries the blank lines for this gap. + if (token.type === 'space' || tokens[index + 1]?.type === 'space') { + return [token] + } + + const trailingBlankLines = (token.raw || '').match(TRAILING_BLANK_LINES) + + if (!trailingBlankLines) { + return [token] + } + + return [ + { ...token, raw: (token.raw || '').slice(0, -trailingBlankLines[0].length) }, + { type: 'space', raw: trailingBlankLines[0] } as MarkdownToken, + ] + }) +} + /** * Wraps each line of the content with the given prefix. * @param prefix The prefix to wrap each line with. @@ -26,9 +62,8 @@ export function wrapInMarkdownBlock(prefix: string, content: string) { } /** - * Identifies marks that need to be closed, based on the marks in the next node. - * Compares both mark type and attributes — two marks of the same type with - * different attributes are treated as distinct and need to be closed/reopened. + * Determines which marks to close based on the next node's marks, + * treating same-type marks with different attributes as distinct. */ export function findMarksToClose(currentMarks: Map, nextNode: any): string[] { const marksToClose: string[] = [] @@ -52,10 +87,8 @@ export function findMarksToClose(currentMarks: Map, nextNode: any): } /** - * Identifies marks that need to be opened (in current node but not active, or - * active with different attributes). Two marks of the same type with different - * attributes are treated as distinct — the old one must be closed and the new - * one reopened. + * Determines which marks need to open, treating same-type marks with + * different attributes as distinct (close + reopen). */ export function findMarksToOpen( activeMarks: Map, @@ -74,11 +107,8 @@ export function findMarksToOpen( } /** - * Determines which marks need to be closed at the end of the current text node. - * This handles cases where marks end at node boundaries or when transitioning - * to nodes with different mark sets. - * Compares both mark type and attributes — two marks of the same type with - * different attributes are treated as distinct and trigger a close/reopen. + * Determines which marks to close at the node end, treating same-type marks + * with different attributes as distinct (close + reopen). */ export function findMarksToCloseAtEnd( activeMarks: Map,