From 6c9c204ede92810d7a40e593cec456344acaf479 Mon Sep 17 00:00:00 2001 From: ump45nose Date: Wed, 23 Sep 2026 18:47:41 +0800 Subject: [PATCH 1/4] fix(extension-table): omit default colspan and rowspan when rendering cells --- .changeset/omit-default-span-attributes.md | 5 +++ .../src/cell/table-cell.test.ts | 37 +++++++++++++++++++ .../extension-table/src/cell/table-cell.ts | 14 ++++++- .../src/header/table-header.test.ts | 37 +++++++++++++++++++ .../src/header/table-header.ts | 14 ++++++- .../src/utils/renderSpanAttribute.ts | 20 ++++++++++ .../tests/integration/tableCommands.test.ts | 4 +- 7 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 .changeset/omit-default-span-attributes.md create mode 100644 packages/extension-table/src/utils/renderSpanAttribute.ts diff --git a/.changeset/omit-default-span-attributes.md b/.changeset/omit-default-span-attributes.md new file mode 100644 index 0000000000..c1099a83bd --- /dev/null +++ b/.changeset/omit-default-span-attributes.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-table': patch +--- + +Table cells and headers no longer render the default `colspan="1"` and `rowspan="1"` attributes. Cells that actually span still render them, matching how prosemirror-tables serializes spans. diff --git a/packages/extension-table/src/cell/table-cell.test.ts b/packages/extension-table/src/cell/table-cell.test.ts index b2f9a04c07..33236dc2a0 100644 --- a/packages/extension-table/src/cell/table-cell.test.ts +++ b/packages/extension-table/src/cell/table-cell.test.ts @@ -131,6 +131,43 @@ describe('extension table cell', () => { getEditorEl()?.remove() }) + it('should not serialize the default colspan and rowspan', () => { + const content = '
Cell
' + + editor = new Editor({ + element: createEditorEl(), + extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table], + content, + }) + + const html = editor.getHTML() + + expect(html).toContain('

Cell

