Skip to content

Commit a66b16f

Browse files
committed
fix: robust table row/col/table deletion and visible cell selection
Bypass prosemirror-tables' TableMap-based select-then-delete flow for the table tooltip's row/column/table delete actions. The gfm preset commands (selectRow/Col/TableCommand + deleteSelectedCellsCommand) rely on TableMap consistency and silently no-op on ragged or otherwise malformed tables, which left the tooltip's per-row and per-column delete buttons unable to clean up such tables. The new helpers operate directly on the document range of the row, column cells, or table node — so they work regardless of TableMap reported problems. Edge cases handled: - Deleting the only row of a table deletes the table. - A column delete that would empty a row drops the row instead. - A column delete that would empty all rows deletes the table. Also style prosemirror-tables' .selectedCell decoration so that multi-cell selections (drag across cells) actually look selected; the viewer was previously only showing the cursor cell's native text selection, making range selections look like single-cell selections. https://claude.ai/code/session_01YQpRGeviHUSZJ84cQJukFt
1 parent 408cf8d commit a66b16f

2 files changed

Lines changed: 91 additions & 15 deletions

File tree

frontend/src/components/Editor/tableTooltip.js

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ import {
77
addRowAfterCommand,
88
addColBeforeCommand,
99
addColAfterCommand,
10-
selectRowCommand,
11-
selectColCommand,
12-
selectTableCommand,
13-
deleteSelectedCellsCommand,
1410
} from '@milkdown/preset-gfm'
1511

1612
const CELL_TYPES = ['table_cell', 'table_header']
@@ -35,12 +31,81 @@ function locateTable(state) {
3531
const colIndex = findNodeIndex(row.node, cell.node)
3632
if (rowIndex < 0 || colIndex < 0) return null
3733
return {
38-
tablePos: table.from,
34+
table,
35+
row,
3936
rowIndex,
4037
colIndex,
4138
}
4239
}
4340

41+
// Direct node-range deletion. Bypasses prosemirror-tables `TableMap` so it
42+
// keeps working on ragged/malformed tables where the gfm select-then-delete
43+
// path silently no-ops.
44+
function deleteTableNode(view, table) {
45+
view.dispatch(view.state.tr.delete(table.from, table.to).scrollIntoView())
46+
}
47+
48+
function countRows(tableNode) {
49+
let n = 0
50+
tableNode.forEach((child) => {
51+
if (ROW_TYPES.includes(child.type.name)) n += 1
52+
})
53+
return n
54+
}
55+
56+
function deleteRowNode(view, location) {
57+
const { table, row } = location
58+
if (countRows(table.node) <= 1) {
59+
deleteTableNode(view, table)
60+
return
61+
}
62+
view.dispatch(view.state.tr.delete(row.from, row.to).scrollIntoView())
63+
}
64+
65+
function deleteColumnAtIndex(view, location) {
66+
const { table, colIndex } = location
67+
// Collect cell ranges to delete and rows that must go entirely (because the
68+
// column being removed is their only cell). Walk in document order, then
69+
// apply in reverse so earlier deletes don't shift later positions.
70+
const ranges = []
71+
let survivingRows = 0
72+
let cursor = table.from + 1 // position right after table opening token
73+
table.node.forEach((rowNode) => {
74+
const rowFrom = cursor
75+
const rowTo = rowFrom + rowNode.nodeSize
76+
cursor = rowTo
77+
if (!ROW_TYPES.includes(rowNode.type.name)) {
78+
survivingRows += 1
79+
return
80+
}
81+
if (rowNode.childCount <= colIndex) {
82+
// Ragged row with no cell at this column — leave it alone.
83+
survivingRows += 1
84+
return
85+
}
86+
if (rowNode.childCount === 1) {
87+
// Removing the only cell would leave an invalid empty row — drop the row.
88+
ranges.push([rowFrom, rowTo])
89+
return
90+
}
91+
let cellOffset = 0
92+
for (let i = 0; i < colIndex; i += 1) cellOffset += rowNode.child(i).nodeSize
93+
const cellFrom = rowFrom + 1 + cellOffset
94+
const cellTo = cellFrom + rowNode.child(colIndex).nodeSize
95+
ranges.push([cellFrom, cellTo])
96+
survivingRows += 1
97+
})
98+
if (!ranges.length) return
99+
if (survivingRows === 0) {
100+
deleteTableNode(view, table)
101+
return
102+
}
103+
ranges.sort((a, b) => b[0] - a[0])
104+
const tr = view.state.tr
105+
ranges.forEach(([from, to]) => tr.delete(from, to))
106+
view.dispatch(tr.scrollIntoView())
107+
}
108+
44109
class TableTooltip {
45110
constructor(ctx, labels) {
46111
this.ctx = ctx
@@ -112,7 +177,7 @@ class TableTooltip {
112177
return
113178
}
114179
this.location = next
115-
this.tableDom = view.nodeDOM(next.tablePos) || null
180+
this.tableDom = view.nodeDOM(next.table.from) || null
116181
if (!this.tableDom || this.tableDom.nodeType !== 1) {
117182
this.hide()
118183
return
@@ -148,11 +213,10 @@ class TableTooltip {
148213
runAction(id) {
149214
if (!this.view) return
150215
const commands = this.ctx.get(commandsCtx)
151-
// Re-resolve row/col index from current state — `this.location` may be stale
152-
// by the time a click handler runs (e.g. after a prior add-row/col op).
216+
// Re-resolve row/col positions from current state — `this.location` may be
217+
// stale by the time a click handler runs (e.g. after a prior add-row/col op).
153218
const fresh = locateTable(this.view.state)
154219
if (!fresh) return
155-
const { rowIndex, colIndex } = fresh
156220

157221
switch (id) {
158222
case 'rowAbove':
@@ -168,16 +232,13 @@ class TableTooltip {
168232
commands.call(addColAfterCommand.key)
169233
break
170234
case 'delRow':
171-
commands.call(selectRowCommand.key, { index: rowIndex })
172-
commands.call(deleteSelectedCellsCommand.key)
235+
deleteRowNode(this.view, fresh)
173236
break
174237
case 'delCol':
175-
commands.call(selectColCommand.key, { index: colIndex })
176-
commands.call(deleteSelectedCellsCommand.key)
238+
deleteColumnAtIndex(this.view, fresh)
177239
break
178240
case 'delTable':
179-
commands.call(selectTableCommand.key)
180-
commands.call(deleteSelectedCellsCommand.key)
241+
deleteTableNode(this.view, fresh.table)
181242
break
182243
default:
183244
break

frontend/src/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,13 +314,28 @@ body {
314314
border: 1px solid var(--color-border);
315315
padding: 0.5em 0.75em;
316316
text-align: left;
317+
position: relative;
317318
}
318319

319320
.milkdown .editor th {
320321
background: var(--color-bg);
321322
font-weight: 600;
322323
}
323324

325+
/* Multi-cell selection (prosemirror-tables marks each cell in a CellSelection
326+
with the .selectedCell class via a node decoration). Without this overlay,
327+
only the cursor's cell shows the browser's native text-selection background,
328+
making range selections look like single-cell selections. */
329+
.milkdown .editor .selectedCell::after {
330+
content: '';
331+
position: absolute;
332+
inset: 0;
333+
background: var(--color-primary-soft);
334+
opacity: 0.55;
335+
pointer-events: none;
336+
z-index: 1;
337+
}
338+
324339
.milkdown .editor a {
325340
color: var(--color-primary);
326341
text-decoration: underline;

0 commit comments

Comments
 (0)