From 48bbace2a830b1806b90167c54d84fecb0b76228 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Mon, 17 Aug 2026 21:57:13 +0300 Subject: [PATCH 1/2] feat(core): show a drag preview and source highlight for table drags Dragging a table row or column gave almost no feedback: a drop cursor at the target position, and a deliberately hidden 1x1 native drag image, so nothing followed the cursor and nothing marked what was being moved. Both are now part of the TableHandles extension, hanging off the drag state it already keeps: - The cells of the row/column being dragged get a `bn-table-drag-source` class, via node decorations added alongside the existing drop-cursor widgets. Both are resolved with getCellsAtRowHandle / getCellsAtColumnHandle, so merged cells are handled the same way the drop cursor already handles them. The highlight is shown for the whole drag, including while the cursor is outside the table or over a position that can't be dropped into - only the drop cursor is conditional on that. - The hidden drag image is replaced by a copy of the cells being dragged, so the content visibly follows the cursor. Cells are copied at their measured on-screen size, which reproduces the widths the supplied and flattens merged cells. The copy is appended next to the editor and inherits the editor's classes, minus the ones identifying it *as* the editor (SideMenuView measures every .bn-editor in the document, and this isn't one) - the same filtering the side menu's own drag preview does. Table styles are matched against `:is(.bn-editor, .bn-table-drag-preview)` so they reach the copy; :is() takes its most specific argument, so the selectors stay exactly as specific as they were and existing overrides are unaffected. Layout properties set on `.ProseMirror table`, which the copy is outside of, are carried over explicitly, as are the width and minimum width the table is given inline - the latter covers every column, so a copy holding one column would otherwise be stretched to the width of the whole table. Also guards the decorations against a stale `tablePos`: it's captured on hover and isn't remapped, so a concurrent edit elsewhere in the document mid-drag could make it resolve out of range and throw out of the plugin (#2921). Skipping the decorations is enough to keep the editor alive. Co-Authored-By: Claude Opus 5 --- packages/core/src/editor/editor.css | 44 +++- .../extensions/TableHandles/TableHandles.ts | 244 +++++++++++++++--- tests/src/end-to-end/tables/tables.test.tsx | 204 +++++++++++++++ 3 files changed, 445 insertions(+), 47 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..75cd4169b5 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -65,6 +65,34 @@ pointer-events: none; } +/* Cells of the row/column currently being dragged. An inset shadow is used + rather than a background so the tint layers on top of any background colour + the cell already has, instead of replacing it. */ +.bn-table-drag-source { + box-shadow: inset 0 0 0 100vmax rgb(170 221 255 / 40%); +} + +/* Drag image shown under the cursor while dragging a table row/column, holding + a copy of the cells being dragged (see `setTableDragImage`). It sits next to + the editor rather than inside it, so the table styles below match it through + its own class instead of `.bn-editor`. */ +.bn-table-drag-preview { + position: absolute; + top: 0; + left: 0; + width: fit-content; + background-color: var(--bn-colors-editor-background, #fff); + color: var(--bn-colors-editor-text, inherit); + border-radius: 4px; + box-shadow: 0 4px 12px rgb(0 0 0 / 25%); + overflow: hidden; + /* Same trick as `.bn-drag-preview` below: an extremely low opacity leaves the + element invisible in the editor without hiding the drag image itself, which + setting it to 0 would. */ + opacity: 0.001; + pointer-events: none; +} + .bn-drag-preview { position: absolute; top: 0; @@ -147,23 +175,27 @@ } /* table related: */ -.bn-editor [data-content-type="table"] table { +/* `.bn-table-drag-preview` holds a copy of the cells being dragged, and is + matched alongside the editor so that the copy is styled like the real table. + `:is()` takes the specificity of its most specific argument, so these stay + exactly as specific as `.bn-editor ...` was on its own. */ +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] table { width: auto !important; word-break: break-word; } -.bn-editor [data-content-type="table"] th, -.bn-editor [data-content-type="table"] td { +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] th, +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] td { border: 1px solid #ddd; padding: 5px 10px; } -.bn-editor [data-content-type="table"] th { +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] th { font-weight: bold; text-align: left; } -.bn-editor [data-content-type="table"] th > p, -.bn-editor [data-content-type="table"] td > p { +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] th > p, +:is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] td > p { min-height: 1.5rem; } diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 4616d76b70..98b0f914e1 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -67,32 +67,130 @@ export type TableHandlesState = { widgetContainer: HTMLElement | undefined; }; -function setHiddenDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement) { - return; +/** + * Copies the cells of the row/column being dragged into a standalone element, + * which is then used as the native drag image so that the content being moved + * visibly follows the cursor. + * + * The copy is wrapped in an element carrying the editor's own class list and + * appended next to the editor, rather than to the document body, so that all + * editor-scoped table styling - including any app-level overrides of it, and + * whichever theme/colour scheme the editor is nested in - applies to the drag + * image exactly as it does to the real table. + */ +function setTableDragImage( + editorElement: HTMLElement, + tableElement: HTMLTableElement, + cells: RelativeCellIndices[], + orientation: "row" | "col", +) { + unsetTableDragImage(); + + const tableCopy = tableElement.cloneNode(false) as HTMLTableElement; + // The clone inherits the width and minimum width the real table is given + // inline, both of which cover all of its columns - a minimum width of + // `columns * --default-cell-min-width` would stretch a copy holding a single + // column to the width of the whole table. The copy is sized by its cells + // instead. + tableCopy.style.removeProperty("width"); + tableCopy.style.removeProperty("min-width"); + tableCopy.style.removeProperty("max-width"); + // How the table lays out and how borders between cells are drawn are both + // set on `.ProseMirror table`, which the copy is deliberately outside of, so + // they're carried over directly. Without them the browser defaults apply: + // borders between cells double up, and auto layout lets a cell grow past the + // width set on it below to fit its content, making the copy wider than the + // column it's a copy of. + const tableStyle = window.getComputedStyle(tableElement); + tableCopy.style.tableLayout = tableStyle.tableLayout; + tableCopy.style.borderCollapse = tableStyle.borderCollapse; + tableCopy.style.borderSpacing = tableStyle.borderSpacing; + const tbody = document.createElement("tbody"); + tableCopy.appendChild(tbody); + + // Dragging a row copies a single row of cells, dragging a column copies one + // cell from each row. + const rows = orientation === "row" ? [cells] : cells.map((cell) => [cell]); + + for (const rowCells of rows) { + const sourceRow = tableElement.rows[rowCells[0]?.row]; + if (!sourceRow) { + continue; + } + + const rowCopy = sourceRow.cloneNode(false) as HTMLTableRowElement; + + for (const { row, col } of rowCells) { + const sourceCell = tableElement.rows[row]?.cells[col]; + if (!sourceCell) { + continue; + } + + const cellRect = sourceCell.getBoundingClientRect(); + const cellCopy = sourceCell.cloneNode(true) as HTMLTableCellElement; + // The drag highlight is already on the source cells by the time the + // drag image is built, but the drag image represents the cells as + // they'll look once dropped, so it shouldn't be tinted. + cellCopy.classList.remove("bn-table-drag-source"); + // The copy is laid out on its own, so merged cells have no neighbouring + // cells left to span into, and the widths that the table's + // would have supplied are gone too. Both are replaced by the size the + // cell actually has on screen, which keeps the drag image the same size + // as what's being dragged. + cellCopy.rowSpan = 1; + cellCopy.colSpan = 1; + cellCopy.style.boxSizing = "border-box"; + cellCopy.style.width = `${cellRect.width}px`; + cellCopy.style.height = `${cellRect.height}px`; + rowCopy.appendChild(cellCopy); + } + + if (rowCopy.childElementCount > 0) { + tbody.appendChild(rowCopy); + } } + // The editor's own classes are inherited so that theme/appearance styles + // reach the copied cells, but the classes identifying it *as* the editor are + // left off - other code looks editors up by those (e.g. `SideMenuView` + // measuring every `.bn-editor` in the document), and this isn't one. + const inheritedClasses = editorElement.className + .split(" ") + .filter( + (className) => + className !== "ProseMirror" && + className !== "bn-root" && + className !== "bn-editor", + ) + .join(" "); + dragImageElement = document.createElement("div"); - dragImageElement.innerHTML = "_"; - dragImageElement.style.opacity = "0"; - dragImageElement.style.height = "1px"; - dragImageElement.style.width = "1px"; - if (rootEl instanceof Document) { - rootEl.body.appendChild(dragImageElement); + dragImageElement.className = `${inheritedClasses} bn-table-drag-preview`; + + if (tbody.childElementCount > 0) { + // Table styles are scoped to `[data-content-type="table"]` within + // `.bn-editor`, so the drag image recreates that structure around the + // copied cells instead of relying on the cloned 's own attributes. + const blockContent = document.createElement("div"); + blockContent.setAttribute("data-content-type", "table"); + blockContent.appendChild(tableCopy); + dragImageElement.appendChild(blockContent); } else { - rootEl.appendChild(dragImageElement); + // No cells could be copied (e.g. the handle's index no longer resolves to + // anything in the table). Fall back to an empty element, which keeps the + // browser from falling back to its own drag image of the drag handle. + dragImageElement.style.height = "1px"; + dragImageElement.style.width = "1px"; } + + (editorElement.parentElement ?? editorElement).appendChild(dragImageElement); + + return dragImageElement; } -function unsetHiddenDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement) { - if (rootEl instanceof Document) { - rootEl.body.removeChild(dragImageElement); - } else { - rootEl.removeChild(dragImageElement); - } - dragImageElement = undefined; - } +function unsetTableDragImage() { + dragImageElement?.remove(); + dragImageElement = undefined; } function getChildIndex(node: Element) { @@ -617,6 +715,38 @@ export const TableHandlesExtension = createExtension(({ editor }) => { const store = createStore(undefined); + // Replaces the browser's default drag image (which would be the drag handle + // itself) with a copy of the row/column being dragged. + const applyDragImage = ( + event: { dataTransfer: DataTransfer | null }, + orientation: "row" | "col", + index: number, + ) => { + const tableElement = view?.tableElement?.querySelector("table"); + if (!event.dataTransfer || !view?.state || !tableElement) { + return; + } + + const dragImage = setTableDragImage( + editor.prosemirrorView.dom as HTMLElement, + tableElement, + orientation === "row" + ? getCellsAtRowHandle(view.state.block, index) + : getCellsAtColumnHandle(view.state.block, index), + orientation, + ); + + // The row handle sits halfway down the row's left edge, and the column + // handle halfway along the column's top edge, so the drag image is + // anchored to the cursor at that same point. + const { width, height } = dragImage.getBoundingClientRect(); + event.dataTransfer.setDragImage( + dragImage, + orientation === "row" ? 0 : width / 2, + orientation === "row" ? height / 2 : 0, + ); + }; + return { key: "tableHandles", store, @@ -638,8 +768,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => { }); return view; }, - // We use decorations to render the drop cursor when dragging a table row - // or column. The decorations are updated in the `dragOverHandler` method. + // We use decorations to highlight the row or column being dragged, and + // to render the drop cursor showing where it will end up. The + // decorations are updated in the `dragOverHandler` method. props: { decorations: (state) => { if ( @@ -651,27 +782,63 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - const newIndex = - view.state.draggingState.draggedCellOrientation === "row" - ? view.state.rowIndex - : view.state.colIndex; + const decorations: Decoration[] = []; + const { block, draggingState } = view.state; + const { originalIndex, draggedCellOrientation } = draggingState; - if (newIndex === undefined) { + if (!block) { + return DecorationSet.create(state.doc, decorations); + } + + // Gets the table to show the decorations in. `tablePos` is + // captured when a cell is hovered and isn't remapped afterwards, + // so a transaction that changes the document elsewhere while a + // drag is in progress - a concurrent local or collaborative edit - + // can leave it pointing outside the document. Skip the decorations + // in that case rather than throwing out of the plugin. + let tableResolvedPos; + try { + tableResolvedPos = state.doc.resolve(view.tablePos + 1); + } catch { return; } - const decorations: Decoration[] = []; - const { block, draggingState } = view.state; - const { originalIndex, draggedCellOrientation } = draggingState; + // Highlights the cells of the row/column being dragged, so it stays + // clear what is being moved while the drop cursor shows where it + // will be moved to. + const draggedCells = + draggedCellOrientation === "row" + ? getCellsAtRowHandle(block, originalIndex) + : getCellsAtColumnHandle(block, originalIndex); + + draggedCells.forEach(({ row, col }) => { + // Gets the row in the table, then the cell within that row. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(row) + 1, + ); + const cellPos = rowResolvedPos.posAtIndex(col); + const cellNode = state.doc.resolve(cellPos + 1).node(); + + decorations.push( + Decoration.node(cellPos, cellPos + cellNode.nodeSize, { + class: "bn-table-drag-source", + }), + ); + }); - // Return empty decorations if: + const newIndex = + draggedCellOrientation === "row" + ? view.state.rowIndex + : view.state.colIndex; + + // Only the highlight is shown, without a drop cursor, if: + // - The cursor isn't over a cell // - Dragging to same position - // - No block exists // - Row drag not allowed // - Column drag not allowed if ( + newIndex === undefined || newIndex === originalIndex || - !block || (draggedCellOrientation === "row" && !canRowBeDraggedInto(block, originalIndex, newIndex)) || (draggedCellOrientation === "col" && @@ -680,10 +847,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return DecorationSet.create(state.doc, decorations); } - // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(view.tablePos + 1); - - if (view.state.draggingState.draggedCellOrientation === "row") { + if (draggedCellOrientation === "row") { const cellsInRow = getCellsAtRowHandle( view.state.block, newIndex, @@ -824,8 +988,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - setHiddenDragImage(editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + applyDragImage(event, "col", view.state.colIndex); event.dataTransfer!.effectAllowed = "move"; }, @@ -864,8 +1027,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - setHiddenDragImage(editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + applyDragImage(event, "row", view!.state.rowIndex); event.dataTransfer!.effectAllowed = "copyMove"; }, @@ -889,7 +1051,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - unsetHiddenDragImage(editor.prosemirrorView.root); + unsetTableDragImage(); }, /** diff --git a/tests/src/end-to-end/tables/tables.test.tsx b/tests/src/end-to-end/tables/tables.test.tsx index f3b6bf7ce4..68b5846343 100644 --- a/tests/src/end-to-end/tables/tables.test.tsx +++ b/tests/src/end-to-end/tables/tables.test.tsx @@ -65,6 +65,11 @@ async function clickTableHandleMenuItem( await userEvent.click(item); } +function centerOf(element: Element) { + const box = element.getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + beforeEach(async () => { await render(); await waitForSelector(EDITOR_SELECTOR); @@ -301,4 +306,203 @@ describe("Check Table interactions", () => { await compareDocToSnapshot("addColumnThenRow"); }, ); + + // Visual feedback shown while a row/column drag is in progress: the cells + // being dragged are highlighted, and a copy of them is used as the drag + // image so it follows the cursor. Playwright doesn't correctly simulate + // drag events in Firefox. + test.skipIf(browserName === "firefox")( + "Row drag should highlight the row and use it as the drag image", + async () => { + await focusOnEditor(); + await executeSlashCommand("table"); + await waitForSelector(TABLE_SELECTOR); + + const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`); + const cellsPerRow = rows[0].querySelectorAll("td").length; + const handle = await getTableHandle( + rows[0].querySelector("td") as HTMLElement, + "row", + ); + + await mouseSequence([ + { type: "move", ...centerOf(handle), steps: 5 }, + { type: "down" }, + // Onto the second row, so the drop cursor has somewhere to go. + { + type: "move", + ...centerOf(rows[1].querySelector("td") as HTMLElement), + steps: 10, + }, + ]); + + await vi.waitFor(() => { + expect( + document.querySelectorAll( + `${TABLE_SELECTOR} tbody tr:first-child .bn-table-drag-source`, + ), + ).toHaveLength(cellsPerRow); + expect( + document.querySelectorAll(".bn-table-drop-cursor").length, + ).toBeGreaterThan(0); + // The drag image holds a copy of the dragged row, and shouldn't + // carry the highlight that's on the row it was copied from. + expect( + document.querySelectorAll(".bn-table-drag-preview tr"), + ).toHaveLength(1); + expect( + document.querySelectorAll( + ".bn-table-drag-preview .bn-table-drag-source", + ), + ).toHaveLength(0); + }); + + await mouseSequence([{ type: "up" }]); + + // All of it is transient, and is torn down on `dragend` rather than + // synchronously with the mouseup. + await vi.waitFor(() => { + expect(document.querySelectorAll(".bn-table-drag-source")).toHaveLength( + 0, + ); + expect(document.querySelectorAll(".bn-table-drop-cursor")).toHaveLength( + 0, + ); + expect( + document.querySelectorAll(".bn-table-drag-preview"), + ).toHaveLength(0); + }); + }, + ); + + test.skipIf(browserName === "firefox")( + "Column drag should highlight every cell in the column", + async () => { + await focusOnEditor(); + await executeSlashCommand("table"); + await waitForSelector(TABLE_SELECTOR); + + const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`); + const firstRowCells = rows[0].querySelectorAll("td"); + const handle = await getTableHandle( + firstRowCells[0] as HTMLElement, + "column", + ); + + await mouseSequence([ + { type: "move", ...centerOf(handle), steps: 5 }, + { type: "down" }, + { + type: "move", + ...centerOf(firstRowCells[firstRowCells.length - 1] as HTMLElement), + steps: 10, + }, + ]); + + await vi.waitFor(() => { + // One highlighted cell per row, and a drag image holding a copy of + // each of them, stacked one per row. + expect(document.querySelectorAll(".bn-table-drag-source")).toHaveLength( + rows.length, + ); + expect( + document.querySelectorAll(".bn-table-drag-preview tr"), + ).toHaveLength(rows.length); + }); + + await mouseSequence([{ type: "up" }]); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".bn-table-drag-source")).toHaveLength( + 0, + ); + }); + }, + ); + + test.skipIf(browserName === "firefox")( + "Cancelling a drag should clean up the highlight and drag image", + async () => { + await focusOnEditor(); + await executeSlashCommand("table"); + await waitForSelector(TABLE_SELECTOR); + + const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`); + const handle = await getTableHandle( + rows[0].querySelector("td") as HTMLElement, + "row", + ); + + await mouseSequence([ + { type: "move", ...centerOf(handle), steps: 5 }, + { type: "down" }, + { + type: "move", + ...centerOf(rows[1].querySelector("td") as HTMLElement), + steps: 10, + }, + ]); + await vi.waitFor(() => { + expect( + document.querySelectorAll(".bn-table-drag-source").length, + ).toBeGreaterThan(0); + }); + + // Escape cancels a native HTML5 drag: the browser fires `dragend` + // without a `drop`. Cleanup hangs off the same `dragEnd()` callback + // either way, so it should run here too. + await userEvent.keyboard("{Escape}"); + // Release the mouse button so it doesn't leak into the next test. + await mouseSequence([{ type: "up" }]); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".bn-table-drag-source")).toHaveLength( + 0, + ); + expect( + document.querySelectorAll(".bn-table-drag-preview"), + ).toHaveLength(0); + }); + }, + ); + + test.skipIf(browserName === "firefox")( + "Drag image should be the same size as the column being dragged", + async () => { + await focusOnEditor(); + await executeSlashCommand("table"); + await waitForSelector(TABLE_SELECTOR); + + const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`); + const columnCell = rows[0].querySelector("td") as HTMLElement; + const columnWidth = columnCell.getBoundingClientRect().width; + + const handle = await getTableHandle(columnCell, "column"); + await mouseSequence([ + { type: "move", ...centerOf(handle), steps: 5 }, + { type: "down" }, + { + type: "move", + ...centerOf(rows[0].querySelectorAll("td")[1]), + steps: 10, + }, + ]); + + await vi.waitFor(() => { + const previewCells = document.querySelectorAll( + ".bn-table-drag-preview td", + ); + expect(previewCells).toHaveLength(rows.length); + previewCells.forEach((previewCell) => { + // Sub-pixel tolerance: the collapsed border around the copy shifts + // the measured width by about a pixel. + expect( + Math.abs(previewCell.getBoundingClientRect().width - columnWidth), + ).toBeLessThan(2); + }); + }); + + await mouseSequence([{ type: "up" }]); + }, + ); }); From 225c1ccf4e6a8dcabe6ca6bf2020c472ff7374ec Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Mon, 17 Aug 2026 22:42:38 +0300 Subject: [PATCH 2/2] fix(core): guard the whole table drag decoration build against stale state Addresses review on #2920 (r3798069357): the try/catch only covered `state.doc.resolve(tablePos + 1)`, so everything after it was still unprotected. `posAtIndex` calls `node.child()` internally and throws a RangeError when the index is out of range, which is reachable two ways - `tablePos` still resolving but no longer pointing at the table, and the row/column counts coming from `state.block`, a snapshot taken on hover that can exceed what's in the document by the time it's used. The decoration build moves into `getTableDragDecorations`, which the plugin prop calls inside a single try/catch, so a stale position or index anywhere in it - including the drop-cursor branches, which were never covered - skips the decorations instead of throwing out of the plugin. It also checks the resolved node really is a table before indexing into it. This still isn't a fix for the underlying staleness (#2921), which needs `tablePos` remapped through `tr.mapping`; it keeps the editor alive until then. Also clears the drag image in `TableHandlesView.destroy()`. Cleanup otherwise only runs from `dragEnd`, which never arrives if the editor is torn down mid-drag, leaving the copy in the DOM and the module-scope reference set. Co-Authored-By: Claude Opus 5 --- .../extensions/TableHandles/TableHandles.ts | 332 +++++++++--------- 1 file changed, 174 insertions(+), 158 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 98b0f914e1..7d20626339 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -694,6 +694,10 @@ export class TableHandlesView implements PluginView { } destroy() { + // The drag image is normally cleaned up on `dragEnd`, which never arrives + // if the editor is torn down mid-drag. + unsetTableDragImage(); + this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler); window.removeEventListener("mouseup", this.mouseUpHandler); this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); @@ -708,6 +712,170 @@ export class TableHandlesView implements PluginView { } } +/** + * Builds the decorations shown while a table row or column is being dragged: + * a highlight on the cells being dragged, and the drop cursor marking where + * they'll end up. + * + * Both `tablePos` and the row/column counts in `viewState.block` are captured + * when a cell is hovered and aren't remapped afterwards, so resolving them can + * throw once they've gone stale - see the caller, which treats that as "no + * decorations". + */ +function getTableDragDecorations( + state: EditorState, + tablePos: number, + viewState: TableHandlesState, +): DecorationSet | undefined { + const { block, draggingState } = viewState; + + if (!block || !draggingState) { + return undefined; + } + + const { originalIndex, draggedCellOrientation } = draggingState; + const decorations: Decoration[] = []; + + // Gets the table to show the decorations in. + const tableResolvedPos = state.doc.resolve(tablePos + 1); + if (tableResolvedPos.node().type.name !== "table") { + return undefined; + } + + // Highlights the cells of the row/column being dragged, so it stays clear + // what is being moved while the drop cursor shows where it will be moved to. + const draggedCells = + draggedCellOrientation === "row" + ? getCellsAtRowHandle(block, originalIndex) + : getCellsAtColumnHandle(block, originalIndex); + + draggedCells.forEach(({ row, col }) => { + // Gets the row in the table, then the cell within that row. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(row) + 1, + ); + const cellPos = rowResolvedPos.posAtIndex(col); + const cellNode = state.doc.resolve(cellPos + 1).node(); + + decorations.push( + Decoration.node(cellPos, cellPos + cellNode.nodeSize, { + class: "bn-table-drag-source", + }), + ); + }); + + const newIndex = + draggedCellOrientation === "row" ? viewState.rowIndex : viewState.colIndex; + + // Only the highlight is shown, without a drop cursor, if: + // - The cursor isn't over a cell + // - Dragging to same position + // - Row drag not allowed + // - Column drag not allowed + if ( + newIndex === undefined || + newIndex === originalIndex || + (draggedCellOrientation === "row" && + !canRowBeDraggedInto(block, originalIndex, newIndex)) || + (draggedCellOrientation === "col" && + !canColumnBeDraggedInto(block, originalIndex, newIndex)) + ) { + return DecorationSet.create(state.doc, decorations); + } + + if (draggedCellOrientation === "row") { + const cellsInRow = getCellsAtRowHandle(block, newIndex); + + cellsInRow.forEach(({ row, col }) => { + // Gets each row in the table. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(row) + 1, + ); + + // Gets the cell within the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(col) + 1, + ); + const cellNode = cellResolvedPos.node(); + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); + decorations.push( + // The widget is a small bar which spans the width of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.left = "0"; + widget.style.right = "0"; + // This is only necessary because the drop indicator's height + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the row is being dropped. + if (newIndex > originalIndex) { + widget.style.bottom = "-2px"; + } else { + widget.style.top = "-3px"; + } + widget.style.height = "4px"; + + return widget; + }), + ); + }); + } else { + const cellsInColumn = getCellsAtColumnHandle(block, newIndex); + + cellsInColumn.forEach(({ row, col }) => { + // Gets each row in the table. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(row) + 1, + ); + + // Gets the cell within the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(col) + 1, + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); + + decorations.push( + // The widget is a small bar which spans the height of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.top = "0"; + widget.style.bottom = "0"; + // This is only necessary because the drop indicator's width + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the column is being dropped. + if (newIndex > originalIndex) { + widget.style.right = "-2px"; + } else { + widget.style.left = "-3px"; + } + widget.style.width = "4px"; + + return widget; + }), + ); + }); + } + + return DecorationSet.create(state.doc, decorations); +} + export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); export const TableHandlesExtension = createExtension(({ editor }) => { @@ -782,168 +950,16 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - const decorations: Decoration[] = []; - const { block, draggingState } = view.state; - const { originalIndex, draggedCellOrientation } = draggingState; - - if (!block) { - return DecorationSet.create(state.doc, decorations); - } - - // Gets the table to show the decorations in. `tablePos` is - // captured when a cell is hovered and isn't remapped afterwards, - // so a transaction that changes the document elsewhere while a - // drag is in progress - a concurrent local or collaborative edit - - // can leave it pointing outside the document. Skip the decorations - // in that case rather than throwing out of the plugin. - let tableResolvedPos; try { - tableResolvedPos = state.doc.resolve(view.tablePos + 1); + return getTableDragDecorations(state, view.tablePos, view.state); } catch { + // A transaction that changes the document while a drag is in + // progress - a concurrent local or collaborative edit - can + // leave the captured table position and block snapshot pointing + // past the end of the document. Skip the decorations for this + // state rather than throwing out of the plugin. return; } - - // Highlights the cells of the row/column being dragged, so it stays - // clear what is being moved while the drop cursor shows where it - // will be moved to. - const draggedCells = - draggedCellOrientation === "row" - ? getCellsAtRowHandle(block, originalIndex) - : getCellsAtColumnHandle(block, originalIndex); - - draggedCells.forEach(({ row, col }) => { - // Gets the row in the table, then the cell within that row. - const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(row) + 1, - ); - const cellPos = rowResolvedPos.posAtIndex(col); - const cellNode = state.doc.resolve(cellPos + 1).node(); - - decorations.push( - Decoration.node(cellPos, cellPos + cellNode.nodeSize, { - class: "bn-table-drag-source", - }), - ); - }); - - const newIndex = - draggedCellOrientation === "row" - ? view.state.rowIndex - : view.state.colIndex; - - // Only the highlight is shown, without a drop cursor, if: - // - The cursor isn't over a cell - // - Dragging to same position - // - Row drag not allowed - // - Column drag not allowed - if ( - newIndex === undefined || - newIndex === originalIndex || - (draggedCellOrientation === "row" && - !canRowBeDraggedInto(block, originalIndex, newIndex)) || - (draggedCellOrientation === "col" && - !canColumnBeDraggedInto(block, originalIndex, newIndex)) - ) { - return DecorationSet.create(state.doc, decorations); - } - - if (draggedCellOrientation === "row") { - const cellsInRow = getCellsAtRowHandle( - view.state.block, - newIndex, - ); - - cellsInRow.forEach(({ row, col }) => { - // Gets each row in the table. - const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(row) + 1, - ); - - // Gets the cell within the row. - const cellResolvedPos = state.doc.resolve( - rowResolvedPos.posAtIndex(col) + 1, - ); - const cellNode = cellResolvedPos.node(); - // Creates a decoration at the start or end of each cell, - // depending on whether the new index is before or after the - // original index. - const decorationPos = - cellResolvedPos.pos + - (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); - decorations.push( - // The widget is a small bar which spans the width of the cell. - Decoration.widget(decorationPos, () => { - const widget = document.createElement("div"); - widget.className = "bn-table-drop-cursor"; - widget.style.left = "0"; - widget.style.right = "0"; - // This is only necessary because the drop indicator's height - // is an even number of pixels, whereas the border between - // table cells is an odd number of pixels. So this makes the - // positioning slightly more consistent regardless of where - // the row is being dropped. - if (newIndex > originalIndex) { - widget.style.bottom = "-2px"; - } else { - widget.style.top = "-3px"; - } - widget.style.height = "4px"; - - return widget; - }), - ); - }); - } else { - const cellsInColumn = getCellsAtColumnHandle( - view.state.block, - newIndex, - ); - - cellsInColumn.forEach(({ row, col }) => { - // Gets each row in the table. - const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(row) + 1, - ); - - // Gets the cell within the row. - const cellResolvedPos = state.doc.resolve( - rowResolvedPos.posAtIndex(col) + 1, - ); - const cellNode = cellResolvedPos.node(); - - // Creates a decoration at the start or end of each cell, - // depending on whether the new index is before or after the - // original index. - const decorationPos = - cellResolvedPos.pos + - (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); - - decorations.push( - // The widget is a small bar which spans the height of the cell. - Decoration.widget(decorationPos, () => { - const widget = document.createElement("div"); - widget.className = "bn-table-drop-cursor"; - widget.style.top = "0"; - widget.style.bottom = "0"; - // This is only necessary because the drop indicator's width - // is an even number of pixels, whereas the border between - // table cells is an odd number of pixels. So this makes the - // positioning slightly more consistent regardless of where - // the column is being dropped. - if (newIndex > originalIndex) { - widget.style.right = "-2px"; - } else { - widget.style.left = "-3px"; - } - widget.style.width = "4px"; - - return widget; - }), - ); - }); - } - - return DecorationSet.create(state.doc, decorations); }, }, }),