Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dedd3be
Update onClick handler to include position parameter
orimdominic Aug 12, 2026
93e3727
fix(core): skip cross-editor drop cleanup for a destroyed source editor
lazerg Aug 26, 2026
65fb84f
docs(core): describe the changeset in user-visible terms
lazerg Aug 26, 2026
4976a13
fix(core): clear the drag source on editor destroy, not on plugin rec…
lazerg Aug 26, 2026
73da270
fix(core): keep keepOnSplit attributes on the content half when split…
giaBaoJS Aug 23, 2026
167072b
docs(core): lead the changeset with the user-visible split behaviour
giaBaoJS Aug 24, 2026
bef6bc1
fix(extension-mathematics): drop the lookbehind from the inline math …
giaBaoJS Aug 23, 2026
ac1c7a0
docs(extension-mathematics): describe the changeset in user-visible t…
giaBaoJS Aug 24, 2026
f040271
docs(extension-mathematics): shorten the comments around the inline m…
giaBaoJS Aug 29, 2026
1d8fba9
fix(core): decouple drag-source cleanup from the paste plugin view
lazerg Sep 5, 2026
2affd48
fix(static-renderer): close audio and video tags instead of self-clos…
breken-ai Sep 25, 2026
2605d9c
fix(static-renderer): close audio and video specs without attributes too
breken-ai Sep 25, 2026
5aba69d
Merge pull request #8266 from lazerg/fix/8265-drop-handler-destroyed-…
arnaugomez Sep 25, 2026
b4d20bc
Merge pull request #8230 from giaBaoJS/fix/inline-math-lookbehind
arnaugomez Sep 25, 2026
e443241
Merge pull request #8228 from giaBaoJS/fix/split-list-item-keep-attrs…
arnaugomez Sep 25, 2026
98fc735
Merge pull request #8382 from breken-ai/fix/static-renderer-audio-clo…
arnaugomez Sep 25, 2026
58f4c69
Merge pull request #8185 from orimdominic/patch-1
arnaugomez Sep 25, 2026
dbecca4
fix(static-renderer): join nested child specs without commas
arnaugomez Sep 25, 2026
aef3880
Merge pull request #8393 from ueberdosis/claude/cranky-tharp-c859f6
arnaugomez Sep 25, 2026
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-23-inline-math-lookbehind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-mathematics': patch
---

The mathematics extension no longer crashes the editor in older WebKit browsers and WKWebView.
5 changes: 5 additions & 0 deletions .changeset/2026-08-26-cross-editor-drop-destroyed-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Fix a `TypeError` thrown when the source editor is destroyed right after dragging content into another editor.
5 changes: 5 additions & 0 deletions .changeset/2026-09-25-static-renderer-audio-closing-tag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/static-renderer': patch
---

Render `<audio>` and `<video>` with a closing tag instead of self-closing them, so browsers no longer nest the following content inside the first audio element.
5 changes: 5 additions & 0 deletions .changeset/2026-09-25-static-renderer-stray-commas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/static-renderer': patch
---

Rendering a node whose `renderHTML` returns several nested child elements no longer adds stray commas between them in the HTML string output.
5 changes: 5 additions & 0 deletions .changeset/fix-split-list-item-checked-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Pressing Enter before the text of a checked task item now leaves the new empty item unchecked and the item with the text checked. Attributes declared with `keepOnSplit: false` now reset on the new item, not on the one that keeps the text.
4 changes: 2 additions & 2 deletions demos/src/Extensions/Mathematics/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ export default () => {
},
},
inlineOptions: {
onClick: node => {
onClick: (node, pos) => {
const newCalculation = prompt('Enter new calculation:', node.attrs.latex)
if (newCalculation) {
editor
.chain()
.setNodeSelection(node.pos)
.setNodeSelection(pos)
.updateInlineMath({ latex: newCalculation })
.focus()
.run()
Expand Down
121 changes: 121 additions & 0 deletions packages/core/__tests__/crossEditorDrop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Editor, Mark, markPasteRule } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'

const editorElClass = 'tiptap-cross-editor-drop-test'

const createEditorEl = () => {
const editorEl = document.createElement('div')

editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)

return editorEl
}

const PasteMark = Mark.create({
name: 'pasteMark',
addPasteRules() {
return [
markPasteRule({
find: /==(.*?)==/g,
type: this.type,
}),
]
},
})

const createEditor = () =>
new Editor({
element: createEditorEl(),
extensions: [Document, Paragraph, Text, PasteMark],
content: '<p>content</p>',
})

const dragBetween = (source: Editor, target: Editor) => {
source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true }))
target.view.dom.dispatchEvent(new Event('drop', { bubbles: true }))
}

