From 952b34a9254f0c8192c5633d9860916b7fa5de25 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Mon, 17 Aug 2026 21:57:13 +0300 Subject: [PATCH 1/3] 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 | 236 ++++++++++++++---- tests/src/end-to-end/tables/tables.test.tsx | 204 +++++++++++++++ 3 files changed, 436 insertions(+), 48 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 bb396fbdd7..2291984eff 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -69,32 +69,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) { @@ -643,6 +741,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, @@ -664,8 +794,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 ( @@ -686,27 +817,53 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - const newIndex = - view.state.draggingState.draggedCellOrientation === "row" - ? view.state.rowIndex - : view.state.colIndex; - - if (newIndex === undefined) { - return; - } - const decorations: Decoration[] = []; const { block, draggingState } = view.state; const { originalIndex, draggedCellOrientation } = draggingState; - // Return empty decorations if: + if (!block) { + return DecorationSet.create(state.doc, decorations); + } + + // Gets the table to show the decorations in. + const tableResolvedPos = state.doc.resolve(tablePos + 1); + + // 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 - // - 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" && @@ -715,10 +872,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(tablePos + 1); - - if (view.state.draggingState.draggedCellOrientation === "row") { + if (draggedCellOrientation === "row") { const cellsInRow = getCellsAtRowHandle( view.state.block, newIndex, @@ -859,8 +1013,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"; }, @@ -899,8 +1052,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"; }, @@ -924,7 +1076,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 b7d7147679f5b80a4f6696026450209e78bdf536 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Thu, 20 Aug 2026 12:59:15 +0300 Subject: [PATCH 2/3] fix(core): clear the table drag image when the view is destroyed `unsetTableDragImage()` only ran from `setTableDragImage` and `dragEnd()`, and `dragEnd()` throws before reaching it when `view.state` is undefined. If the editor is torn down mid-drag the copy is left in the DOM with the module-scope reference still set. Raised in review on #2920 (r3798069357). The stale-position guard from the same review is not carried over: #2972 fixed the underlying issue (#2921) by resolving the table position against the document being rendered, so `decorations` can no longer be handed a stale one. Co-Authored-By: Claude Opus 5 --- packages/core/src/extensions/TableHandles/TableHandles.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 2291984eff..7181429516 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -720,6 +720,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); From 510038418ee48d018dce5223ec1f3d85532ff6ae Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Thu, 20 Aug 2026 13:36:38 +0300 Subject: [PATCH 3/3] fix(core): scope the table drag image to the view that owns it Addresses review on #2920 (r-4981596549). `dragImageElement` was module state, so the `destroy()` cleanup added in the previous commit removed the drag image for *any* editor rather than the one being destroyed. With more than one editor on a page - or a nested editor inside a custom block - unmounting one would have cleared a preview that another editor was still dragging. The cleanup introduced that; the module scope predates it. The element is now a private field on `TableHandlesView`, with `setDragImage` and `unsetDragImage` methods owning its lifecycle, and `buildTableDragImage` left as a pure builder. Also replaces the deprecated `word-break: break-word` on the table rule. The earlier suggestion of `overflow-wrap: break-word` was declined because it changes the min-content contribution that table column sizing depends on; `word-break: normal` + `overflow-wrap: anywhere` is what the deprecated value is defined to mean, so it carries that effect and is an exact swap. The drag-image width test covers the column sizing either way. Co-Authored-By: Claude Opus 5 --- packages/core/src/editor/editor.css | 5 +- .../extensions/TableHandles/TableHandles.ts | 47 +++++++++++++------ packages/core/src/fonts/inter.css | 18 +++---- .../y/extensions/AttributionExtension.test.ts | 17 ++++--- 4 files changed, 53 insertions(+), 34 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 75cd4169b5..ea467020db 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -181,7 +181,10 @@ 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; + /* `word-break: break-word` is deprecated; it is defined as exactly this pair, + including the effect on min-content size that the table layout depends on. */ + word-break: normal; + overflow-wrap: anywhere; } :is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] th, :is(.bn-editor, .bn-table-drag-preview) [data-content-type="table"] td { diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 7181429516..390b470e3d 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -44,8 +44,6 @@ import { } from "../../schema/index.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; -let dragImageElement: HTMLElement | undefined; - // TODO consider switching this to jotai, it is a bit messy and noisy export type TableHandlesState = { show: boolean; @@ -80,14 +78,12 @@ export type TableHandlesState = { * whichever theme/colour scheme the editor is nested in - applies to the drag * image exactly as it does to the real table. */ -function setTableDragImage( +function buildTableDragImage( 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 @@ -166,7 +162,7 @@ function setTableDragImage( ) .join(" "); - dragImageElement = document.createElement("div"); + const dragImageElement = document.createElement("div"); dragImageElement.className = `${inheritedClasses} bn-table-drag-preview`; if (tbody.childElementCount > 0) { @@ -190,11 +186,6 @@ function setTableDragImage( return dragImageElement; } -function unsetTableDragImage() { - dragImageElement?.remove(); - dragImageElement = undefined; -} - function getChildIndex(node: Element) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } @@ -250,6 +241,10 @@ export class TableHandlesView implements PluginView { public tablePos: number | undefined; public tableElement: HTMLElement | undefined; + // Owned per view rather than per module: a page can hold several editors, so + // tearing one down must not remove a drag image belonging to another. + private dragImageElement: HTMLElement | undefined; + public menuFrozen = false; public mouseState: "up" | "down" | "selecting" = "up"; @@ -719,10 +714,33 @@ export class TableHandlesView implements PluginView { this.emitUpdate(); } + // Replaces the browser's default drag image (which would be the drag handle + // itself) with a copy of the row/column being dragged. + setDragImage( + tableElement: HTMLTableElement, + cells: RelativeCellIndices[], + orientation: "row" | "col", + ) { + this.unsetDragImage(); + this.dragImageElement = buildTableDragImage( + this.pmView.dom as HTMLElement, + tableElement, + cells, + orientation, + ); + + return this.dragImageElement; + } + + unsetDragImage() { + this.dragImageElement?.remove(); + this.dragImageElement = undefined; + } + destroy() { // The drag image is normally cleaned up on `dragEnd`, which never arrives // if the editor is torn down mid-drag. - unsetTableDragImage(); + this.unsetDragImage(); this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler); window.removeEventListener("mouseup", this.mouseUpHandler); @@ -757,8 +775,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - const dragImage = setTableDragImage( - editor.prosemirrorView.dom as HTMLElement, + const dragImage = view.setDragImage( tableElement, orientation === "row" ? getCellsAtRowHandle(view.state.block, index) @@ -1080,7 +1097,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - unsetTableDragImage(); + view!.unsetDragImage(); }, /** diff --git a/packages/core/src/fonts/inter.css b/packages/core/src/fonts/inter.css index 57337cdd50..6e152551bf 100644 --- a/packages/core/src/fonts/inter.css +++ b/packages/core/src/fonts/inter.css @@ -9,7 +9,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-200 - latin */ @font-face { @@ -20,7 +20,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-300 - latin */ @font-face { @@ -31,7 +31,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-regular - latin */ @font-face { @@ -42,7 +42,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-regular.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-500 - latin */ @font-face { @@ -53,7 +53,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-600 - latin */ @font-face { @@ -64,7 +64,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-700 - latin */ @font-face { @@ -75,7 +75,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-800 - latin */ @font-face { @@ -86,7 +86,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-900 - latin */ @font-face { @@ -97,5 +97,5 @@ local(""), url("./inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index df0267f093..f752b48182 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -17,15 +17,14 @@ const editors: BlockNoteEditor[] = []; // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. function createEditor() { - const resolveUsers = vi.fn( - async (ids: string[]): Promise => - ids.map((id) => ({ - id, - username: `name-${id}`, - avatarUrl: "", - color: "#123456", - colorLight: "#abcdef", - })), + const resolveUsers = vi.fn(async (ids: string[]): Promise => + ids.map((id) => ({ + id, + username: `name-${id}`, + avatarUrl: "", + color: "#123456", + colorLight: "#abcdef", + })), ); const editor = BlockNoteEditor.create({