Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/few-owls-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': patch
---

Fix colgroup not updating when adding or deleting columns in non-resizable tables (#7015)
7 changes: 7 additions & 0 deletions .changeset/fix-bullet-list-numeric-punctuation.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
46 changes: 46 additions & 0 deletions packages/extension-list/__tests__/listItemMarkdown.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
},
],
},
],
})
})
})
39 changes: 39 additions & 0 deletions packages/extension-list/src/item/list-item.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core'
import { mergeAttributes, Node, renderNestedMarkdownContent } from '@tiptap/core'

export interface ListItemOptions {
Expand All @@ -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
Expand Down Expand Up @@ -65,6 +92,18 @@ export const ListItem = Node.create<ListItemOptions>({
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')

Expand Down
186 changes: 186 additions & 0 deletions packages/extension-table/__tests__/colgroupUpdate.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <colgroup> 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 <colgroup> 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 <colgroup> 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 <colgroup> 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 <colgroup> 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 <table> 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 <colgroup> 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 <colgroup> 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(
'<table><tbody><tr><td colwidth="100">A</td><td colwidth="200">B</td><td colwidth="300">C</td></tr></tbody></table>',
)

const widthsOf = () =>
Array.from(editor!.view.dom.querySelectorAll<HTMLTableColElement>('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'])
})
})
})
14 changes: 14 additions & 0 deletions packages/extension-table/src/table/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,20 @@ export const Table = Node.create<TableOptions>({
]
},

addNodeView() {
// When resizable, the columnResizing plugin registers its own NodeView.
// We only register one here for the non-resizable case so that
// <colgroup> 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,
Expand Down
Loading
Loading