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