Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/2026-07-28-fix-table-column-resize-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': patch
---

Preserve existing column widths when resizing a newly inserted table for the first time.
162 changes: 162 additions & 0 deletions packages/extension-table/__tests__/columnResizeSnapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { UndoRedo } from '@tiptap/extensions'
import { closeHistory } from '@tiptap/pm/history'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { columnResizingPluginKey } from '@tiptap/pm/tables'
import { afterEach, describe, expect, it } from 'vitest'

describe('column resizing width snapshot', () => {
const editorElClass = 'tiptap-column-resize-snapshot'
let editor: Editor | null = null

const getEditorEl = () => document.querySelector(`.${editorElClass}`)

const createResizableEditor = () => {
const element = document.createElement('div')
element.classList.add(editorElClass)
document.body.appendChild(element)

return new Editor({
element,
extensions: [
Document,
Text,
Paragraph,
UndoRedo,
TableKit.configure({ table: { resizable: true } }),
],
})
}

const getTable = (): ProseMirrorNode | null => {
let table: ProseMirrorNode | null = null

editor?.state.doc.descendants(node => {
if (node.type.spec.tableRole === 'table') {
table = node
return false
}

return true
})

return table
}

const getCellPositions = () => {
const positions: number[] = []

editor?.state.doc.descendants((node, pos) => {
if (node.type.spec.tableRole === 'cell' || node.type.spec.tableRole === 'header_cell') {
positions.push(pos)
}
})

return positions
}

const getFirstRowWidths = () => {
const row = getTable()?.firstChild

return Array.from(
{ length: row?.childCount ?? 0 },
(_, index) => row?.child(index).attrs.colwidth,
)
}

const mockColWidths = (widths: number[]) => {
const columns = editor!.view.dom.querySelectorAll<HTMLTableColElement>('colgroup > col')

columns.forEach((column, index) => {
column.getBoundingClientRect = () => new DOMRect(0, 0, widths[index], 0)
})
}

const beginColumnResize = (cellPos: number, clientX = 0) => {
editor!.view.dispatch(editor!.state.tr.setMeta(columnResizingPluginKey, { setHandle: cellPos }))

const win = editor!.view.dom.ownerDocument.defaultView!

editor!.view.dom.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0, clientX }),
)

return win
}

const finishColumnResize = (win: Window, clientX: number) => {
win.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX }))
}

afterEach(() => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
})

it('preserves untouched widths when a non-leading column is resized first', () => {
editor = createResizableEditor()
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: true })
editor.view.dispatch(closeHistory(editor.state.tr))

const renderedWidths = [100, 120, 140]
mockColWidths(renderedWidths)

expect(getFirstRowWidths()).toEqual([null, null, null])

const win = beginColumnResize(getCellPositions()[1])

expect(getFirstRowWidths()).toEqual([[100], [120], [140]])

finishColumnResize(win, 50)

expect(getFirstRowWidths()).toEqual([[100], [170], [140]])

editor.commands.undo()

expect(getFirstRowWidths()).toEqual([[100], [120], [140]])
})

it('does not overwrite existing colwidths on mousedown', () => {
editor = createResizableEditor()
editor.commands.setContent(
'<table><tbody><tr><td colwidth="100">A</td><td colwidth="200">B</td><td colwidth="300">C</td></tr></tbody></table>',
)

mockColWidths([111, 222, 333])

const win = beginColumnResize(getCellPositions()[1])

expect(getFirstRowWidths()).toEqual([[100], [200], [300]])

finishColumnResize(win, 50)

expect(getFirstRowWidths()).toEqual([[100], [250], [300]])
})

it('snapshots multi-value colwidth for colspan cells', () => {
editor = createResizableEditor()
editor.commands.setContent(
'<table><tbody><tr><th>Name</th><th colspan="3">Description</th></tr><tr><td>A</td><td>B</td><td>C</td><td>D</td></tr></tbody></table>',
)

mockColWidths([100, 120, 140, 160])

const bodyCells = getCellPositions().filter(pos => {
const node = editor!.state.doc.nodeAt(pos)
return node?.type.spec.tableRole === 'cell'
})

const win = beginColumnResize(bodyCells[1])

expect(getFirstRowWidths()).toEqual([[100], [120, 140, 160]])

finishColumnResize(win, 50)

expect(getFirstRowWidths()).toEqual([[100], [170, 140, 160]])
})
})
98 changes: 98 additions & 0 deletions packages/extension-table/src/table/columnResizingSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Plugin } from '@tiptap/pm/state'
import { columnResizingPluginKey, TableMap } from '@tiptap/pm/tables'
import type { EditorView } from '@tiptap/pm/view'

function findTableElement(view: EditorView, tableStart: number) {
let dom: Node | null = view.domAtPos(tableStart).node

while (dom && dom.nodeName !== 'TABLE') {
dom = dom.parentNode
}

return dom instanceof HTMLTableElement ? dom : null
}

function readRenderedColumnWidths(view: EditorView, tableStart: number, columnCount: number) {
const tableElement = findTableElement(view, tableStart)

if (!tableElement) {
return null
}

const columns = tableElement.querySelectorAll<HTMLTableColElement>(':scope > colgroup > col')

if (columns.length !== columnCount) {
return null
}

const widths = Array.from(columns, col => Math.round(col.getBoundingClientRect().width))

if (widths.some(width => width <= 0)) {
return null
}

return widths
}

function snapshotColumnWidths(view: EditorView, cellPos: number) {
const $cell = view.state.doc.resolve(cellPos)
const table = $cell.node(-1)
const tableStart = $cell.start(-1)
const map = TableMap.get(table)
const cellPositions = [...new Set(map.map)]

const needsSnapshot = cellPositions.some(pos => {
const cell = table.nodeAt(pos)
const colwidth = cell?.attrs.colwidth as number[] | null

return !colwidth || colwidth.length !== cell?.attrs.colspan || colwidth.some(width => !width)
})

if (!needsSnapshot) {
return
}

const widths = readRenderedColumnWidths(view, tableStart, map.width)

if (!widths) {
return
}

const tr = view.state.tr

cellPositions.forEach(pos => {
const cell = table.nodeAt(pos)

if (!cell) {
return
}

const column = map.colCount(pos)
const colwidth = widths.slice(column, column + cell.attrs.colspan)

tr.setNodeMarkup(tableStart + pos, null, { ...cell.attrs, colwidth })
})

if (tr.docChanged) {
tr.setMeta('addToHistory', false)
view.dispatch(tr)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function columnResizingSnapshot() {
return new Plugin({
props: {
handleDOMEvents: {
mousedown(view) {
const resizeState = columnResizingPluginKey.getState(view.state)

if (resizeState && resizeState.activeHandle > -1 && !resizeState.dragging) {
snapshotColumnWidths(view, resizeState.activeHandle)
}

return false
},
},
},
})
}
2 changes: 2 additions & 0 deletions packages/extension-table/src/table/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { EditorView, NodeView } from '@tiptap/pm/view'

import { type TableCellAlign, normalizeTableCellAlign } from '../utils/parseAlign.js'
import { TableView } from './TableView.js'
import { columnResizingSnapshot } from './columnResizingSnapshot.js'
import { createColGroup } from './utilities/createColGroup.js'
import { createTable } from './utilities/createTable.js'
import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js'
Expand Down Expand Up @@ -567,6 +568,7 @@ export const Table = Node.create<TableOptions>({
return [
...(isResizable
? [
columnResizingSnapshot(),
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
Expand Down