diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..ea467020db 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,30 @@ } /* 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; + /* `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; } -.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..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; @@ -69,32 +67,123 @@ 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 buildTableDragImage( + editorElement: HTMLElement, + tableElement: HTMLTableElement, + cells: RelativeCellIndices[], + orientation: "row" | "col", +) { + 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); + } } - 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); + // 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(" "); + + const dragImageElement = document.createElement("div"); + 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"; } -} -function unsetHiddenDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement) { - if (rootEl instanceof Document) { - rootEl.body.removeChild(dragImageElement); - } else { - rootEl.removeChild(dragImageElement); - } - dragImageElement = undefined; - } + (editorElement.parentElement ?? editorElement).appendChild(dragImageElement); + + return dragImageElement; } function getChildIndex(node: Element) { @@ -152,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"; @@ -621,7 +714,34 @@ 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. + this.unsetDragImage(); + this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler); window.removeEventListener("mouseup", this.mouseUpHandler); this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); @@ -643,6 +763,37 @@ 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 = view.setDragImage( + 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 +815,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 +838,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 +893,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 +1034,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 +1073,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 +1097,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - unsetHiddenDragImage(editor.prosemirrorView.root); + 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({ 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" }]); + }, + ); });