diff --git a/docs/content/docs/react/styling-theming/overriding-css.mdx b/docs/content/docs/react/styling-theming/overriding-css.mdx
index cbc1f70935..7e37cfc6d4 100644
--- a/docs/content/docs/react/styling-theming/overriding-css.mdx
+++ b/docs/content/docs/react/styling-theming/overriding-css.mdx
@@ -38,6 +38,15 @@ BlockNote uses classes with the `bn-` prefix to style editor elements. Here are
Menus and popovers may be portalled outside the toolbar or button that opened them. Target their own classes under `.bn-root` rather than relying on a toolbar or `.bn-container` ancestor. See [Configuring Portal Targets](/docs/react/components#configuring-portal-targets).
+#### 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. It copies the editor's styling context, so normal table styles apply to it too.
+
+Set `--bn-table-drag-source-color` on the editor to customize the translucent source tint without replacing cell background colors.
+
### 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..c12323e388
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/README.md
@@ -0,0 +1,53 @@
+# 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.
+
+## How It Works
+
+Everything here is CSS - no additional extensions or 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. The snapshot lives outside the live editor, but copies its DOM styling
+context, so those same table styles apply to it. Its outer wrapper also carries
+`bn-table-drag-preview` for preview-specific overrides. The source tint uses
+`--bn-table-drag-source-color` so it layers over existing cell backgrounds.
+
+`tableStyles.css` does the restyling; `App.tsx` provides an initial table with
+`headerRows: 1` so the header styling is visible right away.
+
+## 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)
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..2f2746abde
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ 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..97a0f90589
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/package.json
@@ -0,0 +1,31 @@
+{
+ "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",
+ "lint": "vp lint --type-aware src main.tsx vite.config.ts",
+ "build:prod": "vp run lint && 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..7ec3111444
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx
@@ -0,0 +1,40 @@
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+
+import "./tableStyles.css";
+
+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 ;
+}
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..611a7dd9a3
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css
@@ -0,0 +1,116 @@
+/**
+ * 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 {
+ --bn-table-drag-source-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);
+}
+
+/* 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/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..87c50de6e2
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts
@@ -0,0 +1,34 @@
+// 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 }) => ({
+ // This example's standalone build lints its sources, unlike the workspace
+ // lint command which excludes examples.
+ lint: { ignorePatterns: [] },
+ 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 be9ed67002..eb03da51f0 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -59,6 +59,7 @@
-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;
@@ -66,6 +67,21 @@
pointer-events: none;
}
+/* Highlights the source row/column, separately from the destination cursor. */
+.bn-editor [data-content-type="table"] .bn-table-drag-source-row,
+.bn-editor [data-content-type="table"] .bn-table-drag-source-col {
+ /* A separate layer preserves the cell's own background color beneath the
+ translucent tint, in both light and dark themes. */
+ background-image: linear-gradient(
+ var(--bn-table-drag-source-color, rgb(170 221 255 / 22%)),
+ var(--bn-table-drag-source-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;
+}
+
.bn-editor .bn-collaboration-cursor__base {
position: relative;
}
diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts
index 086b3609ff..61bd6d53e1 100644
--- a/packages/core/src/extensions/TableHandles/TableHandles.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandles.ts
@@ -11,7 +11,7 @@ import {
mergeCells,
splitCell,
} from "prosemirror-tables";
-import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
+import { DecorationSet, EditorView } from "prosemirror-view";
import {
RelativeCellIndices,
addRowsOrColumns,
@@ -44,8 +44,12 @@ import {
BlockSchemaWithBlock,
} from "../../schema/index.js";
import { getBlockFromElement } from "../blockDOM.js";
-
-let dragImageElement: HTMLElement | undefined;
+import { getTableDragDecorations } from "./dragDecorations.js";
+import {
+ attachDragPreview,
+ removeDragPreview,
+ setTableDragImage,
+} from "./dragPreview.js";
// TODO consider switching this to jotai, it is a bit messy and noisy
export type TableHandlesState = {
@@ -70,32 +74,35 @@ export type TableHandlesState = {
widgetContainer: HTMLElement | undefined;
};
-function setHiddenDragImage(rootEl: Document | ShadowRoot) {
- if (dragImageElement) {
- return;
- }
+// 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, 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);
- 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);
- } else {
- rootEl.appendChild(dragImageElement);
+ return;
}
-}
-function unsetHiddenDragImage(rootEl: Document | ShadowRoot) {
- if (dragImageElement) {
- if (rootEl instanceof Document) {
- rootEl.body.removeChild(dragImageElement);
- } else {
- rootEl.removeChild(dragImageElement);
- }
- dragImageElement = undefined;
- }
+ const hidden = document.createElement("div");
+ hidden.textContent = "_";
+ hidden.style.cssText =
+ "opacity:0;height:1px;width:1px;position:absolute;pointer-events:none";
+ attachDragPreview(editor.prosemirrorView, hidden);
+ dataTransfer.setDragImage(hidden, 0, 0);
}
function getChildIndex(node: Element) {
@@ -566,6 +573,9 @@ export class TableHandlesView implements PluginView {
// because yjs replaces the element when for example you change the color via the side menu
!this.tableElement?.isConnected
) {
+ // Removing/replacing the table can also remove the handle that would
+ // receive dragend, so don't leave its native-capture DOM attached.
+ removeDragPreview(this.pmView);
this.state = undefined;
this.tableId = undefined;
this.tablePos = undefined;
@@ -637,6 +647,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.
+ removeDragPreview(this.pmView);
}
}
@@ -668,165 +682,45 @@ 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.state.draggingState === undefined ||
+ !view.state.block
) {
return;
}
- // Resolved against the state being rendered rather than read from
- // `view.tablePos`, as ProseMirror computes decorations for a
- // transaction before it calls the plugin view's `update` - so the
- // cached position is still the pre-transaction one here.
- const tablePos = view.getTablePos(state.doc);
+ const { draggedCellOrientation, originalIndex } =
+ view.state.draggingState;
+ // Decorations are computed before the plugin view updates, so use
+ // the table's position in the document being rendered.
+ const tablePos = view.getTablePos(state.doc);
if (tablePos === undefined) {
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;
-
- // Reuse the drop target's cells for validation and decorations.
- // Skip the lookup when there is no block or the position is unchanged.
- const cellsAtDropTarget =
- block && newIndex !== originalIndex
- ? draggedCellOrientation === "row"
- ? getCellsAtRowHandle(block, newIndex)
- : getCellsAtColumnHandle(block, newIndex)
- : [];
-
- // Return empty decorations if:
- // - Dragging to the same position
- // - No block exists
- // - The row or column drag would split merged cells
- if (
- newIndex === originalIndex ||
- !block ||
- (draggedCellOrientation === "row" &&
- !canRowBeDraggedInto(
- block,
- originalIndex,
- newIndex,
- cellsAtDropTarget,
- )) ||
- (draggedCellOrientation === "col" &&
- !canColumnBeDraggedInto(
- block,
+ return DecorationSet.create(
+ state.doc,
+ getTableDragDecorations(
+ state.doc.resolve(tablePos + 1),
+ view.state.block,
+ {
+ draggedCellOrientation,
originalIndex,
- newIndex,
- cellsAtDropTarget,
- ))
- ) {
- 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") {
- cellsAtDropTarget.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 {
- cellsAtDropTarget.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);
+ newIndex:
+ draggedCellOrientation === "row"
+ ? view.state.rowIndex
+ : view.state.colIndex,
+ },
+ ),
+ );
},
},
}),
@@ -850,9 +744,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();
@@ -871,8 +767,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";
},
@@ -890,9 +791,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();
@@ -911,8 +814,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";
},
@@ -921,22 +829,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
* used as the row drag handle, and the one used as the column drag handle.
*/
dragEnd() {
- if (view!.state === undefined) {
- throw new Error(
- "Attempted to drag table row, but no table block was hovered prior.",
- );
+ if (!editor.headless) {
+ removeDragPreview(editor.prosemirrorView);
}
-
- view!.state.draggingState = undefined;
- view!.emitUpdate();
-
- editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null));
-
- if (editor.headless) {
+ // A collaborator may have deleted the table (and cleared the view state)
+ // while the browser was dragging. Ending that drag is still valid.
+ if (view?.state === undefined) {
return;
}
- unsetHiddenDragImage(editor.prosemirrorView.root);
+ view.state.draggingState = undefined;
+ view.emitUpdate();
+
+ editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null));
},
/**
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png
new file mode 100644
index 0000000000..4e83fe8d95
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png
new file mode 100644
index 0000000000..a5d5d9d023
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png
new file mode 100644
index 0000000000..b12912bc43
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png
new file mode 100644
index 0000000000..155c95c5fd
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png
new file mode 100644
index 0000000000..94a148f39c
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png
new file mode 100644
index 0000000000..56d90f6527
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png
new file mode 100644
index 0000000000..fcf7a57a54
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png
new file mode 100644
index 0000000000..ac427fad3c
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png
new file mode 100644
index 0000000000..e9b96ee702
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png
new file mode 100644
index 0000000000..f1b7b2bb75
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png
new file mode 100644
index 0000000000..34e1ba2911
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png differ
diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png
new file mode 100644
index 0000000000..5dbda6b5db
Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png differ
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..b786a635f8
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts
@@ -0,0 +1,352 @@
+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";
+
+/**
+ * | 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.resolve(tablePos + 1), 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.each([
+ {
+ orientation: "row",
+ from: 0,
+ to: 1,
+ edge: "bottom",
+ offset: "-2px",
+ size: "height",
+ },
+ {
+ orientation: "row",
+ from: 1,
+ to: 0,
+ edge: "top",
+ offset: "-3px",
+ size: "height",
+ },
+ {
+ orientation: "col",
+ from: 0,
+ to: 1,
+ edge: "right",
+ offset: "-2px",
+ size: "width",
+ },
+ {
+ orientation: "col",
+ from: 1,
+ to: 0,
+ edge: "left",
+ offset: "-3px",
+ size: "width",
+ },
+ ] as const)(
+ "centers a $orientation cursor on the $edge border",
+ ({ orientation, from, to, edge, offset, size }) => {
+ const { decorationsFor } = setup(simpleTable);
+
+ const [widget] = dropCursors(
+ decorationsFor({
+ draggedCellOrientation: orientation,
+ originalIndex: from,
+ newIndex: to,
+ }),
+ );
+ const element = typeOf(widget).toDOM(
+ null,
+ () => widget.from,
+ ) as HTMLElement;
+
+ expect(element.className).toBe("bn-table-drop-cursor");
+ expect(element.style[size]).toBe("4px");
+ expect(element.style[edge]).toBe(offset);
+ },
+ );
+ });
+
+ 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);
+ });
+ });
+
+ it.each(["row", "col"] as const)(
+ "skips deleted source and target cells during a %s drag",
+ (orientation) => {
+ const { editor, block } = setup(simpleTable);
+ // Decorations run before the view refreshes its cached block.
+ editor.updateBlock("table-0", {
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [{ cells: ["remaining"] }],
+ },
+ });
+ const doc = editor.prosemirrorState.doc;
+ const tablePos = getNodeById("table-0", doc)!.posBeforeNode + 1;
+ for (const [originalIndex, newIndex] of [
+ [0, 2],
+ [2, 0],
+ ]) {
+ const decorations = getTableDragDecorations(
+ doc.resolve(tablePos + 1),
+ block,
+ {
+ draggedCellOrientation: orientation,
+ originalIndex,
+ newIndex,
+ },
+ );
+ expect(decorations).toHaveLength(1);
+ expect(sourceDecorations(decorations)).toHaveLength(
+ originalIndex === 0 ? 1 : 0,
+ );
+ }
+ editor._tiptapEditor.destroy();
+ },
+ );
+});
diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.ts b/packages/core/src/extensions/TableHandles/dragDecorations.ts
new file mode 100644
index 0000000000..989314eb4d
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragDecorations.ts
@@ -0,0 +1,167 @@
+import type { Node, ResolvedPos } from "prosemirror-model";
+import { Decoration } from "prosemirror-view";
+
+import {
+ canColumnBeDraggedInto,
+ canRowBeDraggedInto,
+ getCellsAtColumnHandle,
+ getCellsAtRowHandle,
+ getTableCellOccupancyGrid,
+} 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";
+
+const DROP_CURSOR_THICKNESS = 4;
+// Mirrors the 1px cell border in editor.css.
+const CELL_BORDER_WIDTH = 1;
+const cursorAxes = {
+ row: {
+ before: "top",
+ after: "bottom",
+ crossStart: "left",
+ crossEnd: "right",
+ size: "height",
+ },
+ col: {
+ before: "left",
+ after: "right",
+ crossStart: "top",
+ crossEnd: "bottom",
+ size: "width",
+ },
+} as const;
+
+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.
+ *
+ * `tableResolvedPos` points inside the table in the current document.
+ */
+export function getTableDragDecorations(
+ tableResolvedPos: ResolvedPos,
+ block: BlockFromConfigNoChildren,
+ { draggedCellOrientation, originalIndex, newIndex }: TableDragState,
+): Decoration[] {
+ // Resolve the document path only once. Resolving every cell repeats the
+ // search through blocks before this table, while posAtIndex also scans all
+ // preceding rows/cells. A table-local pass gives constant-time cell lookups.
+ // These are physical child indices, not TableMap's logical (span-expanded)
+ // coordinates, matching the existing row/column handle helpers.
+ const cellPositions: { node: Node; pos: number }[][] = [];
+ tableResolvedPos.node().forEach((row, rowOffset) => {
+ const cells: { node: Node; pos: number }[] = [];
+ row.forEach((node, cellOffset) => {
+ cells.push({
+ node,
+ pos: tableResolvedPos.start() + rowOffset + 1 + cellOffset,
+ });
+ });
+ cellPositions.push(cells);
+ });
+
+ const decorations: Decoration[] = [];
+ // Both helpers accept a prebuilt grid. Reuse it for the source and target
+ // rather than laying out the entire (possibly merged) table twice.
+ const occupancyGrid = getTableCellOccupancyGrid(block);
+ const getCellsAtHandle =
+ draggedCellOrientation === "row"
+ ? getCellsAtRowHandle
+ : getCellsAtColumnHandle;
+ const canBeDraggedInto =
+ draggedCellOrientation === "row"
+ ? canRowBeDraggedInto
+ : canColumnBeDraggedInto;
+
+ const draggedCells = getCellsAtHandle(block, originalIndex, occupancyGrid);
+ draggedCells.forEach((cell) => {
+ const cellResolvedPos = cellPositions[cell.row]?.[cell.col];
+ if (!cellResolvedPos) {
+ return;
+ }
+ const cellStart = cellResolvedPos.pos;
+
+ 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 until hovering a different, valid target.
+ if (newIndex === undefined || newIndex === originalIndex) {
+ return decorations;
+ }
+
+ // Reuse the target cells for validation and decorations, as the occupancy
+ // lookup can be expensive for large tables.
+ const cellsAtNewIndex = getCellsAtHandle(block, newIndex, occupancyGrid);
+ if (!canBeDraggedInto(block, originalIndex, newIndex, cellsAtNewIndex)) {
+ return decorations;
+ }
+
+ cellsAtNewIndex.forEach((cell) => {
+ const cellResolvedPos = cellPositions[cell.row]?.[cell.col];
+ if (!cellResolvedPos) {
+ return;
+ }
+
+ // 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 +
+ 1 +
+ (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;
+
+ const axis = cursorAxes[draggedCellOrientation];
+ const after = newIndex > originalIndex;
+ widget.style[axis.crossStart] = "0";
+ widget.style[axis.crossEnd] = "0";
+ widget.style[axis.size] = `${DROP_CURSOR_THICKNESS}px`;
+ // Center the bar on the shared border; the leading edge includes the
+ // cell's own border, while the trailing edge is already outside it.
+ widget.style[after ? axis.after : axis.before] =
+ `${-DROP_CURSOR_THICKNESS / 2 - (after ? 0 : CELL_BORDER_WIDTH)}px`;
+
+ return widget;
+ }),
+ );
+ });
+
+ return decorations;
+}
diff --git a/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts b/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts
new file mode 100644
index 0000000000..03125ddae3
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts
@@ -0,0 +1,249 @@
+import { afterEach, expect, test } from "vite-plus/test";
+
+import "../../style.css";
+import "../../fonts/inter.css";
+import "../../../../react/src/editor/styles.css";
+import type { PartialBlock } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { TableHandlesExtension } from "./TableHandles.js";
+
+const fixtures: { editor: BlockNoteEditor; container: HTMLElement }[] = [];
+
+// Bridge the browser matcher to the repository's bundled vitest typings.
+const expectElement = (
+ expect as unknown as {
+ element(
+ this: void,
+ element: Element,
+ ): {
+ toMatchScreenshot(name: string): Promise;
+ };
+ }
+).element;
+
+afterEach(() => {
+ for (const { editor, container } of fixtures.splice(0)) {
+ editor._tiptapEditor.destroy();
+ container.remove();
+ }
+});
+
+function createEditor() {
+ const container = document.createElement("div");
+ container.className = "bn-root bn-container";
+ document.body.appendChild(container);
+ const initialContent: PartialBlock[] = [
+ {
+ id: "table",
+ type: "table",
+ props: { textColor: "red" },
+ content: {
+ type: "tableContent",
+ columnWidths: [140, 160, 140],
+ rows: [
+ {
+ cells: [
+ {
+ type: "tableCell",
+ props: { backgroundColor: "blue" },
+ content: "first line\nsecond line",
+ },
+ { type: "tableCell", content: "https://example.com/".repeat(8) },
+ { type: "tableCell", content: "last cell" },
+ ],
+ },
+ { cells: ["another\nmultiline cell", "middle", "right"] },
+ { cells: ["bottom left", "bottom middle", "bottom right"] },
+ ],
+ },
+ },
+ ];
+ const editor = BlockNoteEditor.create({ initialContent });
+ editor.mount(container);
+ fixtures.push({ editor, container });
+ return editor;
+}
+
+function startDrag(editor: BlockNoteEditor, orientation: "row" | "col") {
+ editor.prosemirrorView.dom
+ .querySelector("td, th")!
+ .dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
+ const handles = editor.getExtension(TableHandlesExtension)!;
+ const transfer = new DataTransfer();
+ let preview: Element | undefined;
+ transfer.setDragImage = (image) => {
+ preview = image;
+ };
+ const event = { dataTransfer: transfer, clientX: 0, clientY: 0 };
+ if (orientation === "row") {
+ handles.rowDragStart(event);
+ } else {
+ handles.colDragStart(event);
+ }
+ if (!(preview instanceof HTMLElement)) {
+ throw new Error("Expected a table drag image");
+ }
+ return { handles, transfer, preview };
+}
+
+function dragOver(cell: Element, dataTransfer: DataTransfer) {
+ const box = cell.getBoundingClientRect();
+ cell.dispatchEvent(
+ new DragEvent("dragover", {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer,
+ clientX: box.x + box.width / 2,
+ clientY: box.y + box.height / 2,
+ }),
+ );
+}
+
+for (const orientation of ["row", "col"] as const) {
+ test(`previews the ${orientation}, updates its drop cursor and cleans up`, async () => {
+ const editor = createEditor();
+ await document.fonts.ready;
+ const root = editor.prosemirrorView.dom;
+ const rows = root.querySelectorAll("tr");
+ const originals =
+ orientation === "row"
+ ? Array.from(rows[0].children)
+ : Array.from(rows, (row) => row.children[0]);
+ const source = originals[0];
+ const background = getComputedStyle(source).backgroundColor;
+ source.classList.add("selectedCell");
+ const handle = document.createElement("div");
+ handle.className = "column-resize-handle";
+ source.appendChild(handle);
+ const { handles, transfer, preview } = startDrag(editor, orientation);
+ const clones = Array.from(preview.querySelectorAll("td"));
+ expect(clones.map((cell) => cell.textContent)).toEqual(
+ originals.map((cell) => cell.textContent),
+ );
+ expect(preview.parentElement).toBe(root.parentElement);
+ expect(
+ preview.querySelector(
+ ".selectedCell, .column-resize-handle, [contenteditable]",
+ ),
+ ).toBeNull();
+ for (const [index, clone] of clones.entries()) {
+ const original = originals[index];
+ expect(clone.getBoundingClientRect().width).toBeCloseTo(
+ original.getBoundingClientRect().width,
+ 0,
+ );
+ expect(clone.scrollWidth).toBeLessThanOrEqual(clone.clientWidth);
+ expect(getComputedStyle(clone).backgroundColor).toBe(
+ getComputedStyle(original).backgroundColor,
+ );
+ for (const property of ["whiteSpace", "overflowWrap", "color"] as const) {
+ expect(getComputedStyle(clone.querySelector("p")!)[property]).toBe(
+ getComputedStyle(original.querySelector("p")!)[property],
+ );
+ }
+ }
+ const sourceSelector = `.bn-table-drag-source-${orientation}`;
+ expect(root.querySelectorAll(sourceSelector)).toHaveLength(3);
+ expect(preview.querySelector(sourceSelector)).toBeNull();
+ expect(getComputedStyle(source).backgroundColor).toBe(background);
+ expect(getComputedStyle(source).backgroundImage).toContain(
+ "linear-gradient",
+ );
+ dragOver(
+ orientation === "row" ? rows[2].children[0] : rows[0].children[2],
+ transfer,
+ );
+ expect(root.querySelectorAll(".bn-table-drop-cursor")).toHaveLength(3);
+ dragOver(source, transfer);
+ expect(root.querySelector(".bn-table-drop-cursor")).toBeNull();
+ expect(root.querySelectorAll(sourceSelector)).toHaveLength(3);
+
+ // Photograph the native capture element on-screen, without the live table
+ // behind its transparent cells. Cleanup belongs to afterEach if this fails.
+ preview.style.cssText += ";position:fixed;top:20px;left:20px;z-index:9999";
+ root.style.visibility = "hidden";
+ await expectElement(preview).toMatchScreenshot(
+ `table-${orientation}-preview-wrapped-text`,
+ );
+ handles.dragEnd();
+ expect(preview.isConnected).toBe(false);
+ expect(root.querySelector(sourceSelector)).toBeNull();
+ expect(getComputedStyle(root.querySelector("td")!).backgroundImage).toBe(
+ "none",
+ );
+ });
+}
+
+test("replaces previews per editor and removes them when the table is deleted", () => {
+ const first = createEditor();
+ const second = createEditor();
+ const old = startDrag(first, "row").preview;
+ const replacement = startDrag(first, "row");
+ expect(old.isConnected).toBe(false);
+ const other = startDrag(second, "col");
+ other.handles.dragEnd();
+ expect(other.preview.isConnected).toBe(false);
+ expect(replacement.preview.isConnected).toBe(true);
+ first.replaceBlocks(
+ ["table"],
+ [{ type: "paragraph", content: "replacement" }],
+ );
+ expect(replacement.preview.isConnected).toBe(false);
+ expect(() => replacement.handles.dragEnd()).not.toThrow();
+});
+
+test("keeps a merged header's dimensions without its spans", () => {
+ const editor = createEditor();
+ editor.updateBlock("table", {
+ type: "table",
+ content: {
+ type: "tableContent",
+ headerRows: 1,
+ rows: [
+ {
+ cells: [
+ {
+ type: "tableCell",
+ props: { colspan: 2 },
+ content: ["Merged header"],
+ },
+ { type: "tableCell", content: ["Other header"] },
+ ],
+ },
+ { cells: ["one", "two", "three"] },
+ ],
+ },
+ });
+ const original = editor.prosemirrorView.dom.querySelector("th")!;
+ const { preview } = startDrag(editor, "row");
+ const clone = preview.querySelector("th")!;
+ expect(clone.textContent).toBe("Merged header");
+ expect(clone.getBoundingClientRect().width).toBeCloseTo(
+ original.getBoundingClientRect().width,
+ 0,
+ );
+ expect(clone.getBoundingClientRect().height).toBeCloseTo(
+ original.getBoundingClientRect().height,
+ 0,
+ );
+ expect(clone.hasAttribute("colspan")).toBe(false);
+});
+
+test("renders the source tint and drop cursor in both themes", async () => {
+ const editor = createEditor();
+ const root = editor.prosemirrorView.dom;
+ const themeRoot = root.closest(".bn-root")!;
+ await document.fonts.ready;
+ const { handles, transfer } = startDrag(editor, "row");
+ dragOver(root.querySelectorAll("tr")[2].children[0], transfer);
+ for (const theme of ["light", "dark"] as const) {
+ themeRoot.dataset.colorScheme = theme;
+ expect(getComputedStyle(root).backgroundColor).toBe(
+ theme === "dark" ? "rgb(31, 31, 31)" : "rgb(255, 255, 255)",
+ );
+ await expectElement(root.querySelector("table")!).toMatchScreenshot(
+ `table-row-drag-in-progress-${theme}`,
+ );
+ }
+ handles.dragEnd();
+});
diff --git a/packages/core/src/extensions/TableHandles/dragPreview.ts b/packages/core/src/extensions/TableHandles/dragPreview.ts
new file mode 100644
index 0000000000..e3be256d6d
--- /dev/null
+++ b/packages/core/src/extensions/TableHandles/dragPreview.ts
@@ -0,0 +1,199 @@
+import type { EditorView } from "prosemirror-view";
+import { RelativeCellIndices } from "../../api/blockManipulation/tables/tables.js";
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import {
+ DRAG_SOURCE_COL_CLASS,
+ DRAG_SOURCE_ROW_CLASS,
+ DROP_CURSOR_CLASS,
+} from "./dragDecorations.js";
+
+const previews = new WeakMap();
+
+export function removeDragPreview(view: EditorView) {
+ previews.get(view)?.remove();
+ previews.delete(view);
+}
+
+export function attachDragPreview(view: EditorView, preview: Element) {
+ removeDragPreview(view);
+ const parent = view.dom.parentElement;
+ const root = view.root;
+ const previewParent = parent?.isConnected
+ ? parent
+ : root instanceof ShadowRoot
+ ? root
+ : root.body;
+ previewParent.appendChild(preview);
+ previews.set(view, preview);
+}
+
+// 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",
+ "selectedCell",
+ );
+ clone
+ .querySelectorAll(".ProseMirror-selectednode, .selectedCell")
+ .forEach((node) => {
+ node.classList.remove("ProseMirror-selectednode", "selectedCell");
+ });
+ clone
+ // Editing widgets and embeds are not part of the cell's content snapshot.
+ // As with whole-block previews, an embed can prevent native drag capture.
+ .querySelectorAll(
+ `.${DROP_CURSOR_CLASS}, .column-resize-handle, .ProseMirror-gapcursor, iframe, embed, object`,
+ )
+ .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. Like whole-block previews, it keeps the editor's DOM/style context
+ * rather than approximating its appearance with a second set of table styles.
+ *
+ * `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(
+ editor: BlockNoteEditor,
+ // The block container element for the table, i.e. `TableHandlesView`'s
+ // `tableElement`.
+ blockElement: HTMLElement,
+ cells: RelativeCellIndices[],
+ orientation: "row" | "col",
+): HTMLElement | undefined {
+ const sourceTable = blockElement.querySelector("table");
+ const tableBody = sourceTable?.querySelector("tbody");
+
+ if (!sourceTable || !tableBody) {
+ return undefined;
+ }
+
+ const table = sourceTable.cloneNode(false) as HTMLTableElement;
+ // The original width/colgroup describe the whole table, not this subset.
+ table.style.removeProperty("width");
+ table.style.removeProperty("min-width");
+ const tableBodyClone = tableBody.cloneNode(false) as 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 rowElement = tableBody.children[row];
+ const cell = rowElement?.children[col];
+
+ if (cell && rowElement) {
+ // Row previews share one row; column previews keep each cell's own row.
+ const rowClone =
+ (orientation === "row" && tableBodyClone.firstElementChild) ||
+ tableBodyClone.appendChild(rowElement.cloneNode(false));
+ rowClone.appendChild(cloneCellWithSize(cell));
+ }
+ }
+
+ if (tableBodyClone.childElementCount === 0) {
+ return undefined;
+ }
+
+ table.appendChild(tableBodyClone);
+
+ // Copy the actual ancestor chain through the editor. It carries inherited
+ // block colors/line height and selectors such as `.bn-editor
+ // [data-content-type="table"]`, including app overrides and custom frames.
+ // Only layout constraints are reset: this is a snapshot of a subset, not a
+ // second editor viewport or table scroller.
+ let content: HTMLElement = table;
+ const view = editor.prosemirrorView;
+ let ancestor = sourceTable.parentElement;
+ while (ancestor && view.dom.contains(ancestor)) {
+ const clone = ancestor.cloneNode(false) as HTMLElement;
+ clone.classList.remove("ProseMirror-selectednode");
+ clone.removeAttribute("id");
+ clone.removeAttribute("contenteditable");
+ clone.removeAttribute("tabindex");
+ Object.assign(clone.style, {
+ width: "max-content",
+ minWidth: "0",
+ maxWidth: "none",
+ height: "auto",
+ minHeight: "0",
+ maxHeight: "none",
+ margin: "0",
+ padding: "0",
+ overflow: "visible",
+ });
+ clone.appendChild(content);
+ content = clone;
+ if (ancestor === view.dom) {
+ break;
+ }
+ ancestor = ancestor.parentElement;
+ }
+
+ // Same themed outer wrapper and off-screen capture strategy as SideMenu's
+ // whole-block preview (#3235). Do not hide with opacity: Firefox/Safari also
+ // apply it to the native drag bitmap.
+ const container = view.dom.parentElement?.closest(".bn-root");
+ const wrapper =
+ (container?.cloneNode(false) as HTMLElement | undefined) ??
+ document.createElement("div");
+ wrapper.removeAttribute("id");
+ wrapper.classList.add("bn-drag-preview", "bn-table-drag-preview");
+ wrapper.setAttribute("aria-hidden", "true");
+ Object.assign(wrapper.style, {
+ background: "transparent",
+ position: "absolute",
+ top: "-10000px",
+ left: "0",
+ width: "max-content",
+ maxWidth: "none",
+ margin: "0",
+ padding: "10px",
+ height: "auto",
+ minHeight: "0",
+ maxHeight: "75vh",
+ overflow: "hidden",
+ pointerEvents: "none",
+ });
+ wrapper.appendChild(content);
+
+ // dataTransfer.setDragImage(element) only works if element is attached to the
+ // DOM.
+ // Keep the off-screen image attached until drag end: Firefox/Safari may
+ // capture after dragstart. Attach beside the editor to inherit its theme.
+ attachDragPreview(view, wrapper);
+
+ return wrapper;
+}
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 6fa9173445..0dd1a19a6f 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -891,6 +891,31 @@ export const examples = {
readme:
"By default, BlockNote's floating components (formatting toolbar, slash menu, table handles, etc.) mount next to the editor, inside its `bn-container` (or inside whatever you render `BlockNoteViewEditor` into). The `portalElements` prop on `BlockNoteView` lets you change that: globally via `default`, or per component by key. The menus and popovers a floating component opens follow it wherever it mounts.\n\nThis example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default, so the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so the floating components escape the wrapper and render fully.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)\n- [Mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar)",
},
+ {
+ 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\n## How It Works\n\nEverything here is CSS - no additional extensions or event handling.\nBlockNote'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. The snapshot lives outside the live editor, but copies its DOM styling\ncontext, so those same table styles apply to it. Its outer wrapper also carries\n`bn-table-drag-preview` for preview-specific overrides. The source tint uses\n`--bn-table-drag-source-color` so it layers over existing cell backgrounds.\n\n`tableStyles.css` does the restyling; `App.tsx` provides an initial table with\n`headerRows: 1` so the header styling is visible right away.\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)",
+ },
],
},
theming: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6f36d87bcc..7ebefcd6af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2307,6 +2307,49 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.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.9.5)(esbuild@0.27.5)(jiti@2.7.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite-plus:
+ specifier: 'catalog:'
+ version: 1.4.0(@opentelemetry/api@1.10.0)(@types/node@25.9.5)(@vitest/browser-playwright@5.0.5)(esbuild@0.27.5)(jiti@2.7.0)(jsdom@29.0.2(@noble/hashes@2.4.0)(canvas@3.1.0))(msw@2.11.5(@types/node@25.9.5)(typescript@7.0.2))(playwright@1.60.0)(terser@5.51.2)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.9.0)
+
examples/04-theming/01-theming-dom-attributes:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/tables/tables.test.tsx b/tests/src/end-to-end/tables/tables.test.tsx
index 354e015c49..8f4d7a5b23 100644
--- a/tests/src/end-to-end/tables/tables.test.tsx
+++ b/tests/src/end-to-end/tables/tables.test.tsx
@@ -71,6 +71,48 @@ beforeEach(async () => {
});
describe("Check Table interactions", () => {
+ // Cancellation is a native drag lifecycle check; visual coverage lives in
+ // TableHandles/dragPreview.browser.test.ts alongside the preview implementation.
+ test.skipIf(browserName === "firefox")(
+ "Escape cancels a table drag and cleans up its visuals",
+ async () => {
+ await focusOnEditor();
+ await insertParagraph();
+ await executeSlashCommand("table");
+ const rows = document.querySelectorAll(`${TABLE_SELECTOR} tr`);
+ const handle = await getTableHandle(
+ rows[0].querySelector("td, th")!,
+ "row",
+ );
+ const box = handle.getBoundingClientRect();
+ const target = rows[1].children[0].getBoundingClientRect();
+ await mouseSequence([
+ { type: "move", x: box.x + box.width / 2, y: box.y + box.height / 2 },
+ { type: "down" },
+ {
+ type: "move",
+ x: target.x + target.width / 2,
+ y: target.y + target.height / 2,
+ steps: 10,
+ },
+ ]);
+ await vi.waitFor(() =>
+ expect(
+ document.querySelector(".bn-table-drag-source-row"),
+ ).not.toBeNull(),
+ );
+ await userEvent.keyboard("{Escape}");
+ await mouseSequence([{ type: "up" }]);
+ await vi.waitFor(() =>
+ expect(
+ document.querySelector(
+ ".bn-table-drag-source-row, .bn-table-drop-cursor, .bn-table-drag-preview",
+ ),
+ ).toBeNull(),
+ );
+ },
+ );
+
test("Should be able to type in cell", async () => {
await focusOnEditor();
await executeSlashCommand("table");