From 3b6e3879ff687763ad18434e6484343e4ced04ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 11:20:36 +0000 Subject: [PATCH 1/5] fix: keep table CellSelection when opening the context menu Right-clicking a cell inside a multi-cell table selection no longer collapses that selection, so table context-menu actions still apply to the full selection. Fixes ueberdosis/tiptap#6396 Co-authored-by: Bennett Payoyo --- ...serve-table-cell-selection-context-menu.md | 5 + packages/extension-table/src/table/table.ts | 2 + ...preserveCellSelectionOnContextMenu.test.ts | 166 ++++++++++++++++++ .../preserveCellSelectionOnContextMenu.ts | 69 ++++++++ 4 files changed, 242 insertions(+) create mode 100644 .changeset/2026-09-07-preserve-table-cell-selection-context-menu.md create mode 100644 packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.test.ts create mode 100644 packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.ts diff --git a/.changeset/2026-09-07-preserve-table-cell-selection-context-menu.md b/.changeset/2026-09-07-preserve-table-cell-selection-context-menu.md new file mode 100644 index 0000000000..d0c7305104 --- /dev/null +++ b/.changeset/2026-09-07-preserve-table-cell-selection-context-menu.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-table': patch +--- + +Right-clicking a cell inside a multi-cell table selection no longer collapses that selection. diff --git a/packages/extension-table/src/table/table.ts b/packages/extension-table/src/table/table.ts index 12d10bb5ea..729f13f727 100644 --- a/packages/extension-table/src/table/table.ts +++ b/packages/extension-table/src/table/table.ts @@ -39,6 +39,7 @@ import { createTable } from './utilities/createTable.js' import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js' import { keepCursorInTable } from './utilities/keepCursorInTable.js' import renderTableToMarkdown, { preprocessTablePipes } from './utilities/markdown.js' +import { preserveCellSelectionOnContextMenu } from './utilities/preserveCellSelectionOnContextMenu.js' type MarkdownTableToken = { align?: Array @@ -611,6 +612,7 @@ export const Table = Node.create({ tableEditing({ allowTableNodeSelection: this.options.allowTableNodeSelection, }), + preserveCellSelectionOnContextMenu(), ] }, diff --git a/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.test.ts b/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.test.ts new file mode 100644 index 0000000000..9f6fe907a6 --- /dev/null +++ b/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { CellSelection } from '@tiptap/pm/tables' +import { afterEach, describe, expect, it } from 'vite-plus/test' + +import { TableCell } from '../../cell/table-cell.js' +import { TableHeader } from '../../header/table-header.js' +import { TableRow } from '../../row/table-row.js' +import { Table } from '../table.js' + +const collectCellPositions = (editor: Editor): number[] => { + const positions: number[] = [] + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + positions.push(pos) + } + }) + + return positions +} + +const createTableEditor = (content: string) => { + const element = document.createElement('div') + document.body.appendChild(element) + + return new Editor({ + element, + extensions: [Document, Paragraph, Text, Table, TableRow, TableHeader, TableCell], + content, + }) +} + +const dispatchMouseDown = (editor: Editor, cell: Element, button: number, clickPos: number) => { + editor.view.posAtCoords = () => ({ pos: clickPos, inside: clickPos }) + + const event = new MouseEvent('mousedown', { + button, + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }) + + cell.dispatchEvent(event) + return event +} + +describe('preserveCellSelectionOnContextMenu', () => { + let editor: Editor + + afterEach(() => { + editor?.destroy() + }) + + it('keeps a multi-cell CellSelection when right-clicking a non-anchor cell', () => { + editor = createTableEditor( + '
A1B1C1
A2B2C2
', + ) + + const cells = collectCellPositions(editor) + editor.chain().focus().setCellSelection({ anchorCell: cells[1], headCell: cells[0] }).run() + + const selectionBefore = editor.state.selection + expect(selectionBefore).toBeInstanceOf(CellSelection) + expect(selectionBefore.ranges).toHaveLength(2) + + const event = dispatchMouseDown( + editor, + editor.view.dom.querySelectorAll('td')[0], + 2, + cells[0] + 1, + ) + + expect(event.defaultPrevented).toBe(true) + expect(editor.state.selection).toBeInstanceOf(CellSelection) + expect(editor.state.selection.ranges).toHaveLength(2) + expect(editor.state.selection.eq(selectionBefore)).toBe(true) + }) + + it('does not intercept left-clicks, so the selection can still change', () => { + editor = createTableEditor( + '
A1B1C1
A2B2C2
', + ) + + const cells = collectCellPositions(editor) + editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[1] }).run() + + const event = dispatchMouseDown( + editor, + editor.view.dom.querySelectorAll('td')[0], + 0, + cells[0] + 1, + ) + + expect(event.defaultPrevented).toBe(false) + expect(editor.state.selection).toBeInstanceOf(CellSelection) + + editor + .chain() + .setTextSelection(cells[0] + 2) + .run() + + expect(editor.state.selection).not.toBeInstanceOf(CellSelection) + }) + + it('does not preserve a CellSelection when right-clicking outside it', () => { + editor = createTableEditor( + '
A1B1C1
A2B2C2
', + ) + + const cells = collectCellPositions(editor) + editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[1] }).run() + + const event = dispatchMouseDown( + editor, + editor.view.dom.querySelectorAll('td')[2], + 2, + cells[2] + 1, + ) + + expect(event.defaultPrevented).toBe(false) + }) + + it('keeps a multi-cell CellSelection when right-clicking a table header', () => { + editor = createTableEditor( + '
ABC
A2B2C2
', + ) + + const cells = collectCellPositions(editor) + editor.chain().focus().setCellSelection({ anchorCell: cells[1], headCell: cells[0] }).run() + + const event = dispatchMouseDown( + editor, + editor.view.dom.querySelectorAll('th')[0], + 2, + cells[0] + 1, + ) + + expect(event.defaultPrevented).toBe(true) + expect(editor.state.selection).toBeInstanceOf(CellSelection) + expect(editor.state.selection.ranges).toHaveLength(2) + }) + + it('does not intercept right-clicks on a single-cell CellSelection', () => { + editor = createTableEditor( + '
A1B1C1
A2B2C2
', + ) + + const cells = collectCellPositions(editor) + editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[0] }).run() + + const event = dispatchMouseDown( + editor, + editor.view.dom.querySelectorAll('td')[0], + 2, + cells[0] + 1, + ) + + expect(event.defaultPrevented).toBe(false) + }) +}) diff --git a/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.ts b/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.ts new file mode 100644 index 0000000000..b80abac4c3 --- /dev/null +++ b/packages/extension-table/src/table/utilities/preserveCellSelectionOnContextMenu.ts @@ -0,0 +1,69 @@ +import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { CellSelection } from '@tiptap/pm/tables' +import type { EditorView } from '@tiptap/pm/view' + +import { isCellSelection } from './isCellSelection.js' + +const preserveCellSelectionOnContextMenuPluginKey = new PluginKey( + 'preserveCellSelectionOnContextMenu', +) + +function resolveClickPos(view: EditorView, event: MouseEvent): number | null { + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) + + if (coords) { + return coords.pos + } + + if (!(event.target instanceof Node)) { + return null + } + + try { + return view.posAtDOM(event.target, 0) + } catch { + return null + } +} + +function isPosInsideCellSelection(selection: CellSelection, pos: number): boolean { + let inside = false + + selection.forEachCell((cell, cellPos) => { + if (pos >= cellPos && pos < cellPos + cell.nodeSize) { + inside = true + } + }) + + return inside +} + +export function preserveCellSelectionOnContextMenu() { + return new Plugin({ + key: preserveCellSelectionOnContextMenuPluginKey, + props: { + handleDOMEvents: { + mousedown(view, event) { + if (event.button !== 2) { + return false + } + + const { selection } = view.state + + if (!isCellSelection(selection) || selection.ranges.length < 2) { + return false + } + + const clickPos = resolveClickPos(view, event) + + if (clickPos == null || !isPosInsideCellSelection(selection, clickPos)) { + return false + } + + event.preventDefault() + return true + }, + }, + }, + }) +} From 3451d1075e5c9581e81acc4bdab4007399b38b21 Mon Sep 17 00:00:00 2001 From: Issac Gerges <44379112+issacgerges@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:34:36 -0400 Subject: [PATCH 2/5] perf(core): reuse the inverted mapping in getChangedRanges --- ...6-09-08-core-get-changed-ranges-mapping.md | 5 +++ .../core/src/helpers/getChangedRanges.test.ts | 37 +++++++++++++++++++ packages/core/src/helpers/getChangedRanges.ts | 11 ++++-- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .changeset/2026-09-08-core-get-changed-ranges-mapping.md diff --git a/.changeset/2026-09-08-core-get-changed-ranges-mapping.md b/.changeset/2026-09-08-core-get-changed-ranges-mapping.md new file mode 100644 index 0000000000..d074ce7273 --- /dev/null +++ b/.changeset/2026-09-08-core-get-changed-ranges-mapping.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Large transactions (for example a big paste with Link autolink enabled) are processed faster. diff --git a/packages/core/src/helpers/getChangedRanges.test.ts b/packages/core/src/helpers/getChangedRanges.test.ts index f7a31be60c..0e5c9ef736 100644 --- a/packages/core/src/helpers/getChangedRanges.test.ts +++ b/packages/core/src/helpers/getChangedRanges.test.ts @@ -36,6 +36,43 @@ describe('getChangedRanges', () => { expect(changes[0].oldRange).toEqual({ from: 2, to: 7 }) }) + it('multiple steps: each range is mapped through later steps and back to the original doc', () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Bold], + content: '

