From d2ad165b7d145452a5cf4c7a027f2f80d9e02488 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 30 Apr 2026 08:59:18 +0200 Subject: [PATCH 1/2] fix(extension-table): update colgroup for non-resizable tables --- .changeset/few-owls-lie.md | 5 + .../__tests__/colgroupUpdate.spec.ts | 186 ++++++++++++++++++ packages/extension-table/src/table/table.ts | 14 ++ 3 files changed, 205 insertions(+) create mode 100644 .changeset/few-owls-lie.md create mode 100644 packages/extension-table/__tests__/colgroupUpdate.spec.ts diff --git a/.changeset/few-owls-lie.md b/.changeset/few-owls-lie.md new file mode 100644 index 0000000000..d40f755ce6 --- /dev/null +++ b/.changeset/few-owls-lie.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-table': patch +--- + +Fix colgroup not updating when adding or deleting columns in non-resizable tables (#7015) diff --git a/packages/extension-table/__tests__/colgroupUpdate.spec.ts b/packages/extension-table/__tests__/colgroupUpdate.spec.ts new file mode 100644 index 0000000000..de81e60894 --- /dev/null +++ b/packages/extension-table/__tests__/colgroupUpdate.spec.ts @@ -0,0 +1,186 @@ +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' +import Text from '@tiptap/extension-text' +import { afterEach, describe, expect, it } from 'vitest' + +describe('colgroup updates after column commands', () => { + const editorElClass = 'tiptap' + let editor: Editor | null = null + + const createEditorEl = () => { + const editorEl = document.createElement('div') + + editorEl.classList.add(editorElClass) + document.body.appendChild(editorEl) + return editorEl + } + const getEditorEl = () => document.querySelector(`.${editorElClass}`) + + const createEditor = (resizable: boolean) => + new Editor({ + element: createEditorEl(), + extensions: [Document, Text, Paragraph, TableCell, TableHeader, TableRow, Table.configure({ resizable })], + }) + + const countCols = (e: Editor) => e.view.dom.querySelectorAll('colgroup > col').length + + const countCellsInFirstRow = (e: Editor) => { + const firstRow = e.view.dom.querySelector('tbody > tr') + + return firstRow ? firstRow.querySelectorAll('td, th').length : 0 + } + + // Place cursor inside the first cell of the table so column commands have a target + const focusFirstCell = (e: Editor) => { + e.commands.focus() + e.commands.setTextSelection(3) + } + + afterEach(() => { + editor?.destroy() + editor = null + getEditorEl()?.remove() + }) + + describe('resizable: false (default — bug case)', () => { + it('updates when a column is deleted', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + + expect(countCols(editor)).toBe(3) + expect(countCellsInFirstRow(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.deleteColumn() + + expect(countCellsInFirstRow(editor)).toBe(2) + expect(countCols(editor)).toBe(2) + }) + + it('updates when a column is added after', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + + expect(countCols(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.addColumnAfter() + + expect(countCellsInFirstRow(editor)).toBe(4) + expect(countCols(editor)).toBe(4) + }) + + it('updates when a column is added before', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + + expect(countCols(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.addColumnBefore() + + expect(countCellsInFirstRow(editor)).toBe(4) + expect(countCols(editor)).toBe(4) + }) + }) + + describe('resizable: true (control — should already work)', () => { + it('updates when a column is deleted', () => { + editor = createEditor(true) + editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + + expect(countCols(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.deleteColumn() + + expect(countCellsInFirstRow(editor)).toBe(2) + expect(countCols(editor)).toBe(2) + }) + + it('updates when a column is added after', () => { + editor = createEditor(true) + editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + + expect(countCols(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.addColumnAfter() + + expect(countCellsInFirstRow(editor)).toBe(4) + expect(countCols(editor)).toBe(4) + }) + }) + + describe('edge cases (resizable: false)', () => { + const getTable = (e: Editor) => e.view.dom.querySelector('table') as HTMLTableElement + + it('updates min-width style when columns change', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false }) + + // Default cellMinWidth is 25px → 3 cols × 25 = 75px + expect(getTable(editor).style.minWidth).toBe('75px') + + focusFirstCell(editor) + editor.commands.deleteColumn() + expect(getTable(editor).style.minWidth).toBe('50px') + + editor.commands.addColumnAfter() + expect(getTable(editor).style.minWidth).toBe('75px') + }) + + it('keeps consistent after mergeCells (no col change expected)', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false }) + + const initialCols = countCols(editor) + + // select two adjacent cells then merge + editor.commands.focus() + editor.commands.setTextSelection({ from: 3, to: 8 }) + editor.commands.mergeCells() + + // mergeCells uses colspan; column count must not change + expect(countCols(editor)).toBe(initialCols) + }) + + it('updates in read-only mode when columns change programmatically', () => { + editor = createEditor(false) + editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false }) + + editor.setEditable(false) + + expect(countCols(editor)).toBe(3) + + focusFirstCell(editor) + editor.commands.deleteColumn() + + expect(countCellsInFirstRow(editor)).toBe(2) + expect(countCols(editor)).toBe(2) + }) + + it('preserves remaining colwidths when a column is deleted', () => { + editor = createEditor(false) + editor.commands.setContent( + '
ABC
', + ) + + const widthsOf = () => + Array.from(editor!.view.dom.querySelectorAll('colgroup > col')).map(c => c.style.width) + + expect(widthsOf()).toEqual(['100px', '200px', '300px']) + + // place cursor in the first cell, then delete it + editor.commands.focus() + editor.commands.setTextSelection(3) + editor.commands.deleteColumn() + + // remaining widths must be the original 200 and 300, not 100/200 (which would + // indicate widths got reassigned to the wrong columns after the deletion) + expect(widthsOf()).toEqual(['200px', '300px']) + }) + }) +}) diff --git a/packages/extension-table/src/table/table.ts b/packages/extension-table/src/table/table.ts index 9e45b75803..688c8aa1de 100644 --- a/packages/extension-table/src/table/table.ts +++ b/packages/extension-table/src/table/table.ts @@ -510,6 +510,20 @@ export const Table = Node.create({ ] }, + addNodeView() { + // When resizable, the columnResizing plugin registers its own NodeView. + // We only register one here for the non-resizable case so that + // stays in sync with column changes (issue #7015). + const isResizable = this.options.resizable && this.editor.isEditable + const View = this.options.View + + if (isResizable || !View) { + return null + } + + return ({ node, view }) => new View(node, this.options.cellMinWidth, view) + }, + extendNodeSchema(extension) { const context = { name: extension.name, From 57f8d6609a72a6bd7440441dcb9a05ab4baebb18 Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Mon, 4 May 2026 10:54:00 +0200 Subject: [PATCH 2/2] fix: preserve numeric bullet list item markdown (#7789) * fix: preserve numeric bullet list item markdown * fix(extension-list): enhance markdown parsing with fallback for tokenizeInline --- .../fix-bullet-list-numeric-punctuation.md | 7 ++ packages/core/src/types.ts | 2 + .../__tests__/listItemMarkdown.spec.ts | 46 ++++++++++++ packages/extension-list/src/item/list-item.ts | 39 +++++++++++ .../bullet-list-numeric-punctuation.ts | 70 +++++++++++++++++++ .../__tests__/conversion-files/index.ts | 1 + packages/markdown/src/MarkdownManager.ts | 1 + 7 files changed, 166 insertions(+) create mode 100644 .changeset/fix-bullet-list-numeric-punctuation.md create mode 100644 packages/extension-list/__tests__/listItemMarkdown.spec.ts create mode 100644 packages/markdown/__tests__/conversion-files/bullet-list-numeric-punctuation.ts diff --git a/.changeset/fix-bullet-list-numeric-punctuation.md b/.changeset/fix-bullet-list-numeric-punctuation.md new file mode 100644 index 0000000000..456a88f716 --- /dev/null +++ b/.changeset/fix-bullet-list-numeric-punctuation.md @@ -0,0 +1,7 @@ +--- +"@tiptap/core": patch +"@tiptap/extension-list": patch +"@tiptap/markdown": patch +--- + +Fix markdown parsing for bullet list items whose text looks like an ordered-list marker, such as `- 123.`, so extraction no longer loses the item content. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ad011f838f..f8046174f1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -903,6 +903,8 @@ export type MarkdownHelpers = { export type MarkdownParseHelpers = { /** Parse an array of inline tokens into text nodes with marks */ parseInline: (tokens: MarkdownToken[]) => JSONContent[] + /** Tokenize source text as inline markdown when supported by the markdown parser */ + tokenizeInline?: (src: string) => MarkdownToken[] /** Parse an array of block-level tokens */ parseChildren: (tokens: MarkdownToken[]) => JSONContent[] /** Parse block-level tokens while preserving implicit empty paragraphs from blank lines */ diff --git a/packages/extension-list/__tests__/listItemMarkdown.spec.ts b/packages/extension-list/__tests__/listItemMarkdown.spec.ts new file mode 100644 index 0000000000..43a2412553 --- /dev/null +++ b/packages/extension-list/__tests__/listItemMarkdown.spec.ts @@ -0,0 +1,46 @@ +import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core' +import { describe, expect, it } from 'vitest' + +import { ListItem } from '../src/index.js' + +describe('ListItem markdown parsing', () => { + it('falls back to plain text tokens when tokenizeInline is not available', () => { + const token: MarkdownToken = { + type: 'list_item', + text: '1. Nested item', + tokens: [ + { + type: 'list', + ordered: true, + raw: '1. Nested item', + }, + ], + } + const helpers: MarkdownParseHelpers = { + parseInline: tokens => + tokens.map(inlineToken => ({ + type: 'text', + text: inlineToken.text, + })), + parseChildren: () => [], + createTextNode: text => ({ type: 'text', text }), + createNode: (type, attrs, content) => ({ type, attrs, content }), + applyMark: (mark, content) => ({ mark, content }), + } + + expect(ListItem.config.parseMarkdown?.(token, helpers)).toEqual({ + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '1. Nested item', + }, + ], + }, + ], + }) + }) +}) diff --git a/packages/extension-list/src/item/list-item.ts b/packages/extension-list/src/item/list-item.ts index 6c1ab34295..c9eed2ef93 100644 --- a/packages/extension-list/src/item/list-item.ts +++ b/packages/extension-list/src/item/list-item.ts @@ -1,3 +1,4 @@ +import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core' import { mergeAttributes, Node, renderNestedMarkdownContent } from '@tiptap/core' export interface ListItemOptions { @@ -23,6 +24,32 @@ export interface ListItemOptions { orderedListTypeName: string } +function isSameLineOrderedListToken(token: MarkdownToken): boolean { + const nestedToken = token.tokens?.[0] + + return Boolean( + token.text && + token.tokens?.length === 1 && + nestedToken?.type === 'list' && + nestedToken.ordered && + nestedToken.raw === token.text, + ) +} + +function parseSameLineOrderedListText(text: string, helpers: MarkdownParseHelpers): JSONContent[] { + if (helpers.tokenizeInline) { + return helpers.parseInline(helpers.tokenizeInline(text)) + } + + return helpers.parseInline([ + { + type: 'text', + raw: text, + text, + }, + ]) +} + /** * This extension allows you to create list items. * @see https://www.tiptap.dev/api/nodes/list-item @@ -65,6 +92,18 @@ export const ListItem = Node.create({ let content: any[] = [] if (token.tokens && token.tokens.length > 0) { + if (isSameLineOrderedListToken(token)) { + return { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: parseSameLineOrderedListText(token.text || '', helpers), + }, + ], + } + } + // Check if we have paragraph tokens (complex list items) const hasParagraphTokens = token.tokens.some(t => t.type === 'paragraph') diff --git a/packages/markdown/__tests__/conversion-files/bullet-list-numeric-punctuation.ts b/packages/markdown/__tests__/conversion-files/bullet-list-numeric-punctuation.ts new file mode 100644 index 0000000000..d5d0d566a5 --- /dev/null +++ b/packages/markdown/__tests__/conversion-files/bullet-list-numeric-punctuation.ts @@ -0,0 +1,70 @@ +export const name = 'Bullet List with Numeric Punctuation' + +export const expectedInput = ` +- 123 + - 123 + - 123 +- 123 +- 123. +`.trim() + +export const expectedOutput = { + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '123' }], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '123' }], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '123' }], + }, + ], + }, + ], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '123' }], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '123.' }], + }, + ], + }, + ], + }, + ], +} diff --git a/packages/markdown/__tests__/conversion-files/index.ts b/packages/markdown/__tests__/conversion-files/index.ts index 33ca0d5ab9..3826ebe0ab 100644 --- a/packages/markdown/__tests__/conversion-files/index.ts +++ b/packages/markdown/__tests__/conversion-files/index.ts @@ -1,4 +1,5 @@ export * as bulletList from './bullet-list.js' +export * as bulletListNumericPunctuation from './bullet-list-numeric-punctuation.js' export * as customAtom from './custom-atom.js' export * as customBlock from './custom-block.js' export * as customInline from './custom-inline.js' diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 4e9d7b7135..331ce6875b 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -601,6 +601,7 @@ export class MarkdownManager { private createParseHelpers(): MarkdownParseHelpers { return { parseInline: (tokens: MarkdownToken[]) => this.parseInlineTokens(tokens), + tokenizeInline: (src: string) => this.tokenizeInline(src), parseChildren: (tokens: MarkdownToken[]) => this.parseTokens(tokens), parseBlockChildren: (tokens: MarkdownToken[]) => this.parseTokens(tokens, true), createTextNode: (text: string, marks?: Array<{ type: string; attrs?: any }>) => {