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" }]);
+ },
+ );
});