') + expect(html).not.toContain('colspan="1"') + expect(html).not.toContain('rowspan="1"') + + editor?.destroy() + getEditorEl()?.remove() + }) + + it('should keep colspan and rowspan when a cell actually spans', () => { + const content = '
Cell
' + + editor = new Editor({ + element: createEditorEl(), + extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table], + content, + }) + + const html = editor.getHTML() + + expect(html).toContain('colspan="2"') + expect(html).toContain('rowspan="3"') + + editor?.destroy() + getEditorEl()?.remove() + }) + it('should prefer the colwidth attribute over the colgroup col width', () => { const content = '
helloworld
' diff --git a/packages/extension-table/src/cell/table-cell.ts b/packages/extension-table/src/cell/table-cell.ts index 2ead19ff19..4ecca0b7eb 100644 --- a/packages/extension-table/src/cell/table-cell.ts +++ b/packages/extension-table/src/cell/table-cell.ts @@ -5,6 +5,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' +import { renderSpanAttribute } from '../utils/renderSpanAttribute.js' export interface TableCellOptions { /** @@ -63,6 +64,17 @@ export const TableCell = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + const { colspan, rowspan, ...attributes } = HTMLAttributes + + return [ + 'td', + mergeAttributes( + this.options.HTMLAttributes, + attributes, + renderSpanAttribute('colspan', colspan), + renderSpanAttribute('rowspan', rowspan), + ), + 0, + ] }, }) diff --git a/packages/extension-table/src/header/table-header.test.ts b/packages/extension-table/src/header/table-header.test.ts index e9544b8429..fbce6f4889 100644 --- a/packages/extension-table/src/header/table-header.test.ts +++ b/packages/extension-table/src/header/table-header.test.ts @@ -130,4 +130,41 @@ describe('extension table header', () => { editor?.destroy() getEditorEl()?.remove() }) + + it('should not serialize the default colspan and rowspan', () => { + const content = '
Cell
' + + editor = new Editor({ + element: createEditorEl(), + extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table], + content, + }) + + const html = editor.getHTML() + + expect(html).toContain('

Cell

') + expect(html).not.toContain('colspan="1"') + expect(html).not.toContain('rowspan="1"') + + editor?.destroy() + getEditorEl()?.remove() + }) + + it('should keep colspan and rowspan when a header actually spans', () => { + const content = '
Cell
' + + editor = new Editor({ + element: createEditorEl(), + extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table], + content, + }) + + const html = editor.getHTML() + + expect(html).toContain('colspan="2"') + expect(html).toContain('rowspan="3"') + + editor?.destroy() + getEditorEl()?.remove() + }) }) diff --git a/packages/extension-table/src/header/table-header.ts b/packages/extension-table/src/header/table-header.ts index fb5ce90603..d55563203d 100644 --- a/packages/extension-table/src/header/table-header.ts +++ b/packages/extension-table/src/header/table-header.ts @@ -5,6 +5,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' +import { renderSpanAttribute } from '../utils/renderSpanAttribute.js' export interface TableHeaderOptions { /** @@ -63,6 +64,17 @@ export const TableHeader = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + const { colspan, rowspan, ...attributes } = HTMLAttributes + + return [ + 'th', + mergeAttributes( + this.options.HTMLAttributes, + attributes, + renderSpanAttribute('colspan', colspan), + renderSpanAttribute('rowspan', rowspan), + ), + 0, + ] }, }) diff --git a/packages/extension-table/src/utils/renderSpanAttribute.ts b/packages/extension-table/src/utils/renderSpanAttribute.ts new file mode 100644 index 0000000000..8554a00934 --- /dev/null +++ b/packages/extension-table/src/utils/renderSpanAttribute.ts @@ -0,0 +1,20 @@ +/** + * Renders the `colspan` or `rowspan` attribute of a table cell or header. + * + * Both attributes default to `1`, so rendering them unconditionally adds + * markup every cell carries and makes a parse/serialize round-trip change the + * document. The attribute is omitted when it holds the default value or is + * missing, matching how prosemirror-tables serializes spans. + * + * @param name - The attribute name, `colspan` or `rowspan` + * @param value - The attribute value + * @returns - An object holding the attribute, or an empty object for the default + */ +export function renderSpanAttribute( + name: 'colspan' | 'rowspan', + value: unknown, +): Record { + return value === 1 || value === '1' || value === null || value === undefined + ? {} + : { [name]: value } +} diff --git a/packages/extension-table/tests/integration/tableCommands.test.ts b/packages/extension-table/tests/integration/tableCommands.test.ts index 4748849a55..0cb473d434 100644 --- a/packages/extension-table/tests/integration/tableCommands.test.ts +++ b/packages/extension-table/tests/integration/tableCommands.test.ts @@ -268,7 +268,7 @@ describe('Table commands', () => { editor.commands.clearContent() editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false }) expect(editor.getHTML()).toBe( - '

', + '

', ) }) @@ -276,7 +276,7 @@ describe('Table commands', () => { editor.commands.clearContent() editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: true }) expect(editor.getHTML()).toBe( - '

', + '

', ) }) From 8bd5024bd94900c5eaa2ff1fd95369916595c309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Wed, 23 Sep 2026 13:53:32 +0200 Subject: [PATCH 2/4] fix(extension-table): preserve model spans over configured attributes --- .../src/cell/table-cell.test.ts | 23 +++++++++++++++++++ .../extension-table/src/cell/table-cell.ts | 18 +++++---------- .../src/header/table-header.test.ts | 23 +++++++++++++++++++ .../src/header/table-header.ts | 18 +++++---------- .../src/utils/omitDefaultSpanAttribute.ts | 15 ++++++++++++ .../src/utils/renderSpanAttribute.ts | 20 ---------------- 6 files changed, 73 insertions(+), 44 deletions(-) create mode 100644 packages/extension-table/src/utils/omitDefaultSpanAttribute.ts delete mode 100644 packages/extension-table/src/utils/renderSpanAttribute.ts diff --git a/packages/extension-table/src/cell/table-cell.test.ts b/packages/extension-table/src/cell/table-cell.test.ts index 33236dc2a0..26430f0d3e 100644 --- a/packages/extension-table/src/cell/table-cell.test.ts +++ b/packages/extension-table/src/cell/table-cell.test.ts @@ -150,6 +150,29 @@ describe('extension table cell', () => { getEditorEl()?.remove() }) + it('should not render configured spans for a cell with default spans', () => { + editor = new Editor({ + element: createEditorEl(), + extensions: [ + Document, + Text, + Paragraph, + TableCell.configure({ HTMLAttributes: { colspan: 2, rowspan: 2 } }), + TableHeader, + TableRow, + Table, + ], + content: '
Cell
', + }) + + expect(editor.getHTML()).toContain('

Cell

') + expect(editor.view.dom.querySelector('td')?.hasAttribute('colspan')).toBe(false) + expect(editor.view.dom.querySelector('td')?.hasAttribute('rowspan')).toBe(false) + + editor.destroy() + getEditorEl()?.remove() + }) + it('should keep colspan and rowspan when a cell actually spans', () => { const content = '
Cell
' diff --git a/packages/extension-table/src/cell/table-cell.ts b/packages/extension-table/src/cell/table-cell.ts index 4ecca0b7eb..97755d1804 100644 --- a/packages/extension-table/src/cell/table-cell.ts +++ b/packages/extension-table/src/cell/table-cell.ts @@ -5,7 +5,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' -import { renderSpanAttribute } from '../utils/renderSpanAttribute.js' +import { omitDefaultSpanAttribute } from '../utils/omitDefaultSpanAttribute.js' export interface TableCellOptions { /** @@ -64,17 +64,11 @@ export const TableCell = Node.create({ }, renderHTML({ HTMLAttributes }) { - const { colspan, rowspan, ...attributes } = HTMLAttributes + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) - return [ - 'td', - mergeAttributes( - this.options.HTMLAttributes, - attributes, - renderSpanAttribute('colspan', colspan), - renderSpanAttribute('rowspan', rowspan), - ), - 0, - ] + omitDefaultSpanAttribute(attributes, HTMLAttributes, 'colspan') + omitDefaultSpanAttribute(attributes, HTMLAttributes, 'rowspan') + + return ['td', attributes, 0] }, }) diff --git a/packages/extension-table/src/header/table-header.test.ts b/packages/extension-table/src/header/table-header.test.ts index fbce6f4889..aaa10f08c9 100644 --- a/packages/extension-table/src/header/table-header.test.ts +++ b/packages/extension-table/src/header/table-header.test.ts @@ -150,6 +150,29 @@ describe('extension table header', () => { getEditorEl()?.remove() }) + it('should not render configured spans for a header with default spans', () => { + editor = new Editor({ + element: createEditorEl(), + extensions: [ + Document, + Text, + Paragraph, + TableCell, + TableHeader.configure({ HTMLAttributes: { colspan: 2, rowspan: 2 } }), + TableRow, + Table, + ], + content: '
Cell
', + }) + + expect(editor.getHTML()).toContain('

Cell

') + expect(editor.view.dom.querySelector('th')?.hasAttribute('colspan')).toBe(false) + expect(editor.view.dom.querySelector('th')?.hasAttribute('rowspan')).toBe(false) + + editor.destroy() + getEditorEl()?.remove() + }) + it('should keep colspan and rowspan when a header actually spans', () => { const content = '
Cell
' diff --git a/packages/extension-table/src/header/table-header.ts b/packages/extension-table/src/header/table-header.ts index d55563203d..9cce9b08d8 100644 --- a/packages/extension-table/src/header/table-header.ts +++ b/packages/extension-table/src/header/table-header.ts @@ -5,7 +5,7 @@ import { mergeAttributes, Node } from '@tiptap/core' import { createAlignAttribute } from '../utils/parseAlign.js' import { parseColwidth } from '../utils/parseColwidth.js' import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js' -import { renderSpanAttribute } from '../utils/renderSpanAttribute.js' +import { omitDefaultSpanAttribute } from '../utils/omitDefaultSpanAttribute.js' export interface TableHeaderOptions { /** @@ -64,17 +64,11 @@ export const TableHeader = Node.create({ }, renderHTML({ HTMLAttributes }) { - const { colspan, rowspan, ...attributes } = HTMLAttributes + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) - return [ - 'th', - mergeAttributes( - this.options.HTMLAttributes, - attributes, - renderSpanAttribute('colspan', colspan), - renderSpanAttribute('rowspan', rowspan), - ), - 0, - ] + omitDefaultSpanAttribute(attributes, HTMLAttributes, 'colspan') + omitDefaultSpanAttribute(attributes, HTMLAttributes, 'rowspan') + + return ['th', attributes, 0] }, }) diff --git a/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts b/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts new file mode 100644 index 0000000000..f5b8296c59 --- /dev/null +++ b/packages/extension-table/src/utils/omitDefaultSpanAttribute.ts @@ -0,0 +1,15 @@ +export function omitDefaultSpanAttribute( + attributes: Record, + renderedAttributes: Record, + name: 'colspan' | 'rowspan', +): void { + if (!(name in renderedAttributes)) { + return + } + + const value = renderedAttributes[name] + + if (value === 1 || value === '1' || value === null || value === undefined) { + delete attributes[name] + } +} diff --git a/packages/extension-table/src/utils/renderSpanAttribute.ts b/packages/extension-table/src/utils/renderSpanAttribute.ts deleted file mode 100644 index 8554a00934..0000000000 --- a/packages/extension-table/src/utils/renderSpanAttribute.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Renders the `colspan` or `rowspan` attribute of a table cell or header. - * - * Both attributes default to `1`, so rendering them unconditionally adds - * markup every cell carries and makes a parse/serialize round-trip change the - * document. The attribute is omitted when it holds the default value or is - * missing, matching how prosemirror-tables serializes spans. - * - * @param name - The attribute name, `colspan` or `rowspan` - * @param value - The attribute value - * @returns - An object holding the attribute, or an empty object for the default - */ -export function renderSpanAttribute( - name: 'colspan' | 'rowspan', - value: unknown, -): Record { - return value === 1 || value === '1' || value === null || value === undefined - ? {} - : { [name]: value } -} From 8b37c2ce2652e785098c2535126d1e63505a3dae Mon Sep 17 00:00:00 2001 From: Henrique Date: Wed, 23 Sep 2026 17:33:16 +0100 Subject: [PATCH 3/4] fix(extension-list): keep courtesy titles out of ordered lists Ms. and Dr. match the list marker pattern and were parsed as lists, which dropped the title. --- ...2026-09-23-ordered-list-courtesy-titles.md | 5 ++++ .../collectOrderedListItemsFromSource.ts | 4 ++-- .../extension-list/src/ordered-list/utils.ts | 24 +++++++++++++++---- .../tests/integration/orderedListType.test.ts | 14 +++++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 .changeset/2026-09-23-ordered-list-courtesy-titles.md diff --git a/.changeset/2026-09-23-ordered-list-courtesy-titles.md b/.changeset/2026-09-23-ordered-list-courtesy-titles.md new file mode 100644 index 0000000000..31efc6c5ad --- /dev/null +++ b/.changeset/2026-09-23-ordered-list-courtesy-titles.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-list': patch +--- + +Courtesy titles such as "Ms." and "Dr." are no longer parsed as ordered-list markers. Numbered, lettered, and roman lists are unchanged. diff --git a/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts b/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts index 976085ebfb..962928b80c 100644 --- a/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts +++ b/packages/extension-list/src/ordered-list/collectOrderedListItemsFromSource.ts @@ -1,5 +1,5 @@ import type { OrderedListItem } from './utils.js' -import { collectOrderedListItems, ORDERED_LIST_ITEM_REGEX } from './utils.js' +import { collectOrderedListItems, matchOrderedListItemLine } from './utils.js' const ORDERED_LIST_SCAN_WINDOW = 64 @@ -39,7 +39,7 @@ export function collectOrderedListItemsFromSource( const firstLine = firstLineEnd === -1 ? source : source.slice(0, firstLineEnd) // Every block start is offered here, so reject non-lists before reading further. - if (!ORDERED_LIST_ITEM_REGEX.test(firstLine)) { + if (!matchOrderedListItemLine(firstLine)) { return [[], 0, []] } diff --git a/packages/extension-list/src/ordered-list/utils.ts b/packages/extension-list/src/ordered-list/utils.ts index d0a3b84aa4..244c026124 100644 --- a/packages/extension-list/src/ordered-list/utils.ts +++ b/packages/extension-list/src/ordered-list/utils.ts @@ -26,6 +26,23 @@ export const ORDERED_LIST_ITEM_REGEX = new RegExp( `^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.*)$`, ) +// Mixed-case titles match the pattern but are not list markers. +export function matchOrderedListItemLine(line: string): RegExpMatchArray | undefined { + const match = line.match(ORDERED_LIST_ITEM_REGEX) + + if (!match) { + return undefined + } + + const [, , marker] = match + + if (/^\d+$/.test(marker) || detectMarkerType(marker)) { + return match + } + + return undefined +} + /** * Matches any line that starts with whitespace (indented content). * Used to identify continuation content that belongs to a list item. @@ -57,7 +74,7 @@ export interface OrderedListItem { } function isOrderedListMarkerLine(line: string): boolean { - return ORDERED_LIST_ITEM_REGEX.test(line.trimStart()) + return matchOrderedListItemLine(line.trimStart()) !== undefined } function isBlockContentLine(line: string): boolean { @@ -131,7 +148,7 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu while (currentLineIndex < lines.length) { const line = lines[currentLineIndex] - const match = line.match(ORDERED_LIST_ITEM_REGEX) + const match = matchOrderedListItemLine(line) if (!match) { break @@ -152,10 +169,9 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu // Collect continuation lines for this item (but NOT nested list items) while (nextLineIndex < lines.length) { const nextLine = lines[nextLineIndex] - const nextMatch = nextLine.match(ORDERED_LIST_ITEM_REGEX) // If it's another list item (nested or not), stop collecting - if (nextMatch) { + if (matchOrderedListItemLine(nextLine)) { break } diff --git a/packages/extension-list/tests/integration/orderedListType.test.ts b/packages/extension-list/tests/integration/orderedListType.test.ts index ba1439d093..a04cbdb8e2 100644 --- a/packages/extension-list/tests/integration/orderedListType.test.ts +++ b/packages/extension-list/tests/integration/orderedListType.test.ts @@ -263,6 +263,20 @@ describe('OrderedList type attribute', () => { expect(json.content[0].type).not.toBe('orderedList') }) + it.each(['Ms. Smith arrived today.', 'Mr. Jones arrived today.', 'Dr. House said hello.'])( + 'does not parse a courtesy title as an ordered list (%s)', + input => { + const markdownManager = new MarkdownManager({ + extensions: [Document, Paragraph, Text, ListItem, OrderedList], + }) + + const json = markdownManager.parse(input) + + expect(json.content[0].type).toBe('paragraph') + expect(json.content[0].content?.[0]).toMatchObject({ type: 'text', text: input }) + }, + ) + it('round-trips multi-letter alpha markers beyond 26 items', () => { const markdownManager = new MarkdownManager({ extensions: [Document, Paragraph, Text, ListItem, OrderedList], From 04fa1d86c44b7e180f768b15f3e08583fe30a418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Thu, 24 Sep 2026 13:26:02 +0200 Subject: [PATCH 4/4] test(core): destroy splitBlock editors --- packages/core/__tests__/splitBlock.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/__tests__/splitBlock.spec.ts b/packages/core/__tests__/splitBlock.spec.ts index 34352c87e0..ed526e102c 100644 --- a/packages/core/__tests__/splitBlock.spec.ts +++ b/packages/core/__tests__/splitBlock.spec.ts @@ -2,7 +2,7 @@ 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' +import { afterEach, describe, expect, it } from 'vite-plus/test' const Callout = Node.create({ name: 'callout', @@ -21,12 +21,21 @@ const Callout = Node.create({ }, }) -const createEditor = (content: string) => - new Editor({ +let editor: Editor + +const createEditor = (content: string) => { + editor = new Editor({ extensions: [Document, Paragraph, Text, Callout], content, }) + return editor +} + +afterEach(() => { + editor.destroy() +}) + describe('splitBlock', () => { it('splits a paragraph at the cursor', () => { const editor = createEditor('

onetwo

')