From 7ba4bb6de681f966a3fdaca099214f7c69a02f85 Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:28:57 +0530 Subject: [PATCH 1/2] fix(core): keep comments when dragging table rows and columns Dropping a dragged row or column rebuilt the table from block JSON via updateBlock, which drops marks that aren't styles, like comments. Move the cells in place with prosemirror-tables' moveTableRow/moveTableColumn instead; cell attributes, including column widths, move with the cells. Fixes #2904 --- .../TableHandles/TableHandles.test.ts | 127 ++++++++++++++++++ .../extensions/TableHandles/TableHandles.ts | 103 +++++++------- 2 files changed, 177 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/extensions/TableHandles/TableHandles.test.ts diff --git a/packages/core/src/extensions/TableHandles/TableHandles.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.test.ts new file mode 100644 index 0000000000..03ed2b5a3a --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandles.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { CommentMark } from "../../comments/mark.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { TableHandlesView } from "./TableHandles.js"; + +/** + * @vitest-environment jsdom + */ + +function createEditor() { + const editor = BlockNoteEditor.create({ + extensions: [ + createExtension({ key: "commentMark", tiptapExtensions: [CommentMark] }), + ], + }); + editor.mount(document.body.appendChild(document.createElement("div"))); + editor.replaceBlocks(editor.document, [ + { + id: "table", + type: "table", + content: { + type: "tableContent", + columnWidths: [100, 200], + rows: [{ cells: ["a", "b"] }, { cells: ["c", "d"] }], + }, + }, + ]); + + // Comment on the "a" cell. + const { state } = editor.prosemirrorView; + let from = -1; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === "a") { + from = pos; + } + }); + editor.prosemirrorView.dispatch( + state.tr.addMark( + from, + from + 1, + state.schema.marks.comment.create({ threadId: "thread" }), + ), + ); + + return editor; +} + +// Simulates dropping a row/column handle that was dragged from `from` to `to`. +function drop( + editor: BlockNoteEditor, + orientation: "row" | "col", + from: number, + to: number, +) { + const view = (editor.prosemirrorView as any).pluginViews.find( + (v: unknown) => v instanceof TableHandlesView, + ) as TableHandlesView; + // What hovering the table sets up. + view.tableId = "table"; + view.tableElement = + editor.prosemirrorView.dom.querySelector('[data-id="table"]')!; + view.state = { + show: true, + showAddOrRemoveRowsButton: false, + showAddOrRemoveColumnsButton: false, + referencePosCell: undefined, + referencePosTable: new DOMRect(), + block: editor.getBlock("table") as any, + rowIndex: orientation === "row" ? to : 0, + colIndex: orientation === "col" ? to : 0, + draggingState: { + draggedCellOrientation: orientation, + originalIndex: from, + mousePos: 0, + }, + widgetContainer: undefined, + }; + expect(view.dropHandler(new Event("drop") as DragEvent)).toBe(true); +} + +// Text of each cell, with the thread ID of any comment on it. +function cells(editor: BlockNoteEditor) { + const rows: string[][] = []; + editor.prosemirrorState.doc.descendants((node) => { + if (node.type.name === "tableRow") { + rows.push([]); + } + if (node.isText) { + const comment = node.marks.find((m) => m.type.name === "comment"); + rows[rows.length - 1].push( + node.text! + (comment ? `@${comment.attrs.threadId}` : ""), + ); + } + }); + return rows; +} + +describe("Table handles drag & drop", () => { + it("keeps comments when moving a row", () => { + const editor = createEditor(); + + drop(editor, "row", 0, 1); + + expect(cells(editor)).toEqual([ + ["c", "d"], + ["a@thread", "b"], + ]); + editor._tiptapEditor.destroy(); + }); + + it("keeps comments and column widths when moving a column", () => { + const editor = createEditor(); + + drop(editor, "col", 0, 1); + + expect(cells(editor)).toEqual([ + ["b", "a@thread"], + ["d", "c"], + ]); + expect((editor.getBlock("table") as any).content.columnWidths).toEqual([ + 200, 100, + ]); + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index bb396fbdd7..e3f0346d4c 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -1,5 +1,11 @@ import type { Node } from "prosemirror-model"; -import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; +import { + EditorState, + Plugin, + PluginKey, + PluginView, + TextSelection, +} from "prosemirror-state"; import { CellSelection, addColumnAfter, @@ -9,6 +15,8 @@ import { deleteColumn, deleteRow, mergeCells, + moveTableColumn, + moveTableRow, splitCell, } from "prosemirror-tables"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; @@ -19,11 +27,10 @@ import { canColumnBeDraggedInto, canRowBeDraggedInto, cropEmptyRowsOrColumns, + getAbsoluteTableCells, getCellsAtColumnHandle, getCellsAtRowHandle, getDimensionsOfTable, - moveColumn, - moveRow, } from "../../api/blockManipulation/tables/tables.js"; import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../api/nodeUtil.js"; @@ -472,62 +479,52 @@ export class TableHandlesView implements PluginView { // Clear so a re-dispatched drop short-circuits above (issue #2691). this.state.draggingState = undefined; - const columnWidths = this.state.block.content.columnWidths; + const block = this.state.block; + const isRow = draggingState.draggedCellOrientation === "row"; + const targetIndex = isRow ? rowIndex : colIndex; + const canBeDraggedInto = isRow + ? canRowBeDraggedInto + : canColumnBeDraggedInto; - if (draggingState.draggedCellOrientation === "row") { - if ( - !canRowBeDraggedInto( - this.state.block, - draggingState.originalIndex, - rowIndex, - ) - ) { - // If the target row is invalid, don't move the row - return false; - } - const newTable = moveRow( - this.state.block, - draggingState.originalIndex, - rowIndex, - ); - this.editor.updateBlock(this.state.block, { - type: "table", - content: { - ...this.state.block.content, - rows: newTable as any, - }, - }); - } else { - if ( - !canColumnBeDraggedInto( - this.state.block, - draggingState.originalIndex, - colIndex, - ) - ) { - // If the target column is invalid, don't move the column + if (!canBeDraggedInto(block, draggingState.originalIndex, targetIndex)) { + // If the target row/column is invalid, don't move it + return false; + } + + // The handles use relative indices, prosemirror-tables absolute ones. + function toAbsoluteIndex(index: number) { + return isRow + ? getAbsoluteTableCells({ row: index, col: 0 }, block).row + : getAbsoluteTableCells({ row: 0, col: index }, block).col; + } + const from = toAbsoluteIndex(draggingState.originalIndex); + const to = toAbsoluteIndex(targetIndex); + + // Moves the cells in place instead of rebuilding the table from block + // JSON, which would drop marks that aren't styles, like comments (issue + // #2904). Cell attributes, including column widths, move with the cells. + const moved = this.editor.exec((state, dispatch) => { + const tablePos = this.getTablePos(state.doc); + if (tablePos === undefined) { return false; } - const newTable = moveColumn( - this.state.block, - draggingState.originalIndex, - colIndex, + // prosemirror-tables finds the table from the selection. + const stateInTable = state.apply( + state.tr.setSelection( + TextSelection.near(state.doc.resolve(tablePos + 1)), + ), ); - const [columnWidth] = columnWidths.splice(draggingState.originalIndex, 1); - columnWidths.splice(colIndex, 0, columnWidth); - this.editor.updateBlock(this.state.block, { - type: "table", - content: { - ...this.state.block.content, - columnWidths, - rows: newTable as any, - }, - }); + const move = isRow ? moveTableRow : moveTableColumn; + return move({ from, to, select: false })(stateInTable, dispatch); + }); + + if (!moved) { + return false; } - // Have to reset text cursor position to the block as `updateBlock` moves - // the existing selection out of the block. - this.editor.setTextCursorPosition(this.state.block.id); + // Have to reset text cursor position to the block as replacing the table + // moves the existing selection out of it. + this.editor.setTextCursorPosition(block.id); return true; }; From e30d6d7904c926ec270c99e03c2335f60bee3fb7 Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:20:21 +0530 Subject: [PATCH 2/2] test(core): cover dragging rows and columns in tables with merged cells Moves a column past a cell spanning two columns and a row past a cell spanning two rows, with a comment on the merged cell, and checks that a row can't be dropped into the middle of a cell spanning two rows. --- .../TableHandles/TableHandles.test.ts | 112 +++++++++++++++--- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandles.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.test.ts index 03ed2b5a3a..2c2263eea6 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.test.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.test.ts @@ -1,15 +1,23 @@ import { describe, expect, it } from "vite-plus/test"; +import type { DefaultInlineContentSchema } from "../../blocks/defaultBlocks.js"; import { CommentMark } from "../../comments/mark.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; +import type { PartialTableContent } from "../../schema/index.js"; import { TableHandlesView } from "./TableHandles.js"; /** * @vitest-environment jsdom */ -function createEditor() { +function createEditor( + content: PartialTableContent = { + type: "tableContent", + columnWidths: [100, 200], + rows: [{ cells: ["a", "b"] }, { cells: ["c", "d"] }], + }, +) { const editor = BlockNoteEditor.create({ extensions: [ createExtension({ key: "commentMark", tiptapExtensions: [CommentMark] }), @@ -17,15 +25,7 @@ function createEditor() { }); editor.mount(document.body.appendChild(document.createElement("div"))); editor.replaceBlocks(editor.document, [ - { - id: "table", - type: "table", - content: { - type: "tableContent", - columnWidths: [100, 200], - rows: [{ cells: ["a", "b"] }, { cells: ["c", "d"] }], - }, - }, + { id: "table", type: "table", content }, ]); // Comment on the "a" cell. @@ -77,20 +77,26 @@ function drop( }, widgetContainer: undefined, }; - expect(view.dropHandler(new Event("drop") as DragEvent)).toBe(true); + return view.dropHandler(new Event("drop") as DragEvent); } -// Text of each cell, with the thread ID of any comment on it. +// Text of each cell, with the thread ID of any comment on it and the cell's +// span if it's a merged cell. function cells(editor: BlockNoteEditor) { const rows: string[][] = []; + let span = ""; editor.prosemirrorState.doc.descendants((node) => { if (node.type.name === "tableRow") { rows.push([]); } + if (node.type.name === "tableCell") { + const { colspan, rowspan } = node.attrs; + span = colspan > 1 || rowspan > 1 ? `[${colspan}x${rowspan}]` : ""; + } if (node.isText) { const comment = node.marks.find((m) => m.type.name === "comment"); rows[rows.length - 1].push( - node.text! + (comment ? `@${comment.attrs.threadId}` : ""), + node.text! + (comment ? `@${comment.attrs.threadId}` : "") + span, ); } }); @@ -101,7 +107,7 @@ describe("Table handles drag & drop", () => { it("keeps comments when moving a row", () => { const editor = createEditor(); - drop(editor, "row", 0, 1); + expect(drop(editor, "row", 0, 1)).toBe(true); expect(cells(editor)).toEqual([ ["c", "d"], @@ -113,7 +119,7 @@ describe("Table handles drag & drop", () => { it("keeps comments and column widths when moving a column", () => { const editor = createEditor(); - drop(editor, "col", 0, 1); + expect(drop(editor, "col", 0, 1)).toBe(true); expect(cells(editor)).toEqual([ ["b", "a@thread"], @@ -124,4 +130,80 @@ describe("Table handles drag & drop", () => { ]); editor._tiptapEditor.destroy(); }); + + describe("with merged cells", () => { + it("moves a column past a cell spanning two columns", () => { + const editor = createEditor({ + type: "tableContent", + rows: [ + { + cells: [ + { type: "tableCell", content: "a", props: { colspan: 2 } }, + { type: "tableCell", content: "b" }, + ], + }, + { cells: ["c", "d", "e"] }, + ], + }); + + // The handle indices count cells, so column 1 is the one holding "b". + expect(drop(editor, "col", 1, 0)).toBe(true); + + expect(cells(editor)).toEqual([ + ["b", "a@thread[2x1]"], + ["e", "c", "d"], + ]); + editor._tiptapEditor.destroy(); + }); + + it("moves a row past a cell spanning two rows", () => { + const editor = createEditor({ + type: "tableContent", + rows: [ + { + cells: [ + { type: "tableCell", content: "a", props: { rowspan: 2 } }, + { type: "tableCell", content: "b" }, + ], + }, + { cells: ["c"] }, + { cells: ["d", "e"] }, + ], + }); + + expect(drop(editor, "row", 2, 0)).toBe(true); + + expect(cells(editor)).toEqual([ + ["d", "e"], + ["a@thread[1x2]", "b"], + ["c"], + ]); + editor._tiptapEditor.destroy(); + }); + + it("doesn't drop a row into the middle of a cell spanning two rows", () => { + const editor = createEditor({ + type: "tableContent", + rows: [ + { cells: ["a", "b"] }, + { + cells: [ + { type: "tableCell", content: "c", props: { rowspan: 2 } }, + { type: "tableCell", content: "d" }, + ], + }, + { cells: ["e"] }, + ], + }); + + expect(drop(editor, "row", 0, 1)).toBe(false); + + expect(cells(editor)).toEqual([ + ["a@thread", "b"], + ["c[1x2]", "d"], + ["e"], + ]); + editor._tiptapEditor.destroy(); + }); + }); });