Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-08-31-splitblock-stale-cansplit.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/pin-react-vue-menu-deps.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions packages/core/__tests__/splitBlock.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<p>onetwo</p>')

editor.commands.setTextSelection(4)

expect(editor.commands.splitBlock()).toBe(true)
expect(editor.getHTML()).toBe('<p>one</p><p>two</p>')
})

it('deletes the selection before splitting when the blocks can merge', () => {
const editor = createEditor('<p>one</p><p>two</p>')

// 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('<p>on</p><p>wo</p>')
})

it('does not throw when the selection starts at the start of a block and ends in another block', () => {
const editor = createEditor('<p>one</p><p>two</p>')

// 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('<p>one</p><p>two</p>')

editor.commands.setTextSelection({ from: 1, to: 6 })

expect(editor.commands.splitBlock()).toBe(false)
expect(editor.getHTML()).toBe('<p>one</p><p>two</p>')
})

it('does not throw when the selection spans from a paragraph into an isolating node', () => {
const editor = createEditor('<p>one</p><div data-callout><p>two</p></div>')

// 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('<p>one</p><p>two</p>')

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('<p>one</p><p>two</p>')

editor.commands.setTextSelection({ from: 1, to: 6 })

expect(editor.can().splitBlock()).toBe(false)
expect(editor.getHTML()).toBe('<p>one</p><p>two</p>')
})
})
33 changes: 21 additions & 12 deletions packages/core/src/commands/splitBlock.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
? [
Expand All @@ -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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
4 changes: 2 additions & 2 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading