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/.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/.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/.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/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/core/src/commands/canJoinItemsAt.ts b/packages/core/src/commands/canJoinItemsAt.ts new file mode 100644 index 0000000000..1b6c5c74e2 --- /dev/null +++ b/packages/core/src/commands/canJoinItemsAt.ts @@ -0,0 +1,13 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +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/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: { 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..30bc1a469d --- /dev/null +++ b/packages/extension-list/tests/integration/listKeymapIsolatingDelete.test.ts @@ -0,0 +1,164 @@ +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('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, + }) + + editor.commands.setTextSelection(findTextEnd(editor, 'hello')) + + expect(dispatchDelete(editor)).toBe(true) + + const firstFrameAfterDelete = editor.state.doc.child(0) + const listAfterDelete = firstFrameAfterDelete.child(0) + + 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', () => { + 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') + }) +}) 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 + }, + }, + }, + }) +} 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