hello world

second paragraph here

', + }) + const doc = editor.state.doc + const { schema } = doc.type + editor.destroy() + + const transform = new Transform(doc) + + transform.insert(3, schema.text('abc')) + transform.delete(8, 11) + transform.addMark(2, 6, schema.marks.bold.create()) + transform.insert(20, schema.text('xyz')) + transform.replaceWith(1, 4, schema.text('Q')) + + expect(getChangedRanges(transform)).toEqual([ + { oldRange: { from: 1, to: 3 }, newRange: { from: 1, to: 4 } }, + { oldRange: { from: 5, to: 8 }, newRange: { from: 6, to: 6 } }, + { oldRange: { from: 20, to: 20 }, newRange: { from: 18, to: 21 } }, + ]) + }) + + it('many steps: contained ranges collapse to one', () => { + const doc = createDoc() + const transform = new Transform(doc) + + for (let index = 0; index < 200; index += 1) { + transform.insert(2 + index, doc.type.schema.text('a')) + } + + expect(getChangedRanges(transform)).toEqual([ + { oldRange: { from: 2, to: 2 }, newRange: { from: 2, to: 202 } }, + ]) + }) + it('no-position fallback branch: DocAttrStep produces no changed ranges', () => { const doc = createDoc() const transform = new Transform(doc) diff --git a/packages/core/src/helpers/getChangedRanges.ts b/packages/core/src/helpers/getChangedRanges.ts index 71a007f099..fc25c7699e 100644 --- a/packages/core/src/helpers/getChangedRanges.ts +++ b/packages/core/src/helpers/getChangedRanges.ts @@ -38,6 +38,7 @@ function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] { export function getChangedRanges(transform: Transform): ChangedRange[] { const { mapping, steps } = transform const changes: ChangedRange[] = [] + const inverted = mapping.invert() mapping.maps.forEach((stepMap, index) => { const ranges: Range[] = [] @@ -62,11 +63,13 @@ export function getChangedRanges(transform: Transform): ChangedRange[] { }) } + const rest = mapping.slice(index) + ranges.forEach(({ from, to }) => { - const newStart = mapping.slice(index).map(from, -1) - const newEnd = mapping.slice(index).map(to) - const oldStart = mapping.invert().map(newStart, -1) - const oldEnd = mapping.invert().map(newEnd) + const newStart = rest.map(from, -1) + const newEnd = rest.map(to) + const oldStart = inverted.map(newStart, -1) + const oldEnd = inverted.map(newEnd) changes.push({ oldRange: { From 3f111e95de442013283dd59370d4394a14d20d2e Mon Sep 17 00:00:00 2001 From: Burak Keskin Date: Wed, 9 Sep 2026 14:26:44 +0300 Subject: [PATCH 3/5] fix(core): do not join items across isolating nodes --- .changeset/join-items-isolating.md | 5 + packages/core/src/commands/canJoinItemsAt.ts | 14 ++ .../core/src/commands/joinItemBackward.ts | 5 + .../core/src/commands/joinItemForward.test.ts | 138 ++++++++++++++++ packages/core/src/commands/joinItemForward.ts | 5 + .../listKeymapIsolatingDelete.test.ts | 150 ++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 .changeset/join-items-isolating.md create mode 100644 packages/core/src/commands/canJoinItemsAt.ts create mode 100644 packages/core/src/commands/joinItemForward.test.ts create mode 100644 packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts diff --git a/.changeset/join-items-isolating.md b/.changeset/join-items-isolating.md new file mode 100644 index 0000000000..1d0c80c409 --- /dev/null +++ b/.changeset/join-items-isolating.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Stop joinItemForward and joinItemBackward from joining across isolating nodes. diff --git a/packages/core/src/commands/canJoinItemsAt.ts b/packages/core/src/commands/canJoinItemsAt.ts new file mode 100644 index 0000000000..1ebe639148 --- /dev/null +++ b/packages/core/src/commands/canJoinItemsAt.ts @@ -0,0 +1,14 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** False when the siblings around `pos` are isolating and must not be joined. */ +export const canJoinItemsAt = (doc: ProseMirrorNode, pos: number) => { + const $pos = doc.resolve(pos) + const before = $pos.nodeBefore + const after = $pos.nodeAfter + + if (!before || !after) { + return false + } + + return !before.type.spec.isolating && !after.type.spec.isolating +} diff --git a/packages/core/src/commands/joinItemBackward.ts b/packages/core/src/commands/joinItemBackward.ts index 45b69f54b7..28191f11e4 100644 --- a/packages/core/src/commands/joinItemBackward.ts +++ b/packages/core/src/commands/joinItemBackward.ts @@ -1,6 +1,7 @@ import { joinPoint } from '@tiptap/pm/transform' import type { RawCommands } from '../types.js' +import { canJoinItemsAt } from './canJoinItemsAt.js' declare module '@tiptap/core' { interface Commands { @@ -24,6 +25,10 @@ export const joinItemBackward: RawCommands['joinItemBackward'] = return false } + if (!canJoinItemsAt(state.doc, point)) { + return false + } + tr.join(point, 2) if (dispatch) { diff --git a/packages/core/src/commands/joinItemForward.test.ts b/packages/core/src/commands/joinItemForward.test.ts new file mode 100644 index 0000000000..11da51ec45 --- /dev/null +++ b/packages/core/src/commands/joinItemForward.test.ts @@ -0,0 +1,138 @@ +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' + +import { Editor } from '../Editor.js' +import { Node } from '../Node.js' + +const Wrapper = Node.create({ + name: 'wrapper', + group: 'block', + content: 'block+', + parseHTML() { + return [{ tag: 'div[data-wrapper]' }] + }, + renderHTML() { + return ['div', { 'data-wrapper': '' }, 0] + }, +}) + +const Frame = Node.create({ + name: 'frame', + group: 'block', + content: 'block+', + isolating: true, + parseHTML() { + return [{ tag: 'div[data-frame]' }] + }, + renderHTML() { + return ['div', { 'data-frame': '' }, 0] + }, +}) + +function findTextEnd(editor: Editor, text: string) { + let position: number | null = null + + editor.state.doc.descendants((node, pos) => { + if (position !== null) { + return false + } + + if (node.isText && node.text === text) { + position = pos + node.text.length + } + }) + + if (position === null) { + throw new Error(`Could not find text position for "${text}"`) + } + + return position +} + +describe('joinItemForward', () => { + it('joins adjacent non-isolating wrappers', () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Wrapper], + content: { + type: 'doc', + content: [ + { + type: 'wrapper', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }], + }, + { + type: 'wrapper', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'world' }] }], + }, + ], + }, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'hello')) + + expect(editor.commands.joinItemForward()).toBe(true) + expect(editor.getJSON().content).toHaveLength(1) + expect(JSON.stringify(editor.getJSON())).toContain('helloworld') + + editor.destroy() + }) + + it('does not join isolating frames', () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Frame], + content: { + type: 'doc', + content: [ + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }], + }, + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }], + }, + ], + }, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'hello')) + const before = editor.getJSON() + + expect(editor.commands.joinItemForward()).toBe(false) + expect(editor.getJSON()).toEqual(before) + expect(editor.getJSON().content).toHaveLength(2) + + editor.destroy() + }) +}) + +describe('joinItemBackward', () => { + it('does not join isolating frames', () => { + const editor = new Editor({ + extensions: [Document, Paragraph, Text, Frame], + content: { + type: 'doc', + content: [ + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }], + }, + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }], + }, + ], + }, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'whoops') - 'whoops'.length) + const before = editor.getJSON() + + expect(editor.commands.joinItemBackward()).toBe(false) + expect(editor.getJSON()).toEqual(before) + + editor.destroy() + }) +}) diff --git a/packages/core/src/commands/joinItemForward.ts b/packages/core/src/commands/joinItemForward.ts index e292959784..2916ca18ff 100644 --- a/packages/core/src/commands/joinItemForward.ts +++ b/packages/core/src/commands/joinItemForward.ts @@ -1,6 +1,7 @@ import { joinPoint } from '@tiptap/pm/transform' import type { RawCommands } from '../types.js' +import { canJoinItemsAt } from './canJoinItemsAt.js' declare module '@tiptap/core' { interface Commands { @@ -24,6 +25,10 @@ export const joinItemForward: RawCommands['joinItemForward'] = return false } + if (!canJoinItemsAt(state.doc, point)) { + return false + } + tr.join(point, 2) if (dispatch) { diff --git a/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts b/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts new file mode 100644 index 0000000000..c4ba8b2bff --- /dev/null +++ b/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts @@ -0,0 +1,150 @@ +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 { afterEach, describe, expect, it } from 'vite-plus/test' + +import { ListItem, ListKeymap, OrderedList } from '../../src/index.js' + +const Frame = Node.create({ + name: 'frame', + content: 'block+', + isolating: true, + parseHTML() { + return [{ tag: 'div[data-frame]' }] + }, + renderHTML() { + return ['div', { 'data-frame': '' }, 0] + }, +}) + +const Page = Document.extend({ + content: 'frame+', +}) + +const isolatingFramesContent = { + type: 'doc', + content: [ + { + type: 'frame', + content: [ + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }], + }, + ], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'world' }] }, + ], + }, + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }], + }, + ], +} + +function findTextEnd(editor: Editor, text: string) { + let position: number | null = null + + editor.state.doc.descendants((node, pos) => { + if (position !== null) { + return false + } + + if (node.isText && node.text === text) { + position = pos + node.text.length + } + }) + + if (position === null) { + throw new Error(`Could not find text position for "${text}"`) + } + + return position +} + +function dispatchDelete(editor: Editor) { + return editor.view.someProp('handleKeyDown', f => + f( + editor.view, + new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }), + ), + ) +} + +describe('ListKeymap Delete across isolating frames (#8321)', () => { + let editor: Editor + + afterEach(() => { + editor?.destroy() + }) + + it('joins the following paragraph in the same frame and leaves the next frame alone', () => { + editor = new Editor({ + extensions: [Page, Paragraph, Text, Frame, OrderedList, ListItem, ListKeymap], + content: isolatingFramesContent, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'hello')) + + expect(dispatchDelete(editor)).toBe(true) + + const frames = editor.getJSON().content + const first = JSON.stringify(frames?.[0]) + const second = JSON.stringify(frames?.[1]) + + expect(frames).toHaveLength(2) + expect(first).toContain('hello') + expect(first).toContain('world') + expect(first).not.toContain('whoops') + expect(second).toContain('whoops') + expect(second).not.toContain('world') + }) + + it('still joins two list items inside the same isolating frame', () => { + editor = new Editor({ + extensions: [Page, Paragraph, Text, Frame, OrderedList, ListItem, ListKeymap], + content: { + type: 'doc', + content: [ + { + type: 'frame', + content: [ + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'next' }] }], + }, + ], + }, + ], + }, + { + type: 'frame', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }], + }, + ], + }, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'hello')) + + expect(dispatchDelete(editor)).toBe(true) + + const frames = editor.getJSON().content + + expect(frames).toHaveLength(2) + expect(JSON.stringify(frames?.[0])).toContain('hellonext') + expect(JSON.stringify(frames?.[1])).toContain('whoops') + }) +}) From 6520e58b19b778307f88eb70a50ab719661b4dfa Mon Sep 17 00:00:00 2001 From: Ashwin Mridul Date: Fri, 18 Sep 2026 09:36:25 +0530 Subject: [PATCH 4/5] fix(markdown): bind parse and render hooks to the configured extension MarkdownManager resolved parseMarkdown and renderMarkdown without the extension as this, so configured options were undefined. Bind those hooks (and related markdown fields) the same way other extension methods are resolved. Co-authored-by: Cursor --- .../fix-markdown-hook-extension-context.md | 6 + packages/core/src/Extendable.ts | 23 +++- packages/markdown/src/MarkdownManager.test.ts | 119 ++++++++++++++++++ packages/markdown/src/MarkdownManager.ts | 14 ++- 4 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-markdown-hook-extension-context.md diff --git a/.changeset/fix-markdown-hook-extension-context.md b/.changeset/fix-markdown-hook-extension-context.md new file mode 100644 index 0000000000..c9f6d7534c --- /dev/null +++ b/.changeset/fix-markdown-hook-extension-context.md @@ -0,0 +1,6 @@ +--- +'@tiptap/core': patch +'@tiptap/markdown': patch +--- + +Bind `parseMarkdown` and `renderMarkdown` to the configured extension so hooks can read `this.options` and `this.name` without an Editor. diff --git a/packages/core/src/Extendable.ts b/packages/core/src/Extendable.ts index 15a30cbaaa..d378b63b1c 100644 --- a/packages/core/src/Extendable.ts +++ b/packages/core/src/Extendable.ts @@ -304,13 +304,34 @@ export interface ExtendableConfig< /** * The parse function used by the markdown parser to convert markdown tokens to ProseMirror nodes. + * + * Bound to the configured extension instance, so `this.name`, `this.options` + * and `this.storage` are available even when parsing without an Editor. */ - parseMarkdown?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => MarkdownParseResult + parseMarkdown?: ( + this: { + name: string + options: Options + storage: Storage + parent: ParentConfig['parseMarkdown'] + }, + token: MarkdownToken, + helpers: MarkdownParseHelpers, + ) => MarkdownParseResult /** * The serializer function used by the markdown serializer to convert ProseMirror nodes to markdown tokens. + * + * Bound to the configured extension instance, so `this.name`, `this.options` + * and `this.storage` are available even when serializing without an Editor. */ renderMarkdown?: ( + this: { + name: string + options: Options + storage: Storage + parent: ParentConfig['renderMarkdown'] + }, node: JSONContent, helpers: MarkdownRendererHelpers, ctx: RenderContext, diff --git a/packages/markdown/src/MarkdownManager.test.ts b/packages/markdown/src/MarkdownManager.test.ts index 4208a1944e..36368935da 100644 --- a/packages/markdown/src/MarkdownManager.test.ts +++ b/packages/markdown/src/MarkdownManager.test.ts @@ -876,4 +876,123 @@ Some text with [@ id="user" label="User"]. expect(rendered).toContain('Level 2') }) }) + + describe('configured extension context for markdown hooks', () => { + const createParagraphWithUrlHooks = () => + Node.create({ + name: 'paragraph', + group: 'block', + content: 'inline*', + + addOptions() { + return { + repairUrl: (url: string) => url, + serializeMarkdownUrl: (url: string) => url, + } + }, + + parseMarkdown(token, helpers) { + return helpers.createNode( + this.name, + { url: this.options.repairUrl(token.text || '') }, + helpers.parseInline(token.tokens || []), + ) + }, + + renderMarkdown(node) { + return this.options.serializeMarkdownUrl(node.attrs?.url) + }, + }) + + const configuredParagraph = createParagraphWithUrlHooks().configure({ + repairUrl: (url: string) => `https://example.com/${url}`, + serializeMarkdownUrl: (url: string) => `<${url}>`, + }) + + const createManager = (paragraph: AnyExtension) => + new MarkdownManager({ + extensions: [paragraph, Text], + }) + + it('exposes configured options and name while parsing and serializing', () => { + const manager = createManager(configuredParagraph) + const parsed = manager.parse('asset.png') + + expect(parsed).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { url: 'https://example.com/asset.png' }, + content: [{ type: 'text', text: 'asset.png' }], + }, + ], + }) + + expect( + manager.serialize({ + type: 'paragraph', + attrs: { url: 'https://example.com/asset.png' }, + content: [{ type: 'text', text: 'asset.png' }], + }), + ).toBe('') + }) + + it('keeps independent configured instances isolated', () => { + const first = createParagraphWithUrlHooks().configure({ + repairUrl: (url: string) => `https://first.example/${url}`, + serializeMarkdownUrl: (url: string) => ``, + }) + const second = createParagraphWithUrlHooks().configure({ + repairUrl: (url: string) => `https://second.example/${url}`, + serializeMarkdownUrl: (url: string) => ``, + }) + + expect(createManager(first).parse('asset.png').content![0].attrs).toEqual({ + url: 'https://first.example/asset.png', + }) + expect(createManager(second).parse('asset.png').content![0].attrs).toEqual({ + url: 'https://second.example/asset.png', + }) + }) + + it('merges chained configure() calls into markdown hooks', () => { + const paragraph = createParagraphWithUrlHooks() + .configure({ + repairUrl: (url: string) => `https://first.example/${url}`, + }) + .configure({ + serializeMarkdownUrl: (url: string) => `<${url}>`, + repairUrl: (url: string) => `https://chained.example/${url}`, + }) + + const manager = createManager(paragraph) + const parsed = manager.parse('asset.png') + + expect(parsed.content![0].attrs).toEqual({ url: 'https://chained.example/asset.png' }) + expect(manager.serialize(parsed.content![0])).toBe('') + }) + + it('lets markdown hooks call their parent with the configured context', () => { + const paragraph = createParagraphWithUrlHooks() + .extend({ + parseMarkdown(token, helpers) { + return this.parent?.(token, helpers) + }, + renderMarkdown(node, helpers, ctx) { + return this.parent?.(node, helpers, ctx) ?? '' + }, + }) + .configure({ + repairUrl: (url: string) => `https://example.com/${url}`, + serializeMarkdownUrl: (url: string) => `<${url}>`, + }) + + const manager = createManager(paragraph) + const parsed = manager.parse('asset.png') + + expect(parsed.content![0].attrs).toEqual({ url: 'https://example.com/asset.png' }) + expect(manager.serialize(parsed.content![0])).toBe('') + }) + }) }) diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 7a287409dd..8ca86d3e78 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -132,6 +132,11 @@ export class MarkdownManager { const isCode = callOrReturn(getExtensionField(extension, 'code')) const name = extension.name + const extensionContext = { + name, + options: extension.options, + storage: extension.storage, + } if (isCode) { this.codeTypes.add(name) @@ -144,20 +149,21 @@ export class MarkdownManager { (getExtensionField( extension, 'markdownTokenName', + extensionContext, ) as ExtendableConfig['markdownTokenName']) || name - const parseMarkdown = getExtensionField(extension, 'parseMarkdown') as + const parseMarkdown = getExtensionField(extension, 'parseMarkdown', extensionContext) as | ExtendableConfig['parseMarkdown'] | undefined - const renderMarkdown = getExtensionField(extension, 'renderMarkdown') as + const renderMarkdown = getExtensionField(extension, 'renderMarkdown', extensionContext) as | ExtendableConfig['renderMarkdown'] | undefined - const tokenizer = getExtensionField(extension, 'markdownTokenizer') as + const tokenizer = getExtensionField(extension, 'markdownTokenizer', extensionContext) as | ExtendableConfig['markdownTokenizer'] | undefined // Read the `markdown` object from the extension config. This allows // extensions to provide `markdown: { name?, parseName?, renderName?, parse?, render?, match? }`. - const markdownCfg = (getExtensionField(extension, 'markdownOptions') ?? + const markdownCfg = (getExtensionField(extension, 'markdownOptions', extensionContext) ?? null) as ExtendableConfig['markdownOptions'] const isIndenting = markdownCfg?.indentsContent ?? false const htmlReopen = markdownCfg?.htmlReopen From 43142513dca1899137fcd76db4b99ed3d95b1060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Mon, 21 Sep 2026 12:08:05 +0200 Subject: [PATCH 5/5] test: assert isolating list delete results --- packages/core/src/commands/canJoinItemsAt.ts | 1 - .../listKeymapIsolatingDelete.test.ts | 34 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/core/src/commands/canJoinItemsAt.ts b/packages/core/src/commands/canJoinItemsAt.ts index 1ebe639148..1b6c5c74e2 100644 --- a/packages/core/src/commands/canJoinItemsAt.ts +++ b/packages/core/src/commands/canJoinItemsAt.ts @@ -1,6 +1,5 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' -/** False when the siblings around `pos` are isolating and must not be joined. */ export const canJoinItemsAt = (doc: ProseMirrorNode, pos: number) => { const $pos = doc.resolve(pos) const before = $pos.nodeBefore diff --git a/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts b/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts index c4ba8b2bff..30bc1a469d 100644 --- a/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts +++ b/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts @@ -83,7 +83,7 @@ describe('ListKeymap Delete across isolating frames (#8321)', () => { editor?.destroy() }) - it('joins the following paragraph in the same frame and leaves the next frame alone', () => { + it('moves the following paragraph into the list, then joins it without changing the next frame', () => { editor = new Editor({ extensions: [Page, Paragraph, Text, Frame, OrderedList, ListItem, ListKeymap], content: isolatingFramesContent, @@ -93,16 +93,30 @@ describe('ListKeymap Delete across isolating frames (#8321)', () => { expect(dispatchDelete(editor)).toBe(true) - const frames = editor.getJSON().content - const first = JSON.stringify(frames?.[0]) - const second = JSON.stringify(frames?.[1]) + const firstFrameAfterDelete = editor.state.doc.child(0) + const listAfterDelete = firstFrameAfterDelete.child(0) - expect(frames).toHaveLength(2) - expect(first).toContain('hello') - expect(first).toContain('world') - expect(first).not.toContain('whoops') - expect(second).toContain('whoops') - expect(second).not.toContain('world') + expect(firstFrameAfterDelete.childCount).toBe(1) + expect(listAfterDelete.type.name).toBe('orderedList') + expect(listAfterDelete.childCount).toBe(2) + expect(listAfterDelete.child(0).textContent).toBe('hello') + expect(listAfterDelete.child(1).textContent).toBe('world') + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(1).textContent).toBe('whoops') + + expect(dispatchDelete(editor)).toBe(true) + + const doc = editor.state.doc + const firstFrame = doc.child(0) + const orderedList = firstFrame.child(0) + + expect(doc.childCount).toBe(2) + expect(firstFrame.childCount).toBe(1) + expect(orderedList.type.name).toBe('orderedList') + expect(orderedList.childCount).toBe(1) + expect(orderedList.child(0).type.name).toBe('listItem') + expect(orderedList.child(0).textContent).toBe('helloworld') + expect(doc.child(1).textContent).toBe('whoops') }) it('still joins two list items inside the same isolating frame', () => {