diff --git a/docs/content/docs/react/styling-theming/overriding-css.mdx b/docs/content/docs/react/styling-theming/overriding-css.mdx
index bece286f4d..b67823807e 100644
--- a/docs/content/docs/react/styling-theming/overriding-css.mdx
+++ b/docs/content/docs/react/styling-theming/overriding-css.mdx
@@ -36,6 +36,13 @@ BlockNote uses classes with the `bn-` prefix to style editor elements. Here are
- `.bn-drag-handle-menu`: Drag handle menu.
- `.bn-suggestion-menu`: Suggestion menu.
+#### Table Row & Column Dragging
+
+- `.bn-table-handle`: Row & column drag handles.
+- `.bn-table-drag-source-row` / `.bn-table-drag-source-col`: Every cell of the row/column being dragged.
+- `.bn-table-drop-cursor`: Bar marking the edge the row/column would be dropped at.
+- `.bn-table-drag-preview`: Snapshot of the row/column shown next to the cursor. Rendered outside the editor, so selectors scoped to `.bn-editor` won't match it.
+
### BlockNote CSS Attributes
BlockNote uses data attributes to target specific block types and properties:
diff --git a/examples/03-ui-components/21-table-reordering-visualization/.bnexample.json b/examples/03-ui-components/21-table-reordering-visualization/.bnexample.json
new file mode 100644
index 0000000000..4188fc0d80
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/.bnexample.json
@@ -0,0 +1,12 @@
+{
+ "playground": true,
+ "docs": false,
+ "author": "must",
+ "tags": [
+ "Intermediate",
+ "UI Components",
+ "Tables",
+ "Drag & Drop",
+ "Appearance & Styling"
+ ]
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/README.md b/examples/03-ui-components/21-table-reordering-visualization/README.md
new file mode 100644
index 0000000000..860942a982
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/README.md
@@ -0,0 +1,55 @@
+# Table Reordering Visualization
+
+BlockNote gives table row/column dragging visual feedback out of the box: a
+snapshot of the row/column follows the cursor, the row/column being dragged is
+tinted and outlined, and a drop indicator marks where it would land.
+
+This example shows how to restyle a table - and those built-in drag
+affordances - to match your own product, using a Microsoft Loop-inspired look:
+
+- **Restyled tables**: rounded card look, muted header row, hairline
+ borders, and a row-hover highlight instead of a harsh black grid.
+- **Retuned drag affordances**: the built-in drag source highlight, drop
+ indicator and drag snapshot recolored to the same palette.
+- **Header row by default**: the `/table` command starts new tables with
+ a header row already enabled, so the header styling is visible right away.
+
+## How It Works
+
+Everything here is CSS plus one slash-menu tweak - no extensions, no event
+handling. BlockNote's `TableHandlesExtension` owns the whole drag lifecycle and
+exposes it through classes you can target:
+
+| Class | What it's on |
+| -------------------------- | ---------------------------------------------- |
+| `bn-table-drag-source-row` | every cell of the row being dragged |
+| `bn-table-drag-source-col` | every cell of the column being dragged |
+| `bn-table-drop-cursor` | a bar on the edge the row/column would drop at |
+| `bn-table-drag-preview` | the snapshot shown next to the cursor |
+
+The first three are ProseMirror decorations inside the editor, so they're
+scoped under `.bn-editor [data-content-type="table"]` like any other table
+style. `bn-table-drag-preview` is different: it's appended outside the editor
+(the browser can only use an attached element as a drag image), so it has to be
+styled through its own class rather than through the table selectors.
+
+`tableStyles.css` does the restyling; `App.tsx` overrides the default `/table`
+slash-menu item so new tables start with `headerRows: 1`.
+
+## Known Limitations
+
+- **Keyboard and touch**: BlockNote's table drag handles are `draggable` +
+ `onDragStart` only today (see `TableHandle.tsx`) - there's no
+ keyboard-operable reorder path, and native HTML5 drag-and-drop isn't
+ supported on touch browsers at all. Both are gaps in BlockNote's table-drag
+ feature as a whole, not something this example introduces or fixes.
+- **Accessibility**: for the same reason, there's no keyboard focus
+ restoration to verify after a reorder - the interaction can't be reached by
+ keyboard in the first place yet.
+
+**Relevant Docs:**
+
+- [Tables](/docs/features/blocks/tables)
+- [Overriding CSS](/docs/react/styling-theming/overriding-css)
+- [Editor Setup](/docs/getting-started/editor-setup)
+- [Slash Menu](/docs/react/components/suggestion-menus)
diff --git a/examples/03-ui-components/21-table-reordering-visualization/index.html b/examples/03-ui-components/21-table-reordering-visualization/index.html
new file mode 100644
index 0000000000..24dca1c75e
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Table Reordering Visualization
+
+
+
+
+
+
+
diff --git a/examples/03-ui-components/21-table-reordering-visualization/main.tsx b/examples/03-ui-components/21-table-reordering-visualization/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/03-ui-components/21-table-reordering-visualization/package.json b/examples/03-ui-components/21-table-reordering-visualization/package.json
new file mode 100644
index 0000000000..c2e5c9198b
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@blocknote/example-ui-components-table-reordering-visualization",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vp dev",
+ "dev": "vp dev",
+ "build:prod": "tsc && vp build",
+ "preview": "vp preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite-plus": "catalog:"
+ }
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx
new file mode 100644
index 0000000000..2ad9e61f5f
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx
@@ -0,0 +1,86 @@
+import { BlockNoteEditor } from "@blocknote/core";
+import {
+ filterSuggestionItems,
+ insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ DefaultReactSuggestionItem,
+ getDefaultReactSlashMenuItems,
+ SuggestionMenuController,
+ useCreateBlockNote,
+} from "@blocknote/react";
+
+import "./tableStyles.css";
+
+// BlockNote's stock "/table" item inserts a table with no header row, so it
+// never picks up the header styling until someone manually toggles it on.
+// This swaps in a version that starts with `headerRows: 1` instead.
+const getCustomSlashMenuItems = (
+ editor: BlockNoteEditor,
+): DefaultReactSuggestionItem[] =>
+ getDefaultReactSlashMenuItems(editor).map((item) => {
+ // `key` is typed away on the React item (it's reserved for JSX), but the
+ // underlying object - built from the same items core uses - still has it.
+ const key = (item as unknown as { key?: string }).key;
+ if (key !== "table") {
+ return item;
+ }
+ return {
+ ...item,
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "table",
+ content: {
+ type: "tableContent",
+ headerRows: 1,
+ rows: [{ cells: ["", "", ""] }, { cells: ["", "", ""] }],
+ } as any,
+ }),
+ };
+ });
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ tables: {
+ splitCells: true,
+ cellBackgroundColor: true,
+ cellTextColor: true,
+ headers: true,
+ },
+ initialContent: [
+ {
+ type: "heading",
+ props: { level: 2 },
+ content: "Restyling BlockNote.js Table Reordering",
+ },
+ {
+ type: "table",
+ content: {
+ type: "tableContent",
+ columnWidths: [180, 140, 140, 220],
+ headerRows: 1,
+ rows: [
+ { cells: ["Column A", "Column B", "Column C", "Column D"] },
+ { cells: ["1a", "1b", "1c", "1d"] },
+ { cells: ["2a", "2b", "2c", "2d"] },
+ { cells: ["3a", "3b", "3c", "3d"] },
+ ],
+ },
+ },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(getCustomSlashMenuItems(editor), query)
+ }
+ />
+
+ );
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css
new file mode 100644
index 0000000000..7c70fde262
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css
@@ -0,0 +1,124 @@
+/**
+ * Palette
+ *
+ * Declared on `.bn-root` rather than on the table, for two reasons: the drag
+ * handles render in a portal outside the editor, and so does the drag
+ * snapshot - BlockNote puts `bn-root` and the active color scheme on both, so
+ * anything defined here reaches them.
+ */
+.bn-root {
+ --table-border: #e2e2ea;
+ --table-shadow: rgb(0 0 0 / 6%);
+ --table-header-bg: #f0f0f3;
+ --table-header-text: #5d5d70;
+ --table-row-hover: #d3d4e0;
+ --table-accent: #4a48b8;
+ --table-handle-hover-bg: #eef1fa;
+ --table-handle-hover-text: #5e5cd0;
+ /* Kept translucent so they read as a wash over the cell's own background
+ rather than replacing it - a flat fill light enough for this palette would
+ blot out the text in dark mode. */
+ --table-drag-tint: rgb(94 92 208 / 14%);
+ --table-drag-outline: rgb(94 92 208 / 45%);
+ --table-selected: rgb(94 92 208 / 18%);
+}
+
+.bn-root[data-color-scheme="dark"] {
+ --table-border: #3b3b46;
+ --table-shadow: rgb(0 0 0 / 40%);
+ --table-header-bg: #2c2c35;
+ --table-header-text: #a6a6bd;
+ --table-row-hover: #35353f;
+ /* The light-mode accent is a dark purple, which all but disappears against
+ the dark editor background. */
+ --table-accent: #9391ff;
+ --table-handle-hover-bg: #35354a;
+ --table-handle-hover-text: #b3b1ff;
+ --table-drag-tint: rgb(147 145 255 / 22%);
+ --table-drag-outline: rgb(147 145 255 / 55%);
+ --table-selected: rgb(147 145 255 / 25%);
+}
+
+/**
+ * Tables
+ * Loop/Notion-style card look: rounded outer border, muted header row,
+ * hairline internal grid and a hover highlight instead of the default
+ * harsh black grid lines.
+ */
+.bn-editor [data-content-type="table"] table {
+ border-collapse: separate;
+ border-spacing: 0;
+ border: 1px solid var(--table-border);
+ border-radius: 8px;
+ box-shadow: 0 1px 3px var(--table-shadow);
+ overflow: hidden;
+}
+.bn-editor [data-content-type="table"] th,
+.bn-editor [data-content-type="table"] td {
+ border: none;
+ border-right: 1px solid var(--table-border);
+ border-bottom: 1px solid var(--table-border);
+ padding: 10px 16px;
+ transition: background-color 0.15s ease;
+}
+.bn-editor [data-content-type="table"] th:last-child,
+.bn-editor [data-content-type="table"] td:last-child {
+ border-right: none;
+}
+.bn-editor [data-content-type="table"] tr:last-child th,
+.bn-editor [data-content-type="table"] tr:last-child td {
+ border-bottom: none;
+}
+.bn-editor [data-content-type="table"] th {
+ background-color: var(--table-header-bg);
+ color: var(--table-header-text);
+ font-weight: 600;
+ font-size: 0.8125em;
+ letter-spacing: 0.01em;
+}
+.bn-editor [data-content-type="table"] tr:hover > td {
+ background-color: var(--table-row-hover);
+}
+.bn-editor [data-content-type="table"] .selectedCell:after {
+ background: var(--table-selected);
+ opacity: 1;
+}
+
+/**
+ * Row/column reordering: BlockNote already highlights the row/column being
+ * dragged and marks the drop position; these just retune the built-in
+ * affordances to the palette above, so the drag state stays distinguishable
+ * from this table's own hover and selection colors.
+ */
+.bn-editor [data-content-type="table"] td.bn-table-drag-source-row,
+.bn-editor [data-content-type="table"] th.bn-table-drag-source-row,
+.bn-editor [data-content-type="table"] td.bn-table-drag-source-col,
+.bn-editor [data-content-type="table"] th.bn-table-drag-source-col {
+ background-color: var(--table-drag-tint);
+ outline-color: var(--table-drag-outline);
+}
+.bn-editor [data-content-type="table"] .bn-table-drop-cursor {
+ background-color: var(--table-accent);
+}
+
+/* The drag snapshot is rendered outside `.bn-editor`, so it's styled through
+ its own class rather than the table selectors above. */
+.bn-table-drag-preview th,
+.bn-table-drag-preview td {
+ border-color: var(--table-accent);
+ padding: 10px 16px;
+}
+
+/* Row/column drag handles and add-row/add-column buttons. */
+.bn-mantine .bn-table-handle,
+.bn-mantine .bn-table-cell-handle {
+ border-radius: 2px;
+}
+.bn-mantine .bn-table-handle:hover,
+.bn-mantine .bn-table-handle-dragging,
+.bn-mantine .bn-table-cell-handle:hover,
+.bn-mantine .bn-extend-button:hover,
+.bn-mantine .bn-extend-button-editing {
+ background-color: var(--table-handle-hover-bg);
+ color: var(--table-handle-hover-text);
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/src/vite-env.d.ts b/examples/03-ui-components/21-table-reordering-visualization/src/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/03-ui-components/21-table-reordering-visualization/tsconfig.json b/examples/03-ui-components/21-table-reordering-visualization/tsconfig.json
new file mode 100644
index 0000000000..93fa81bee8
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/vite-env.d.ts b/examples/03-ui-components/21-table-reordering-visualization/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts
new file mode 100644
index 0000000000..0133a6da9e
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts
@@ -0,0 +1,31 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite-plus";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css
index a1a3dda7b0..c501c00016 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -58,13 +58,55 @@
-moz-osx-font-smoothing: grayscale;
}
+/* Marks the edge a dragged table row/column would be dropped at. */
.bn-table-drop-cursor {
position: absolute;
z-index: 20;
background-color: #adf;
+ border-radius: 2px;
pointer-events: none;
}
+/* Marks the table row/column currently being dragged. */
+.bn-editor [data-content-type="table"] .bn-table-drag-source-row,
+.bn-editor [data-content-type="table"] .bn-table-drag-source-col {
+ /* Translucent rather than a flat tint, so it reads as a wash over whatever
+ the cell's own background is - including a dark theme, where an opaque
+ light fill would blot out the text. */
+ background-color: rgb(170 221 255 / 22%);
+ /* An outline rather than a border, so it doesn't shift the cell's contents,
+ and inset so adjacent cells in the dragged row/column don't double it up. */
+ outline: 1.5px dashed #adf;
+ outline-offset: -1.5px;
+}
+
+/* Snapshot of the row/column being dragged, shown next to the cursor. Built in
+ `TableHandles/dragPreview.ts`, and appended outside the editor - so it can't
+ rely on any of the `.bn-editor` scoped table styling above. */
+.bn-table-drag-preview table {
+ border-collapse: separate;
+ border-spacing: 0;
+ background: var(--bn-colors-editor-background, #fff);
+ color: var(--bn-colors-editor-text, inherit);
+ border-radius: 8px;
+ box-shadow:
+ 0 8px 24px rgb(0 0 0 / 18%),
+ 0 2px 6px rgb(0 0 0 / 10%);
+ overflow: hidden;
+}
+
+.bn-table-drag-preview th,
+.bn-table-drag-preview td {
+ border: 2px solid #adf;
+ padding: 5px 10px;
+ box-shadow: 0 1px 4px rgb(0 0 0 / 12%);
+}
+
+.bn-table-drag-preview th {
+ font-weight: bold;
+ text-align: left;
+}
+
.bn-drag-preview {
position: absolute;
top: 0;
diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts
index 25d09380f1..75591d0a44 100644
--- a/packages/core/src/extensions/TableHandles/TableHandles.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandles.ts
@@ -10,7 +10,7 @@ import {
mergeCells,
splitCell,
} from "prosemirror-tables";
-import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
+import { DecorationSet, EditorView } from "prosemirror-view";
import {
RelativeCellIndices,
addRowsOrColumns,
@@ -42,6 +42,8 @@ import {
BlockSchemaWithBlock,
} from "../../schema/index.js";
import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import { getTableDragDecorations } from "./dragDecorations.js";
+import { setTableDragImage, unsetTableDragImage } from "./dragPreview.js";
let dragImageElement: HTMLElement | undefined;
@@ -96,6 +98,38 @@ function unsetHiddenDragImage(rootEl: Document | ShadowRoot) {
}
}
+// Sets the image shown next to the cursor while dragging a table row or column
+// to a snapshot of that row/column. Falls back to the hidden 1x1 image if the
+// snapshot can't be built, since leaving the drag image unset makes the browser
+// fill in its own - a ghost of the entire editor.
+function setDragImage(
+ editor: BlockNoteEditor,
+ view: TableHandlesView,
+ cells: RelativeCellIndices[],
+ orientation: "row" | "col",
+ dataTransfer: DataTransfer,
+) {
+ const dragImage = view.tableElement
+ ? setTableDragImage(
+ editor.prosemirrorView,
+ view.tableElement,
+ cells,
+ orientation,
+ )
+ : undefined;
+
+ if (dragImage) {
+ // Offset so the snapshot trails the cursor instead of sitting centered
+ // under it, which would hide the cell being pointed at.
+ dataTransfer.setDragImage(dragImage, 16, 16);
+
+ return;
+ }
+
+ setHiddenDragImage(editor.prosemirrorView.root);
+ dataTransfer.setDragImage(dragImageElement!, 0, 0);
+}
+
function getChildIndex(node: Element) {
return Array.prototype.indexOf.call(node.parentElement!.childNodes, node);
}
@@ -609,6 +643,10 @@ export class TableHandlesView implements PluginView {
"drop",
this.dropHandler as unknown as EventListener,
);
+
+ // The drag image is normally cleaned up on `dragEnd`, which won't fire if
+ // the editor unmounts mid-drag.
+ unsetTableDragImage();
}
}
@@ -640,148 +678,40 @@ 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 at the position it would be dropped into.
+ // The decorations are updated in the `dragOverHandler` method.
props: {
decorations: (state) => {
if (
view === undefined ||
view.state === undefined ||
view.state.draggingState === undefined ||
- view.tablePos === undefined
+ view.tablePos === undefined ||
+ !view.state.block
) {
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:
- // - 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);
+ const { draggedCellOrientation, originalIndex } =
+ view.state.draggingState;
- 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(
+ return DecorationSet.create(
+ state.doc,
+ getTableDragDecorations(
+ state.doc,
+ view.tablePos,
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);
+ {
+ draggedCellOrientation,
+ originalIndex,
+ newIndex:
+ draggedCellOrientation === "row"
+ ? view.state.rowIndex
+ : view.state.colIndex,
+ },
+ ),
+ );
},
},
}),
@@ -805,9 +735,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
);
}
+ const originalIndex = view.state.colIndex;
+
view.state.draggingState = {
draggedCellOrientation: "col",
- originalIndex: view.state.colIndex,
+ originalIndex,
mousePos: event.clientX,
};
view.emitUpdate();
@@ -826,8 +758,13 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
return;
}
- setHiddenDragImage(editor.prosemirrorView.root);
- event.dataTransfer!.setDragImage(dragImageElement!, 0, 0);
+ setDragImage(
+ editor,
+ view,
+ getCellsAtColumnHandle(view.state.block, originalIndex),
+ "col",
+ event.dataTransfer!,
+ );
event.dataTransfer!.effectAllowed = "move";
},
@@ -845,9 +782,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
);
}
+ const originalIndex = view!.state.rowIndex;
+
view!.state.draggingState = {
draggedCellOrientation: "row",
- originalIndex: view!.state.rowIndex,
+ originalIndex,
mousePos: event.clientY,
};
view!.emitUpdate();
@@ -866,8 +805,13 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
return;
}
- setHiddenDragImage(editor.prosemirrorView.root);
- event.dataTransfer!.setDragImage(dragImageElement!, 0, 0);
+ setDragImage(
+ editor,
+ view!,
+ getCellsAtRowHandle(view!.state.block, originalIndex),
+ "row",
+ event.dataTransfer!,
+ );
event.dataTransfer!.effectAllowed = "copyMove";
},
@@ -892,6 +836,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
}
unsetHiddenDragImage(editor.prosemirrorView.root);
+ unsetTableDragImage();
},
/**
diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.test.ts b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts
new file mode 100644
index 0000000000..e830ff5a41
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts
@@ -0,0 +1,320 @@
+import { Decoration } from "prosemirror-view";
+import { describe, expect, it } from "vite-plus/test";
+
+import { getNodeById } from "../../api/nodeUtil.js";
+import type { PartialBlock } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { TableDragState, getTableDragDecorations } from "./dragDecorations.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+/**
+ * | 1-1 | 1-2 | 1-3 |
+ * | 2-1 | 2-2 | 2-3 |
+ * | 3-1 | 3-2 | 3-3 |
+ */
+const simpleTable: PartialBlock[] = [
+ {
+ id: "table-0",
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [
+ { cells: ["1-1", "1-2", "1-3"] },
+ { cells: ["2-1", "2-2", "2-3"] },
+ { cells: ["3-1", "3-2", "3-3"] },
+ ],
+ },
+ },
+];
+
+const cell = (text: string, colspan = 1, rowspan = 1) =>
+ ({
+ type: "tableCell",
+ props: { colspan, rowspan },
+ content: text,
+ }) as any;
+
+/**
+ * | 1-1 | 1-2 |
+ * | 2-1 | 2-2 | 2-3 |
+ * "1-2" spans two columns.
+ */
+const colspanTable: PartialBlock[] = [
+ {
+ id: "table-0",
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [
+ { cells: [cell("1-1"), cell("1-2", 2)] },
+ { cells: [cell("2-1"), cell("2-2"), cell("2-3")] },
+ ],
+ },
+ },
+];
+
+/**
+ * | 1-1 | 1-2 | 1-3 |
+ * | 2-1 | | 2-3 |
+ * "1-2" spans two rows.
+ */
+const rowspanTable: PartialBlock[] = [
+ {
+ id: "table-0",
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [
+ { cells: [cell("1-1"), cell("1-2", 1, 2), cell("1-3")] },
+ { cells: [cell("2-1"), cell("2-3")] },
+ ],
+ },
+ },
+];
+
+function setup(initialContent: PartialBlock[]) {
+ const editor = BlockNoteEditor.create({ initialContent });
+ const doc = editor.prosemirrorState.doc;
+ const block = editor.getBlock("table-0")! as any;
+ const tablePos = getNodeById("table-0", doc)!.posBeforeNode + 1;
+
+ const decorationsFor = (dragState: TableDragState) =>
+ getTableDragDecorations(doc, tablePos, block, dragState);
+
+ return { editor, doc, block, tablePos, decorationsFor };
+}
+
+// Node decorations span the cell they highlight; widget decorations are a
+// single point.
+const sourceDecorations = (decorations: Decoration[]) =>
+ decorations.filter((decoration) => decoration.from !== decoration.to);
+const dropCursors = (decorations: Decoration[]) =>
+ decorations.filter((decoration) => decoration.from === decoration.to);
+
+// `Decoration.type` isn't part of prosemirror-view's public typings.
+const typeOf = (decoration: Decoration) => (decoration as any).type;
+
+const classesOf = (decorations: Decoration[]) =>
+ decorations.map((decoration) => typeOf(decoration).attrs?.class);
+
+describe("getTableDragDecorations", () => {
+ describe("source highlight", () => {
+ it("highlights every cell of the dragged row", () => {
+ const { decorationsFor, doc } = setup(simpleTable);
+
+ const source = sourceDecorations(
+ decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 1,
+ newIndex: 2,
+ }),
+ );
+
+ expect(classesOf(source)).toEqual([
+ "bn-table-drag-source-row",
+ "bn-table-drag-source-row",
+ "bn-table-drag-source-row",
+ ]);
+ expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([
+ "2-1",
+ "2-2",
+ "2-3",
+ ]);
+ });
+
+ it("highlights every cell of the dragged column", () => {
+ const { decorationsFor, doc } = setup(simpleTable);
+
+ const source = sourceDecorations(
+ decorationsFor({
+ draggedCellOrientation: "col",
+ originalIndex: 1,
+ newIndex: 0,
+ }),
+ );
+
+ expect(classesOf(source)).toEqual([
+ "bn-table-drag-source-col",
+ "bn-table-drag-source-col",
+ "bn-table-drag-source-col",
+ ]);
+ expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([
+ "1-2",
+ "2-2",
+ "3-2",
+ ]);
+ });
+
+ // The drag image is set on `dragstart`, but the first `dragover` (which is
+ // what produces a target index) doesn't arrive until the cursor moves.
+ it("is shown before the drag has been over the table", () => {
+ const { decorationsFor } = setup(simpleTable);
+
+ const decorations = decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 0,
+ newIndex: undefined,
+ });
+
+ expect(sourceDecorations(decorations)).toHaveLength(3);
+ expect(dropCursors(decorations)).toHaveLength(0);
+ });
+
+ it("is shown when hovering the row's own position", () => {
+ const { decorationsFor } = setup(simpleTable);
+
+ const decorations = decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 1,
+ newIndex: 1,
+ });
+
+ expect(sourceDecorations(decorations)).toHaveLength(3);
+ expect(dropCursors(decorations)).toHaveLength(0);
+ });
+ });
+
+ describe("drop cursor", () => {
+ it("is rendered across the target row", () => {
+ const { decorationsFor } = setup(simpleTable);
+
+ expect(
+ dropCursors(
+ decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 0,
+ newIndex: 2,
+ }),
+ ),
+ ).toHaveLength(3);
+ });
+
+ it("is rendered down the target column", () => {
+ const { decorationsFor } = setup(simpleTable);
+
+ expect(
+ dropCursors(
+ decorationsFor({
+ draggedCellOrientation: "col",
+ originalIndex: 2,
+ newIndex: 0,
+ }),
+ ),
+ ).toHaveLength(3);
+ });
+
+ it("renders a bar spanning the cell", () => {
+ const { decorationsFor } = setup(simpleTable);
+
+ const [widget] = dropCursors(
+ decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 0,
+ newIndex: 1,
+ }),
+ );
+ const element = typeOf(widget).toDOM(
+ null,
+ () => widget.from,
+ ) as HTMLElement;
+
+ expect(element.className).toBe("bn-table-drop-cursor");
+ expect(element.style.height).toBe("4px");
+ // Dropping below the original position, so the bar sits on the bottom
+ // edge of the target row.
+ expect(element.style.bottom).toBe("-2px");
+ });
+ });
+
+ describe("merged cells", () => {
+ it("highlights the cell spanning the dragged column", () => {
+ const { decorationsFor, doc } = setup(colspanTable);
+
+ const source = sourceDecorations(
+ decorationsFor({
+ draggedCellOrientation: "col",
+ originalIndex: 1,
+ newIndex: undefined,
+ }),
+ );
+
+ // The spanning cell is part of both column 1 and column 2, so dragging
+ // column 1 highlights it alongside the regular cell below.
+ expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([
+ "1-2",
+ "2-2",
+ ]);
+ });
+
+ it("highlights the cell spanning the dragged row", () => {
+ const { decorationsFor, doc } = setup(rowspanTable);
+
+ const source = sourceDecorations(
+ decorationsFor({
+ draggedCellOrientation: "row",
+ originalIndex: 1,
+ newIndex: undefined,
+ }),
+ );
+
+ expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([
+ "2-1",
+ "1-2",
+ "2-3",
+ ]);
+ });
+
+ it("omits the drop cursor when the column can't be dropped there", () => {
+ const { decorationsFor } = setup(colspanTable);
+
+ const decorations = decorationsFor({
+ draggedCellOrientation: "col",
+ originalIndex: 0,
+ newIndex: 1,
+ });
+
+ // Dropping column 0 into the middle of the column-spanning cell would
+ // tear it in half, so the move is blocked and only the source highlight
+ // is shown.
+ expect(sourceDecorations(decorations).length).toBeGreaterThan(0);
+ expect(dropCursors(decorations)).toHaveLength(0);
+ });
+ });
+
+ describe("stale table position", () => {
+ // `tablePos` is captured on mousemove, which doesn't fire during a native
+ // drag - so a concurrent edit elsewhere in the document can leave it
+ // pointing past the end of the doc, or at some other node.
+ it("returns no decorations when the position is out of range", () => {
+ const { doc, block } = setup(simpleTable);
+
+ expect(
+ getTableDragDecorations(doc, doc.content.size + 100, block, {
+ draggedCellOrientation: "row",
+ originalIndex: 0,
+ newIndex: 1,
+ }),
+ ).toEqual([]);
+ });
+
+ it("returns no decorations when the position isn't a table", () => {
+ const { doc, block } = setup([
+ { id: "paragraph-0", type: "paragraph", content: "Hello" },
+ ...simpleTable,
+ ]);
+
+ const paragraphPos = getNodeById("paragraph-0", doc)!.posBeforeNode + 1;
+
+ expect(
+ getTableDragDecorations(doc, paragraphPos, block, {
+ draggedCellOrientation: "row",
+ originalIndex: 0,
+ newIndex: 1,
+ }),
+ ).toEqual([]);
+ });
+ });
+});
diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.ts b/packages/core/src/extensions/TableHandles/dragDecorations.ts
new file mode 100644
index 0000000000..045984c6a7
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragDecorations.ts
@@ -0,0 +1,169 @@
+import type { Node, ResolvedPos } from "prosemirror-model";
+import { Decoration } from "prosemirror-view";
+
+import {
+ RelativeCellIndices,
+ canColumnBeDraggedInto,
+ canRowBeDraggedInto,
+ getCellsAtColumnHandle,
+ getCellsAtRowHandle,
+} from "../../api/blockManipulation/tables/tables.js";
+import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js";
+import { BlockFromConfigNoChildren } from "../../schema/index.js";
+
+/** Marks each cell of the row being dragged. */
+export const DRAG_SOURCE_ROW_CLASS = "bn-table-drag-source-row";
+/** Marks each cell of the column being dragged. */
+export const DRAG_SOURCE_COL_CLASS = "bn-table-drag-source-col";
+/** Marks the edge the dragged row/column would be dropped at. */
+export const DROP_CURSOR_CLASS = "bn-table-drop-cursor";
+
+export type TableDragState = {
+ draggedCellOrientation: "row" | "col";
+ /**
+ * The index of the row/column being dragged.
+ */
+ originalIndex: number;
+ /**
+ * The index the row/column would be dropped into, or `undefined` if the drag
+ * hasn't been over the table yet.
+ */
+ newIndex: number | undefined;
+};
+
+/**
+ * Builds the decorations shown while dragging a table row or column:
+ *
+ * - `bn-table-drag-source-row` / `bn-table-drag-source-col` on each cell of the
+ * row/column being dragged, so it's clear what's moving. Shown for the whole
+ * drag, including before the first `dragover` and while hovering a position
+ * the row/column can't be dropped into.
+ * - `bn-table-drop-cursor` widgets marking the edge the row/column would be
+ * dropped at. Only shown once the drag is over a valid, different position.
+ *
+ * Returns an empty array if the table can't be resolved at `tablePos`.
+ */
+export function getTableDragDecorations(
+ doc: Node,
+ /**
+ * Position just before the table node, i.e. `TableHandlesView`'s `tablePos`.
+ */
+ tablePos: number,
+ block: BlockFromConfigNoChildren,
+ { draggedCellOrientation, originalIndex, newIndex }: TableDragState,
+): Decoration[] {
+ // `tablePos` is only updated on mousemove, and mousemove doesn't fire during
+ // a native drag - so a transaction which shifts or removes the table mid-drag
+ // (a concurrent local or collaborative edit) leaves it stale. Resolving a
+ // stale position throws, which would take down the whole view update, so drop
+ // the decorations instead.
+ let tableResolvedPos: ResolvedPos;
+ try {
+ tableResolvedPos = doc.resolve(tablePos + 1);
+ } catch {
+ return [];
+ }
+ if (tableResolvedPos.node().type.name !== "table") {
+ return [];
+ }
+
+ // Resolves the relative indices returned by `getCellsAtRowHandle` /
+ // `getCellsAtColumnHandle` to a position inside that cell.
+ const resolveCell = ({ row, col }: RelativeCellIndices) => {
+ // Gets each row in the table.
+ const rowResolvedPos = doc.resolve(tableResolvedPos.posAtIndex(row) + 1);
+
+ // Gets the cell within the row.
+ return doc.resolve(rowResolvedPos.posAtIndex(col) + 1);
+ };
+
+ const decorations: Decoration[] = [];
+
+ const draggedCells =
+ draggedCellOrientation === "row"
+ ? getCellsAtRowHandle(block, originalIndex)
+ : getCellsAtColumnHandle(block, originalIndex);
+
+ draggedCells.forEach((cell) => {
+ const cellResolvedPos = resolveCell(cell);
+ const cellStart = cellResolvedPos.before();
+
+ decorations.push(
+ Decoration.node(cellStart, cellStart + cellResolvedPos.node().nodeSize, {
+ class:
+ draggedCellOrientation === "row"
+ ? DRAG_SOURCE_ROW_CLASS
+ : DRAG_SOURCE_COL_CLASS,
+ }),
+ );
+ });
+
+ // Only the source highlight is shown if:
+ // - The drag hasn't been over the table yet
+ // - Dragging to the 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 decorations;
+ }
+
+ const cellsAtNewIndex =
+ draggedCellOrientation === "row"
+ ? getCellsAtRowHandle(block, newIndex)
+ : getCellsAtColumnHandle(block, newIndex);
+
+ cellsAtNewIndex.forEach((cell) => {
+ const cellResolvedPos = resolveCell(cell);
+
+ // 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 ? cellResolvedPos.node().nodeSize - 2 : 0);
+
+ decorations.push(
+ // The widget is a small bar which spans the width (for a row) or height
+ // (for a column) of the cell.
+ Decoration.widget(decorationPos, () => {
+ const widget = document.createElement("div");
+ widget.className = DROP_CURSOR_CLASS;
+
+ // The offsets below are only necessary because the drop indicator's
+ // size 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/column is being
+ // dropped.
+ if (draggedCellOrientation === "row") {
+ widget.style.left = "0";
+ widget.style.right = "0";
+ if (newIndex > originalIndex) {
+ widget.style.bottom = "-2px";
+ } else {
+ widget.style.top = "-3px";
+ }
+ widget.style.height = "4px";
+ } else {
+ widget.style.top = "0";
+ widget.style.bottom = "0";
+ if (newIndex > originalIndex) {
+ widget.style.right = "-2px";
+ } else {
+ widget.style.left = "-3px";
+ }
+ widget.style.width = "4px";
+ }
+
+ return widget;
+ }),
+ );
+ });
+
+ return decorations;
+}
diff --git a/packages/core/src/extensions/TableHandles/dragPreview.test.ts b/packages/core/src/extensions/TableHandles/dragPreview.test.ts
new file mode 100644
index 0000000000..7c81e39162
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragPreview.test.ts
@@ -0,0 +1,242 @@
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
+
+import {
+ getCellsAtColumnHandle,
+ getCellsAtRowHandle,
+} from "../../api/blockManipulation/tables/tables.js";
+import type { PartialBlock } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { TableHandlesExtension } from "./TableHandles.js";
+import { setTableDragImage, unsetTableDragImage } from "./dragPreview.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+/**
+ * | 1-1 | 1-2 | 1-3 |
+ * | 2-1 | 2-2 | 2-3 |
+ */
+const testDocument: PartialBlock[] = [
+ {
+ id: "table-0",
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [
+ { cells: ["1-1", "1-2", "1-3"] },
+ { cells: ["2-1", "2-2", "2-3"] },
+ ],
+ },
+ },
+];
+
+let editor: BlockNoteEditor;
+let mountPoint: HTMLDivElement;
+
+beforeEach(() => {
+ // jsdom does no layout, so it implements neither of these. Hovering a cell
+ // means dispatching a real bubbling mousemove, which other plugins (the side
+ // menu, prosemirror-tables' cell selection) also listen for - and they hit
+ // test the pointer position.
+ (document as any).elementFromPoint = () => null;
+ (document as any).elementsFromPoint = () => [];
+
+ mountPoint = document.createElement("div");
+ document.body.appendChild(mountPoint);
+
+ editor = BlockNoteEditor.create({ initialContent: testDocument });
+ editor.mount(mountPoint);
+});
+
+afterEach(() => {
+ unsetTableDragImage();
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+ mountPoint.remove();
+});
+
+const blockElement = () =>
+ editor.prosemirrorView.dom.querySelector('[data-id="table-0"]')!;
+
+const previewRows = (preview: HTMLElement) =>
+ Array.from(preview.querySelectorAll("tr")).map((row) =>
+ Array.from(row.children).map((cell) => cell.textContent),
+ );
+
+describe("setTableDragImage", () => {
+ it("builds a single-row snapshot of the dragged row", () => {
+ const block = editor.getBlock("table-0")! as any;
+
+ const preview = setTableDragImage(
+ editor.prosemirrorView,
+ blockElement(),
+ getCellsAtRowHandle(block, 1),
+ "row",
+ )!;
+
+ expect(preview).toBeDefined();
+ expect(previewRows(preview)).toEqual([["2-1", "2-2", "2-3"]]);
+ });
+
+ it("builds a single-column snapshot of the dragged column", () => {
+ const block = editor.getBlock("table-0")! as any;
+
+ const preview = setTableDragImage(
+ editor.prosemirrorView,
+ blockElement(),
+ getCellsAtColumnHandle(block, 2),
+ "col",
+ )!;
+
+ expect(previewRows(preview)).toEqual([["1-3"], ["2-3"]]);
+ });
+
+ it("attaches the snapshot to the document so it can be captured", () => {
+ const block = editor.getBlock("table-0")! as any;
+
+ // `DataTransfer.setDragImage` only works with an element that's in the
+ // document.
+ const preview = setTableDragImage(
+ editor.prosemirrorView,
+ blockElement(),
+ getCellsAtRowHandle(block, 0),
+ "row",
+ )!;
+
+ expect(preview.isConnected).toBe(true);
+ expect(preview.className).toContain("bn-drag-preview");
+ expect(preview.className).toContain("bn-table-drag-preview");
+
+ unsetTableDragImage();
+
+ expect(preview.isConnected).toBe(false);
+ });
+
+ it("replaces a previous snapshot rather than stacking them", () => {
+ const block = editor.getBlock("table-0")! as any;
+ const cells = getCellsAtRowHandle(block, 0);
+
+ const first = setTableDragImage(
+ editor.prosemirrorView,
+ blockElement(),
+ cells,
+ "row",
+ )!;
+ const second = setTableDragImage(
+ editor.prosemirrorView,
+ blockElement(),
+ cells,
+ "row",
+ )!;
+
+ expect(first.isConnected).toBe(false);
+ expect(second.isConnected).toBe(true);
+ });
+
+ it("returns undefined when the table's DOM can't be read", () => {
+ const block = editor.getBlock("table-0")! as any;
+
+ expect(
+ setTableDragImage(
+ editor.prosemirrorView,
+ document.createElement("div"),
+ getCellsAtRowHandle(block, 0),
+ "row",
+ ),
+ ).toBeUndefined();
+ });
+});
+
+describe("drag start", () => {
+ // The handles only know which row/column they're on from having been hovered,
+ // so a drag can't start without a mousemove over the table first.
+ function hoverCell(row: number, col: number) {
+ const cell = blockElement().querySelectorAll("tr")[row].children[col];
+
+ cell.dispatchEvent(
+ new MouseEvent("mousemove", { bubbles: true, clientX: 0, clientY: 0 }),
+ );
+ }
+
+ function stubDataTransfer() {
+ const setDragImage: {
+ calls: [Element, number, number][];
+ } = { calls: [] };
+
+ return {
+ dataTransfer: {
+ setDragImage: (image: Element, x: number, y: number) =>
+ setDragImage.calls.push([image, x, y]),
+ effectAllowed: "",
+ } as unknown as DataTransfer,
+ setDragImage,
+ };
+ }
+
+ it("hands the row snapshot to the drag event", () => {
+ const tableHandles = editor.getExtension(TableHandlesExtension)!;
+ hoverCell(1, 0);
+
+ const { dataTransfer, setDragImage } = stubDataTransfer();
+ tableHandles.rowDragStart({ dataTransfer, clientY: 0 });
+
+ expect(setDragImage.calls).toHaveLength(1);
+ const [image, x, y] = setDragImage.calls[0];
+ expect(previewRows(image as HTMLElement)).toEqual([["2-1", "2-2", "2-3"]]);
+ expect([x, y]).toEqual([16, 16]);
+
+ tableHandles.dragEnd();
+
+ expect(image.isConnected).toBe(false);
+ });
+
+ it("hands the column snapshot to the drag event", () => {
+ const tableHandles = editor.getExtension(TableHandlesExtension)!;
+ hoverCell(0, 1);
+
+ const { dataTransfer, setDragImage } = stubDataTransfer();
+ tableHandles.colDragStart({ dataTransfer, clientX: 0 });
+
+ const [image] = setDragImage.calls[0];
+ expect(previewRows(image as HTMLElement)).toEqual([["1-2"], ["2-2"]]);
+
+ tableHandles.dragEnd();
+
+ expect(image.isConnected).toBe(false);
+ });
+
+ it("leaves the source highlight out of the snapshot", () => {
+ const tableHandles = editor.getExtension(TableHandlesExtension)!;
+ hoverCell(1, 0);
+
+ const { dataTransfer, setDragImage } = stubDataTransfer();
+ tableHandles.rowDragStart({ dataTransfer, clientY: 0 });
+
+ // The cells are cloned after the highlight decoration has been applied, so
+ // the snapshot has to drop it - it should look like the row, not like the
+ // row's drag state.
+ const [image] = setDragImage.calls[0];
+ expect(image.querySelectorAll(".bn-table-drag-source-row")).toHaveLength(0);
+
+ tableHandles.dragEnd();
+ });
+
+ it("renders the source highlight from the moment the drag starts", () => {
+ const tableHandles = editor.getExtension(TableHandlesExtension)!;
+ hoverCell(1, 0);
+
+ const { dataTransfer } = stubDataTransfer();
+ tableHandles.rowDragStart({ dataTransfer, clientY: 0 });
+
+ expect(
+ editor.prosemirrorView.dom.querySelectorAll(".bn-table-drag-source-row"),
+ ).toHaveLength(3);
+
+ tableHandles.dragEnd();
+
+ expect(
+ editor.prosemirrorView.dom.querySelectorAll(".bn-table-drag-source-row"),
+ ).toHaveLength(0);
+ });
+});
diff --git a/packages/core/src/extensions/TableHandles/dragPreview.ts b/packages/core/src/extensions/TableHandles/dragPreview.ts
new file mode 100644
index 0000000000..2c47ba1a94
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragPreview.ts
@@ -0,0 +1,158 @@
+import { EditorView } from "prosemirror-view";
+
+import { RelativeCellIndices } from "../../api/blockManipulation/tables/tables.js";
+import {
+ DRAG_SOURCE_COL_CLASS,
+ DRAG_SOURCE_ROW_CLASS,
+ DROP_CURSOR_CLASS,
+} from "./dragDecorations.js";
+
+let dragImageElement: HTMLElement | undefined;
+
+// Clones a single table cell for use in the drag preview. The clone is pulled
+// out of the real table, so it loses everything the table's own layout was
+// giving it.
+function cloneCellWithSize(cell: Element): HTMLElement {
+ // Read the size before cloning: column widths live on the table's
+ // `` and row heights are implied by the tallest cell in the row,
+ // neither of which survives into a table built from a handful of cells.
+ const { width, height } = cell.getBoundingClientRect();
+
+ const clone = cell.cloneNode(true) as HTMLElement;
+ clone.style.width = `${width}px`;
+ clone.style.height = `${height}px`;
+ clone.style.boxSizing = "border-box";
+
+ // The snapshot is taken after the drag decorations have been applied, so the
+ // cell being cloned is already marked up as the drag source (and may contain
+ // a drop cursor widget). The preview should look like the row/column itself,
+ // not like its drag state.
+ clone.classList.remove(
+ DRAG_SOURCE_ROW_CLASS,
+ DRAG_SOURCE_COL_CLASS,
+ "ProseMirror-selectednode",
+ );
+ clone
+ .querySelectorAll(`.${DROP_CURSOR_CLASS}`)
+ .forEach((widget) => widget.remove());
+
+ // The preview only holds the dragged row (or column), so a span pointing at
+ // cells that aren't in it would stretch the clone out of shape. The size set
+ // above already accounts for the space the span was taking up.
+ clone.removeAttribute("colspan");
+ clone.removeAttribute("rowspan");
+
+ return clone;
+}
+
+/**
+ * Builds the image shown next to the cursor while dragging a table row or
+ * column: a snapshot of the row/column itself, styled like a lifted card, so
+ * the drag actually looks like you're carrying it to its new position.
+ *
+ * `cells` are the cells making up the dragged row/column, as returned by
+ * `getCellsAtRowHandle` / `getCellsAtColumnHandle` - using those (rather than
+ * indexing the DOM directly) means merged cells resolve to the right elements.
+ *
+ * Returns the element to hand to `DataTransfer.setDragImage`, or `undefined`
+ * if the table's DOM couldn't be read, in which case the caller should fall
+ * back to the hidden drag image.
+ */
+export function setTableDragImage(
+ view: EditorView,
+ // The block container element for the table, i.e. `TableHandlesView`'s
+ // `tableElement`.
+ blockElement: HTMLElement,
+ cells: RelativeCellIndices[],
+ orientation: "row" | "col",
+): HTMLElement | undefined {
+ const tableBody = blockElement.querySelector("tbody");
+
+ if (!tableBody) {
+ return undefined;
+ }
+
+ const cellClones: HTMLElement[] = [];
+ for (const { row, col } of cells) {
+ // Relative cell indices line up with the DOM here for the same reason they
+ // line up with the ProseMirror node tree: one `` per row node, one
+ // cell element per cell node.
+ const cell = tableBody.children[row]?.children[col];
+
+ if (cell) {
+ cellClones.push(cloneCellWithSize(cell));
+ }
+ }
+
+ if (cellClones.length === 0) {
+ return undefined;
+ }
+
+ const table = document.createElement("table");
+ const tableBodyClone = document.createElement("tbody");
+
+ if (orientation === "row") {
+ const rowClone = document.createElement("tr");
+ cellClones.forEach((cell) => rowClone.appendChild(cell));
+ tableBodyClone.appendChild(rowClone);
+ } else {
+ cellClones.forEach((cell) => {
+ const rowClone = document.createElement("tr");
+ rowClone.appendChild(cell);
+ tableBodyClone.appendChild(rowClone);
+ });
+ }
+
+ table.appendChild(tableBodyClone);
+
+ const wrapper = document.createElement("div");
+ wrapper.appendChild(table);
+
+ // The preview is appended outside the editor, so the theme variables (which
+ // `@blocknote/react` defines on `.bn-root`) only resolve if it carries the
+ // class, and the colour scheme, itself.
+ const colorScheme = view.dom
+ .closest(".bn-root")
+ ?.getAttribute("data-color-scheme");
+ if (colorScheme) {
+ wrapper.setAttribute("data-color-scheme", colorScheme);
+ }
+
+ // TODO: This is hacky, need a better way of assigning classes to the editor
+ // so that they can also be applied to the drag preview. Same caveat as the
+ // equivalent code in `SideMenu/dragging.ts`.
+ const inheritedClasses = view.dom.className
+ .split(" ")
+ .filter(
+ (className) =>
+ className !== "ProseMirror" &&
+ className !== "bn-root" &&
+ className !== "bn-editor",
+ )
+ .join(" ");
+
+ wrapper.className =
+ `bn-root bn-drag-preview bn-table-drag-preview ${inheritedClasses}`.trim();
+
+ // dataTransfer.setDragImage(element) only works if element is attached to the
+ // DOM.
+ unsetTableDragImage();
+ dragImageElement = wrapper;
+
+ if (view.root instanceof ShadowRoot) {
+ view.root.appendChild(wrapper);
+ } else {
+ view.root.body.appendChild(wrapper);
+ }
+
+ return wrapper;
+}
+
+export function unsetTableDragImage() {
+ // `remove()` rather than `removeChild()` on the root the element was added
+ // to: the preview outlives a single drag only when something went wrong (a
+ // missed `dragend`, an editor unmounting mid-drag), and in those cases the
+ // root it was attached to may no longer be its parent.
+ dragImageElement?.remove();
+ dragImageElement = undefined;
+}
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index c0eb48c336..4cf23dddb1 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -892,6 +892,31 @@ export const examples = {
readme:
"By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container`. The `portalElements` prop on `BlockNoteView` lets you change that — globally via `default`, or per element by key.\n\nThis example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default — the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so floating UI escapes the wrapper and renders fully.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)",
},
+ {
+ projectSlug: "table-reordering-visualization",
+ fullSlug: "ui-components/table-reordering-visualization",
+ pathFromRoot:
+ "examples/03-ui-components/21-table-reordering-visualization",
+ config: {
+ playground: true,
+ docs: false,
+ author: "must",
+ tags: [
+ "Intermediate",
+ "UI Components",
+ "Tables",
+ "Drag & Drop",
+ "Appearance & Styling",
+ ],
+ },
+ title: "Table Reordering Visualization",
+ group: {
+ pathFromRoot: "examples/03-ui-components",
+ slug: "ui-components",
+ },
+ readme:
+ "BlockNote gives table row/column dragging visual feedback out of the box: a\nsnapshot of the row/column follows the cursor, the row/column being dragged is\ntinted and outlined, and a drop indicator marks where it would land.\n\nThis example shows how to restyle a table - and those built-in drag\naffordances - to match your own product, using a Microsoft Loop-inspired look:\n\n- **Restyled tables**: rounded card look, muted header row, hairline\n borders, and a row-hover highlight instead of a harsh black grid.\n- **Retuned drag affordances**: the built-in drag source highlight, drop\n indicator and drag snapshot recolored to the same palette.\n- **Header row by default**: the `/table` command starts new tables with\n a header row already enabled, so the header styling is visible right away.\n\n## How It Works\n\nEverything here is CSS plus one slash-menu tweak - no extensions, no event\nhandling. BlockNote's `TableHandlesExtension` owns the whole drag lifecycle and\nexposes it through classes you can target:\n\n| Class | What it's on |\n| -------------------------- | ---------------------------------------------- |\n| `bn-table-drag-source-row` | every cell of the row being dragged |\n| `bn-table-drag-source-col` | every cell of the column being dragged |\n| `bn-table-drop-cursor` | a bar on the edge the row/column would drop at |\n| `bn-table-drag-preview` | the snapshot shown next to the cursor |\n\nThe first three are ProseMirror decorations inside the editor, so they're\nscoped under `.bn-editor [data-content-type=\"table\"]` like any other table\nstyle. `bn-table-drag-preview` is different: it's appended outside the editor\n(the browser can only use an attached element as a drag image), so it has to be\nstyled through its own class rather than through the table selectors.\n\n`tableStyles.css` does the restyling; `App.tsx` overrides the default `/table`\nslash-menu item so new tables start with `headerRows: 1`.\n\n## Known Limitations\n\n- **Keyboard and touch**: BlockNote's table drag handles are `draggable` +\n `onDragStart` only today (see `TableHandle.tsx`) - there's no\n keyboard-operable reorder path, and native HTML5 drag-and-drop isn't\n supported on touch browsers at all. Both are gaps in BlockNote's table-drag\n feature as a whole, not something this example introduces or fixes.\n- **Accessibility**: for the same reason, there's no keyboard focus\n restoration to verify after a reorder - the interaction can't be reached by\n keyboard in the first place yet.\n\n**Relevant Docs:**\n\n- [Tables](/docs/features/blocks/tables)\n- [Overriding CSS](/docs/react/styling-theming/overriding-css)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Slash Menu](/docs/react/components/suggestion-menus)",
+ },
],
},
theming: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f902ae81a..c9eb8924a8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2258,6 +2258,49 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/03-ui-components/21-table-reordering-visualization:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite-plus:
+ specifier: 'catalog:'
+ version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
+
examples/04-theming/01-theming-dom-attributes:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-chromium-linux.png b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-chromium-linux.png
new file mode 100644
index 0000000000..eaa1c87435
Binary files /dev/null and b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-chromium-linux.png differ
diff --git a/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-webkit-linux.png b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-webkit-linux.png
new file mode 100644
index 0000000000..00eeac7540
Binary files /dev/null and b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-webkit-linux.png differ
diff --git a/tests/src/end-to-end/tables/tableDragVisuals.test.tsx b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx
new file mode 100644
index 0000000000..d954abe2d1
--- /dev/null
+++ b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx
@@ -0,0 +1,332 @@
+import App from "@examples/01-basic/testing/src/App";
+import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+import { EDITOR_SELECTOR, TABLE_SELECTOR } from "../../utils/const.js";
+import { browserName, userEvent } from "../../utils/context.js";
+import {
+ expectElement,
+ focusOnEditor,
+ waitForSelector,
+} from "../../utils/editor.js";
+import { mouseSequence, moveMouseOverElement } from "../../utils/mouse.js";
+
+// Feedback shown while dragging a table row/column, all of which BlockNote's
+// TableHandlesExtension renders by default: a highlight on the row/column being
+// dragged (`bn-table-drag-source-row` / `-col`), a bar marking where it would
+// land (`bn-table-drop-cursor`), and a snapshot of it next to the cursor
+// (`bn-table-drag-preview`). The reorder itself is covered by tables.test.tsx.
+//
+// Playwright doesn't correctly simulate drag events in Firefox, matching the
+// existing skip condition in tables.test.tsx for the same reason.
+const skipDrag = browserName === "firefox";
+
+const CELL_ATTRS = {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ colwidth: null,
+};
+
+// Replaces the document with a deterministic table, the same way
+// tables.test.tsx seeds its row-drag test - driving the table UI to build one
+// is slower and leaves the row/column count dependent on the default table.
+async function seedTable(rows: string[][]) {
+ const rowsContent = rows.map((cells) => ({
+ type: "tableRow",
+ content: cells.map((text) => ({
+ type: "tableCell",
+ attrs: CELL_ATTRS,
+ content: [{ type: "tableParagraph", content: [{ type: "text", text }] }],
+ })),
+ }));
+
+ (
+ window as unknown as {
+ ProseMirror: { commands: { setContent: (doc: unknown) => void } };
+ }
+ ).ProseMirror.commands.setContent({
+ type: "doc",
+ content: [
+ {
+ type: "blockGroup",
+ content: [
+ // The column handles render above the table. Without a block in
+ // front of it the table sits flush against the top of the editor,
+ // putting them out of reach of the mouse.
+ {
+ type: "blockContainer",
+ attrs: { id: "0" },
+ content: [
+ {
+ type: "paragraph",
+ attrs: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ content: [{ type: "text", text: "Above the table" }],
+ },
+ ],
+ },
+ {
+ type: "blockContainer",
+ attrs: { id: "1" },
+ content: [
+ {
+ type: "table",
+ attrs: { textColor: "default" },
+ content: rowsContent,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`).length !==
+ rows.length
+ ) {
+ throw new Error("Table not yet replaced");
+ }
+ });
+}
+
+// Hovers `cell` to reveal the table handles, then returns the row or column
+// handle. The column handle is rendered with a rotate transform on the
+// `.bn-table-handle` element itself; the row handle has none.
+async function getTableHandle(
+ cell: HTMLElement,
+ orientation: "row" | "column",
+): Promise {
+ await moveMouseOverElement(cell);
+ return vi.waitFor(() => {
+ const candidate = Array.from(
+ document.querySelectorAll(".bn-table-handle"),
+ ).find((el) => {
+ const isColumn = el.style.transform.includes("rotate");
+ return orientation === "column" ? isColumn : !isColumn;
+ });
+ if (!candidate) {
+ throw new Error(`${orientation} table handle not visible`);
+ }
+ return candidate;
+ });
+}
+
+function centerOf(el: Element) {
+ const box = el.getBoundingClientRect();
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+}
+
+const cellAt = (row: number, col: number) =>
+ document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`)[row].children[
+ col
+ ] as HTMLElement;
+
+// The decorations are scoped to the editor deliberately: the drag preview is
+// built from clones of the same cells and lives outside it, so an unscoped
+// selector would count both.
+const SOURCE_ROW = `${EDITOR_SELECTOR} .bn-table-drag-source-row`;
+const SOURCE_COL = `${EDITOR_SELECTOR} .bn-table-drag-source-col`;
+const DROP_CURSOR = `${EDITOR_SELECTOR} .bn-table-drop-cursor`;
+const DRAG_PREVIEW = ".bn-table-drag-preview";
+
+const count = (selector: string) => document.querySelectorAll(selector).length;
+
+async function waitForCount(selector: string, expected: number) {
+ await vi.waitFor(() => {
+ const actual = count(selector);
+ if (actual !== expected) {
+ throw new Error(`Expected ${expected} ${selector}, got ${actual}`);
+ }
+ });
+}
+
+// Presses the row/column handle for `cell` and drags onto `onto`, leaving the
+// mouse button down so the drag is still in progress when this resolves.
+//
+// A native drag doesn't begin on mousedown - the browser only starts it once
+// the pointer has moved far enough while the button is held, which is why every
+// test here has to drag somewhere before it can assert anything. The state
+// between `dragstart` and the first `dragover` isn't reachable through a
+// synthetic mouse at all; it's covered by the jsdom tests in
+// packages/core/src/extensions/TableHandles instead.
+async function startDrag(
+ cell: HTMLElement,
+ orientation: "row" | "column",
+ onto: HTMLElement,
+): Promise {
+ const handle = await getTableHandle(cell, orientation);
+ const { x, y } = centerOf(handle);
+ await mouseSequence([{ type: "move", x, y, steps: 5 }, { type: "down" }]);
+ await dragOver(onto);
+}
+
+async function dragOver(cell: HTMLElement): Promise {
+ const { x, y } = centerOf(cell);
+ await mouseSequence([{ type: "move", x, y, steps: 10 }]);
+}
+
+beforeEach(async () => {
+ await render();
+ await waitForSelector(EDITOR_SELECTOR);
+ await focusOnEditor();
+ await seedTable([
+ ["R1C1", "R1C2", "R1C3"],
+ ["R2C1", "R2C2", "R2C3"],
+ ["R3C1", "R3C2", "R3C3"],
+ ]);
+});
+
+describe("Table drag visuals", () => {
+ test.skipIf(skipDrag)(
+ "highlights the dragged row and marks the drop position",
+ async () => {
+ await startDrag(cellAt(1, 0), "row", cellAt(2, 0));
+
+ // The highlight covers the whole row being dragged; the drop cursor
+ // marks the row it would land on.
+ await waitForCount(SOURCE_ROW, 3);
+ await waitForCount(DROP_CURSOR, 3);
+
+ await mouseSequence([{ type: "up" }]);
+
+ // Both are transient - cleanup runs off `dragend`, not synchronously
+ // with the mouseup, so wait for it.
+ await waitForCount(SOURCE_ROW, 0);
+ await waitForCount(DROP_CURSOR, 0);
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "highlights every cell of the dragged column",
+ async () => {
+ await startDrag(cellAt(0, 1), "column", cellAt(0, 2));
+
+ await waitForCount(SOURCE_COL, 3);
+ await waitForCount(DROP_CURSOR, 3);
+
+ await mouseSequence([{ type: "up" }]);
+
+ await waitForCount(SOURCE_COL, 0);
+ await waitForCount(DROP_CURSOR, 0);
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "keeps the source highlight over an invalid drop position",
+ async () => {
+ await startDrag(cellAt(1, 0), "row", cellAt(2, 0));
+ await waitForCount(DROP_CURSOR, 3);
+
+ // Back onto the row's own position: there's nowhere to drop, so the drop
+ // cursor goes away, but the row being dragged is still the row being
+ // dragged.
+ await dragOver(cellAt(1, 0));
+
+ await waitForCount(DROP_CURSOR, 0);
+ expect(count(SOURCE_ROW)).toBe(3);
+
+ await mouseSequence([{ type: "up" }]);
+ await waitForCount(SOURCE_ROW, 0);
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "shows a snapshot of the dragged row next to the cursor",
+ async () => {
+ await startDrag(cellAt(1, 0), "row", cellAt(2, 0));
+
+ // The snapshot is what the browser hands to `setDragImage`. It stays in
+ // the DOM (invisible) for the duration of the drag - the composited image
+ // the user actually sees is drawn by the OS and can't be inspected here.
+ const preview = await vi.waitFor(() => {
+ const el = document.querySelector(DRAG_PREVIEW);
+ if (!el) {
+ throw new Error("Drag preview not attached");
+ }
+ return el;
+ });
+
+ // One row, holding a copy of each cell in it.
+ expect(preview.querySelectorAll("tr")).toHaveLength(1);
+ expect(
+ Array.from(preview.querySelectorAll("td, th")).map(
+ (cell) => cell.textContent,
+ ),
+ ).toEqual(["R2C1", "R2C2", "R2C3"]);
+
+ // The cells are cloned after the source highlight has been applied, so
+ // the snapshot has to drop it - it should look like the row, not like
+ // the row's drag state.
+ expect(
+ preview.querySelectorAll(".bn-table-drag-source-row"),
+ ).toHaveLength(0);
+
+ await mouseSequence([{ type: "up" }]);
+
+ await waitForCount(DRAG_PREVIEW, 0);
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "shows a snapshot of the dragged column next to the cursor",
+ async () => {
+ await startDrag(cellAt(0, 2), "column", cellAt(0, 1));
+
+ const preview = await vi.waitFor(() => {
+ const el = document.querySelector(DRAG_PREVIEW);
+ if (!el) {
+ throw new Error("Drag preview not attached");
+ }
+ return el;
+ });
+
+ // One row per cell in the column.
+ expect(preview.querySelectorAll("tr")).toHaveLength(3);
+ expect(
+ Array.from(preview.querySelectorAll("td, th")).map(
+ (cell) => cell.textContent,
+ ),
+ ).toEqual(["R1C3", "R2C3", "R3C3"]);
+
+ await mouseSequence([{ type: "up" }]);
+ await waitForCount(DRAG_PREVIEW, 0);
+ },
+ );
+
+ test.skipIf(skipDrag)("cancelling with Escape cleans up", async () => {
+ await startDrag(cellAt(1, 0), "row", cellAt(2, 0));
+ await waitForCount(SOURCE_ROW, 3);
+
+ // Escape cancels a native HTML5 drag: the browser fires `dragend` without
+ // a `drop`.
+ await userEvent.keyboard("{Escape}");
+ // Release the mouse button so it doesn't leak into the next test.
+ await mouseSequence([{ type: "up" }]);
+
+ await waitForCount(SOURCE_ROW, 0);
+ await waitForCount(DROP_CURSOR, 0);
+ await waitForCount(DRAG_PREVIEW, 0);
+ });
+
+ test.skipIf(skipDrag)("mid-drag appearance", async () => {
+ await startDrag(cellAt(1, 0), "row", cellAt(2, 0));
+ await waitForCount(DROP_CURSOR, 3);
+
+ // Framed on the table rather than the whole page: the suite's screenshot
+ // tolerance is a proportion of the captured area, and against a full page
+ // of whitespace a recoloured drop cursor doesn't move enough pixels to
+ // register.
+ await expectElement(
+ document.querySelector(TABLE_SELECTOR),
+ ).toMatchScreenshot("tableRowDragInProgress");
+
+ await mouseSequence([{ type: "up" }]);
+ });
+});