describe('cross editor drop', () => {
let source: Editor
let target: Editor

afterEach(() => {
vi.useRealTimers()
source?.destroy()
target?.destroy()
document.querySelectorAll(`.${editorElClass}`).forEach(element => element.remove())
})

it('does not throw when the source editor is destroyed before the deferred delete runs', () => {
vi.useFakeTimers()
source = createEditor()
target = createEditor()

dragBetween(source, target)
source.destroy()

expect(() => vi.advanceTimersByTime(10)).not.toThrow()
})

it('deletes the dragged selection from a source editor that is still alive', () => {
vi.useFakeTimers()
source = createEditor()
target = createEditor()

source.commands.setTextSelection({ from: 1, to: 8 })
dragBetween(source, target)
vi.advanceTimersByTime(10)

expect(source.getHTML()).toBe('<p></p>')
})

it('does not queue a delete when the source editor was destroyed before the drop', () => {
vi.useFakeTimers()
source = createEditor()
target = createEditor()

source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true }))
source.destroy()

const queuedBeforeDrop = vi.getTimerCount()

target.view.dom.dispatchEvent(new Event('drop', { bubbles: true }))

expect(vi.getTimerCount()).toBe(queuedBeforeDrop)
})

it('keeps the queued delete when the source editor registers a plugin during the drag', () => {
vi.useFakeTimers()
source = createEditor()
target = createEditor()

source.commands.setTextSelection({ from: 1, to: 8 })
source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true }))
source.registerPlugin(new Plugin({ key: new PluginKey('noop') }))
target.view.dom.dispatchEvent(new Event('drop', { bubbles: true }))
vi.advanceTimersByTime(10)

expect(source.getHTML()).toBe('<p></p>')
})

it('does not queue a delete when the source editor is unmounted then destroyed', () => {
vi.useFakeTimers()
source = createEditor()
target = createEditor()

source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true }))
source.unmount()
source.destroy()

const queuedBeforeDrop = vi.getTimerCount()

target.view.dom.dispatchEvent(new Event('drop', { bubbles: true }))

expect(vi.getTimerCount()).toBe(queuedBeforeDrop)
})
})
10 changes: 10 additions & 0 deletions packages/core/src/PasteRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
return tr
}

editor.on('destroy', () => {
if (tiptapDragFromOtherEditor === editor) {
tiptapDragFromOtherEditor = null
}
})

