diff --git a/.changeset/2026-08-31-splitblock-stale-cansplit.md b/.changeset/2026-08-31-splitblock-stale-cansplit.md new file mode 100644 index 0000000000..3cf028dcda --- /dev/null +++ b/.changeset/2026-08-31-splitblock-stale-cansplit.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +`splitBlock` no longer throws `TransformError: Inserted content deeper than insertion position` when the selection spans block boundaries (for example from the start of one paragraph into another block, or across an isolating node). The command now returns `false` when the split is not possible. diff --git a/.changeset/pin-react-vue-menu-deps.md b/.changeset/pin-react-vue-menu-deps.md new file mode 100644 index 0000000000..33dc143f66 --- /dev/null +++ b/.changeset/pin-react-vue-menu-deps.md @@ -0,0 +1,6 @@ +--- +'@tiptap/react': patch +'@tiptap/vue': patch +--- + +`@tiptap/react` and `@tiptap/vue` now pin their optional `@tiptap/extension-bubble-menu` and `@tiptap/extension-floating-menu` dependencies to the exact version they were released with, so installing a specific version gives you that version's menu extensions instead of the newest ones. diff --git a/packages/core/__tests__/splitBlock.spec.ts b/packages/core/__tests__/splitBlock.spec.ts new file mode 100644 index 0000000000..34352c87e0 --- /dev/null +++ b/packages/core/__tests__/splitBlock.spec.ts @@ -0,0 +1,98 @@ +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 { describe, expect, it } from 'vite-plus/test' + +const Callout = Node.create({ + name: 'callout', + group: 'block', + content: 'paragraph+', + isolating: true, + renderHTML() { + return ['div', { 'data-callout': '' }, 0] + }, + parseHTML() { + return [ + { + tag: 'div[data-callout]', + }, + ] + }, +}) + +const createEditor = (content: string) => + new Editor({ + extensions: [Document, Paragraph, Text, Callout], + content, + }) + +describe('splitBlock', () => { + it('splits a paragraph at the cursor', () => { + const editor = createEditor('

onetwo

') + + editor.commands.setTextSelection(4) + + expect(editor.commands.splitBlock()).toBe(true) + expect(editor.getHTML()).toBe('

one

two

') + }) + + it('deletes the selection before splitting when the blocks can merge', () => { + const editor = createEditor('

one

two

') + + // from inside the first paragraph to inside the second one + editor.commands.setTextSelection({ from: 3, to: 7 }) + + expect(editor.commands.splitBlock()).toBe(true) + expect(editor.getHTML()).toBe('

on

wo

') + }) + + it('does not throw when the selection starts at the start of a block and ends in another block', () => { + const editor = createEditor('

one

two

') + + // start of the first paragraph to start of the second one: deleting this + // selection removes the first paragraph entirely, so the mapped split + // position resolves at depth 0 + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.splitBlock()).not.toThrow() + }) + + it('returns false and leaves the document unchanged when the split position is invalid after deleting the selection', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(editor.commands.splitBlock()).toBe(false) + expect(editor.getHTML()).toBe('

one

two

') + }) + + it('does not throw when the selection spans from a paragraph into an isolating node', () => { + const editor = createEditor('

one

two

') + + // start of the paragraph to the start of the isolating node's content: + // deleting this selection removes the paragraph, and splitting at the + // stale position throws "Invalid content for node callout" (same root + // cause, different message) + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.splitBlock()).not.toThrow() + }) + + it('does not throw when pressing Enter with a selection spanning block boundaries', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.keyboardShortcut('Enter')).not.toThrow() + }) + + it('can().splitBlock() returns false without mutating the document', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(editor.can().splitBlock()).toBe(false) + expect(editor.getHTML()).toBe('

one

two

') + }) +}) diff --git a/packages/core/src/commands/splitBlock.ts b/packages/core/src/commands/splitBlock.ts index 6f264da40f..10a9e70f64 100644 --- a/packages/core/src/commands/splitBlock.ts +++ b/packages/core/src/commands/splitBlock.ts @@ -1,5 +1,4 @@ -import type { EditorState } from '@tiptap/pm/state' -import { NodeSelection, TextSelection } from '@tiptap/pm/state' +import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state' import { canSplit } from '@tiptap/pm/transform' import { defaultBlockAt } from '../helpers/defaultBlockAt.js' @@ -80,13 +79,21 @@ export const splitBlock: RawCommands['splitBlock'] = ] : undefined - let can = canSplit(tr.doc, tr.mapping.map($from.pos), 1, types) - - if ( - !types && - !can && - canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined) - ) { + // Deleting the selection can restructure the document, so the split has to + // be validated against the document as it will look after the deletion. + // Simulate the deletion on a scratch transaction instead of mutating `tr`: + // the shared transaction is dispatched even when a command returns `false`, + // so it must stay untouched until the split is known to be possible. + const willDeleteSelection = selection instanceof TextSelection && !selection.empty + const deletedTr = willDeleteSelection + ? EditorState.create({ doc: tr.doc, selection }).tr.deleteSelection() + : null + const splitDoc = deletedTr ? deletedTr.doc : tr.doc + const splitPos = deletedTr ? deletedTr.mapping.map($from.pos) : $from.pos + + let can = canSplit(splitDoc, splitPos, 1, types) + + if (!types && !can && canSplit(splitDoc, splitPos, 1, deflt ? [{ type: deflt }] : undefined)) { can = true types = deflt ? [ @@ -100,18 +107,20 @@ export const splitBlock: RawCommands['splitBlock'] = if (dispatch) { if (can) { + const mapFrom = tr.steps.length + if (selection instanceof TextSelection) { tr.deleteSelection() } - tr.split(tr.mapping.map($from.pos), 1, types) + tr.split(tr.mapping.slice(mapFrom).map($from.pos), 1, types) if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) { - const first = tr.mapping.map($from.before()) + const first = tr.mapping.slice(mapFrom).map($from.before()) const $first = tr.doc.resolve(first) if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) { - tr.setNodeMarkup(tr.mapping.map($from.before()), deflt) + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map($from.before()), deflt) } } } diff --git a/packages/react/package.json b/packages/react/package.json index 4c0d1fe781..1350857481 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -74,7 +74,7 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "workspace:^", - "@tiptap/extension-floating-menu": "workspace:^" + "@tiptap/extension-bubble-menu": "workspace:*", + "@tiptap/extension-floating-menu": "workspace:*" } } diff --git a/packages/vue/package.json b/packages/vue/package.json index 655dba1dc1..933f794eea 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -62,7 +62,7 @@ "vue": "^3.0.0" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "workspace:^", - "@tiptap/extension-floating-menu": "workspace:^" + "@tiptap/extension-bubble-menu": "workspace:*", + "@tiptap/extension-floating-menu": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0e4a7c3b0..32fa9a2fe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -974,10 +974,10 @@ importers: version: 0.3.0(@types/node@22.20.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@22.20.1)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.50.0)(typescript@5.9.3))(happy-dom@20.11.2)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(svelte@5.56.8)(terser@5.50.0)(typescript@5.9.3) optionalDependencies: '@tiptap/extension-bubble-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-bubble-menu '@tiptap/extension-floating-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-floating-menu packages/starter-kit: @@ -1093,10 +1093,10 @@ importers: version: 3.5.41(typescript@5.9.3) optionalDependencies: '@tiptap/extension-bubble-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-bubble-menu '@tiptap/extension-floating-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-floating-menu packages: