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..7d20626339 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) { @@ -596,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); @@ -610,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 }) => { @@ -617,6 +883,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 +936,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,135 +950,16 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - const newIndex = - view.state.draggingState.draggedCellOrientation === "row" - ? view.state.rowIndex - : view.state.colIndex; - - if (newIndex === undefined) { + try { + 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; } - - const decorations: Decoration[] = []; - const { block, draggingState } = view.state; - const { originalIndex, draggedCellOrientation } = draggingState; - - // Return empty decorations if: - // - Dragging to same position - // - No block exists - // - Row drag not allowed - // - Column drag not allowed - if ( - newIndex === originalIndex || - !block || - (draggedCellOrientation === "row" && - !canRowBeDraggedInto(block, originalIndex, newIndex)) || - (draggedCellOrientation === "col" && - !canColumnBeDraggedInto(block, originalIndex, newIndex)) - ) { - 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") { - 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); }, }, }), @@ -824,8 +1004,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 +1043,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 +1067,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" }]); + }, + ); });