const plugins = rules.map(rule => {
return new Plugin({
// we register a global drag handler to track the current drag source element
Expand Down Expand Up @@ -297,6 +303,10 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
if (dragFromOtherEditor?.isEditable) {
// setTimeout to avoid the wrong content after drop, timeout arg can't be empty or 0
setTimeout(() => {
if (dragFromOtherEditor.isDestroyed) {
return
}

const selection = dragFromOtherEditor.state.selection

if (selection) {
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/commands/splitListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,26 @@ export const splitListItem: RawCommands['splitListItem'] =
...overrideAttrs,
}

// `Transform.split` only ever applies `types` to the node created *after* the split, so
// attributes that should not survive a split (`keepOnSplit: false`) normally land on the
// trailing item, which is the newly created one. When the cursor sits at the very start
// of the item, that is inverted: the empty new item is the *leading* one and the trailing
// item is the original content. Detect that case and swap the two sets of attributes.
const splitsAtStartOfItem = $from.parentOffset === 0 && $from.index(-1) === 0
const itemStart = $from.before(-1)

tr.delete($from.pos, $to.pos)

// The trailing item keeps the attributes of the item being split when it is the half that
// carries the content over; the reset is applied to the leading item after the split.
const trailingTypeAttributes = splitsAtStartOfItem ? grandParent.attrs : newTypeAttributes

const types = nextType
? [
{ type, attrs: newTypeAttributes },
{ type, attrs: trailingTypeAttributes },
{ type: nextType, attrs: newNextTypeAttributes },
]
: [{ type, attrs: newTypeAttributes }]
: [{ type, attrs: trailingTypeAttributes }]

if (!canSplit(tr.doc, $from.pos, 2)) {
return false
Expand All @@ -144,6 +156,10 @@ export const splitListItem: RawCommands['splitListItem'] =

tr.split($from.pos, 2, types).scrollIntoView()

if (splitsAtStartOfItem) {
tr.setNodeMarkup(itemStart, undefined, newTypeAttributes)
}

if (!marks || !dispatch) {
return true
}
Expand Down
93 changes: 93 additions & 0 deletions packages/extension-list/src/task-item/task-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,4 +901,97 @@ describe('TaskItem', () => {
expect(from).toBe(1)
expect(to).toBe(14)
})

describe('splitListItem', () => {
function createTaskEditor(checked: boolean, text: string) {
return new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked },
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
},
],
},
],
},
})
}

/** `[checked, text]` for every task item in the first (and only) task list. */
function readTaskItems(instance: Editor) {
return (instance.getJSON().content?.[0].content ?? []).map(item => [
item.attrs?.checked,
item.content?.[0].content?.[0]?.text ?? '',
])
}

it('leaves the checked state on the half that keeps the text when splitting at the start', () => {
editor = createTaskEditor(true, 'Task')
// Offset 0 of the paragraph inside the first task item.
editor.commands.setTextSelection(3)

expect(editor.commands.splitListItem('taskItem')).toBe(true)
expect(readTaskItems(editor)).toEqual([
[false, ''],
[true, 'Task'],
])
})

it('resets the checked state on the trailing half when splitting mid-text', () => {
editor = createTaskEditor(true, 'Task')
editor.commands.setTextSelection(5)

expect(editor.commands.splitListItem('taskItem')).toBe(true)
expect(readTaskItems(editor)).toEqual([
[true, 'Ta'],
[false, 'sk'],
])
})

it('resets the checked state on the trailing half when splitting at the end', () => {
editor = createTaskEditor(true, 'Task')
editor.commands.setTextSelection(7)

expect(editor.commands.splitListItem('taskItem')).toBe(true)
expect(readTaskItems(editor)).toEqual([
[true, 'Task'],
[false, ''],
])
})

it('splits a plain list item at the start without moving its content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
editor.commands.setTextSelection(3)

expect(editor.commands.splitListItem('listItem')).toBe(true)
expect(
(editor.getJSON().content?.[0].content ?? []).map(
item => item.content?.[0].content?.[0]?.text ?? '',
),
).toEqual(['', 'Item'])
})
})
})
66 changes: 66 additions & 0 deletions packages/extension-mathematics/src/extensions/InlineMath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,70 @@ describe('InlineMath', () => {
// Expect no input rule to match
expect(handled).toBeFalsy()
})

// Types a trailing `$` and returns whether an input rule handled it.
const typeTrailingDollar = (initialText: string) => {
editor = new Editor({
extensions: [Document, Paragraph, Text, InlineMath],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: initialText }],
},
],
},
})

editor.commands.setTextSelection(editor.state.doc.nodeSize)

return editor.view.someProp('handleTextInput', f =>
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
)
}

it('replaces only the math when the preceding character is not a space', () => {
typeTrailingDollar('x$$a$')

expect(editor.getJSON().content).toEqual([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'x' },
{ type: 'inlineMath', attrs: { latex: 'a' } },
],
},
])
})

it('matches math at the very start of a paragraph', () => {
typeTrailingDollar('$$a$')

expect(editor.getJSON().content).toEqual([
{
type: 'paragraph',
content: [{ type: 'inlineMath', attrs: { latex: 'a' } }],
},
])
})

it('skips a rejected match and still matches the one that follows it', () => {
// The first `$$a$$` is rejected, but a valid match starts at its trailing `$$`.
typeTrailingDollar('$$$a$$b$')

expect(editor.getJSON().content).toEqual([
{
type: 'paragraph',
content: [
{ type: 'text', text: '$$$a' },
{ type: 'inlineMath', attrs: { latex: 'b' } },
],
},
])
})

it('does not match when the closing delimiter is followed by a third $', () => {
expect(typeTrailingDollar('$$a$$')).toBeFalsy()
})
})
Loading
Loading