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..2c2263eea6 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandles.test.ts @@ -0,0 +1,209 @@ +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( + content: PartialTableContent = { + type: "tableContent", + columnWidths: [100, 200], + rows: [{ cells: ["a", "b"] }, { cells: ["c", "d"] }], + }, +) { + 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 }, + ]); + + // 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, + }; + return view.dropHandler(new Event("drop") as DragEvent); +} + +// 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}` : "") + span, + ); + } + }); + return rows; +} + +describe("Table handles drag & drop", () => { + it("keeps comments when moving a row", () => { + const editor = createEditor(); + + expect(drop(editor, "row", 0, 1)).toBe(true); + + 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(); + + expect(drop(editor, "col", 0, 1)).toBe(true); + + expect(cells(editor)).toEqual([ + ["b", "a@thread"], + ["d", "c"], + ]); + expect((editor.getBlock("table") as any).content.columnWidths).toEqual([ + 200, 100, + ]); + 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(); + }); + }); +}); 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; };