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..3323362d58
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/README.md
@@ -0,0 +1,107 @@
+# Table Reordering Visualization
+
+This example gives dragging a table row/column much clearer visual feedback
+than BlockNote's default, matching the feel of tools like Microsoft Loop:
+
+- **Restyled tables**: rounded card look, muted header row, hairline
+ borders, and a row-hover highlight instead of a harsh black grid.
+- **Drag source highlight**: the row/column actually being dragged is
+ tinted and outlined so it's obvious what's moving.
+- **Colored drop indicator**: the drop-position line uses a solid brand
+ color instead of the default pale blue.
+- **Floating drag image**: a real snapshot of the row/column follows the
+ cursor while dragging, instead of BlockNote's default (invisible) native
+ drag image.
+- **Header row by default**: the `/table` command starts new tables with
+ a header row already enabled, so the header styling is visible right away.
+
+## Interaction Model
+
+Nothing here changes _what_ a row/column drag does - BlockNote's own
+`TableHandlesExtension` still owns the drag lifecycle (`dragstart` /
+`dragover` / `drop`) and the actual reorder (`moveRow` / `moveColumn` +
+`editor.updateBlock`). This example only adds feedback layered on top of
+that existing lifecycle:
+
+1. **Drag start** - `TableDragSourceExtension` reads the same
+ `tableHandlesPluginKey` transaction metadata BlockNote's own drop-cursor
+ decoration reads, and paints a ProseMirror node decoration on the
+ row/column being dragged. `useTableDragImage` builds a cloned snapshot of
+ that same row/column and swaps it in as the native drag image via
+ `DataTransfer.setDragImage`.
+2. **Drag over** - BlockNote's existing drop-cursor decoration renders as
+ normal (just recolored via CSS); the source decoration stays as the drag
+ continues, since it's keyed off the drag's _original_ index, not the
+ current hover target.
+3. **Drop / dragend** - BlockNote clears its `draggingState` and dispatches
+ the move as a normal transaction either way. Because the source
+ decoration is derived from that same state, it disappears the instant
+ `draggingState` is cleared - on a successful drop **and** on a cancelled
+ drag (e.g. `Escape`), since both go through `dragEnd()` set to `undefined`/`null`.
+
+## 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 pre-existing gaps in
+ BlockNote's table-drag feature as a whole, not something this example
+ introduces or fixes - building either would be a separate, larger feature
+ for BlockNote's core drag system.
+- **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.
+- **Merged cells**: the source-highlight decoration resolves cells by plain
+ row/column index, which doesn't account for `colspan`/`rowspan` shifting
+ indices. In practice BlockNote's own `canRowBeDraggedInto` /
+ `canColumnBeDraggedInto` guards already block most drags across a merged
+ cell, so this mainly affects highlighting fidelity in edge cases, not
+ document correctness - see the "merged (rowspan) cell" test.
+- **Concurrent edits mid-drag**: confirmed via manual repro, this is worse
+ than it first looked. BlockNote's `TableHandlesView` stores `tablePos`
+ (and the table content snapshot used by `dropHandler`) once per
+ `mousemove`, and never remaps them through `tr.mapping`. `mousemove`
+ doesn't fire on the dragged-over element during a native drag, so any
+ transaction that changes the document elsewhere while a drag is in
+ progress - a concurrent local or collaborative edit - leaves both stale.
+ The _next_ `dragover` recomputes BlockNote's own drop-cursor decoration
+ from that stale `tablePos` and throws (`RangeError`, confirmed), not just
+ "drops silently overwrite a concurrent change" as previously stated here.
+ `tableDragSourceExtension.ts`'s own plugin state now remaps `tablePos`
+ through `tr.mapping` so it doesn't share this specific failure mode, but
+ there's no way to verify that in an end-to-end test while BlockNote's own
+ decoration throws first in the same view update. This is pre-existing
+ BlockNote core behavior this example doesn't introduce - see the PR
+ discussion for the upstream report.
+
+## Tests
+
+`tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx` covers
+the parts this example actually adds: source-highlight + drop-cursor
+appearance during a drag, per-cell tinting for column drags, cleanup on a
+cancelled drag, dragging a row with rich (bold) inline content, dragging a
+column across a merged cell, and the `/table` header-row default. It
+doesn't re-test BlockNote's own move/reorder logic, which is already
+covered by `tables.test.tsx`, and it doesn't cover the concurrent-edit
+scenario above - see that section for why.
+
+## How It Works
+
+- `tableDragSourceExtension.ts` adds a small ProseMirror plugin that reads
+ the same transaction metadata BlockNote's own `TableHandlesExtension`
+ uses for its drop-cursor, and applies a node decoration to the row/column
+ being dragged _from_. Using a decoration (not a direct DOM class mutation)
+ matters: ProseMirror's table view can redraw independently of React, and
+ a plain DOM mutation gets silently discarded on the next redraw.
+- `useTableDragImage.ts` swaps BlockNote's hidden 1x1 native drag image for
+ a cloned snapshot of the row/column, styled like a lifted card, via the
+ standard `DataTransfer.setDragImage` API.
+- `tableStyles.css` restyles the table itself and the drop-cursor color.
+- `App.tsx` overrides the default `/table` slash-menu item so new tables
+ start with `headerRows: 1`.
+
+**Relevant Docs:**
+
+- [Tables](/docs/features/blocks/tables)
+- [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..fcbbd93063
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ 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..f0deafb163
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@blocknote/example-ui-components-table-reordering-visualization",
+ "description": "Enhanced visual feedback for table row/column drag-and-drop reordering.",
+ "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",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-view": "^1.41.4",
+ "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..4552c3bab5
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx
@@ -0,0 +1,91 @@
+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 { TableDragSourceExtension } from "./tableDragSourceExtension";
+import "./tableStyles.css";
+import { useTableDragImage } from "./useTableDragImage";
+
+// 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,
+ },
+ extensions: [TableDragSourceExtension()],
+ initialContent: [
+ {
+ type: "heading",
+ props: { level: 2 },
+ content: "Enriched Reordering Visualization for BlockNote.js Tables",
+ },
+ {
+ 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"] },
+ ],
+ },
+ },
+ ],
+ });
+
+ useTableDragImage(editor);
+
+ return (
+
+
+ filterSuggestionItems(getCustomSlashMenuItems(editor), query)
+ }
+ />
+
+ );
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts b/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts
new file mode 100644
index 0000000000..5b3494d203
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts
@@ -0,0 +1,123 @@
+import { createExtension } from "@blocknote/core";
+import { tableHandlesPluginKey } from "@blocknote/core/extensions";
+import { Plugin, PluginKey } from "prosemirror-state";
+import { Decoration, DecorationSet } from "prosemirror-view";
+
+const SOURCE_ROW_CLASS = "bn-table-drag-source-row";
+const SOURCE_COL_CLASS = "bn-table-drag-source-col";
+
+type DragSourceMeta = {
+ draggedCellOrientation: "row" | "col";
+ originalIndex: number;
+ tablePos: number;
+};
+
+const pluginKey = new PluginKey(
+ "tableDragSourceHighlight",
+);
+
+/**
+ * BlockNote's TableHandlesExtension decorates the drop *target* while
+ * dragging a row/column (the `bn-table-drop-cursor` widget), but has no
+ * equivalent for the row/column being dragged *from*, which makes it hard to
+ * tell what's actually moving. This mirrors that mechanism for the source
+ * side: it reads the same `tableHandlesPluginKey` transaction meta
+ * (`{draggedCellOrientation, originalIndex, tablePos}` on drag start, `null`
+ * on drag end, a bare `true` "redraw the decorations" ping while hovering)
+ * and applies a node decoration - not a direct DOM class mutation, which
+ * ProseMirror's table NodeView can silently discard on redraw - so the
+ * highlight survives every dragover-triggered decoration recompute.
+ */
+export const TableDragSourceExtension = createExtension(() => ({
+ key: "tableDragSourceHighlight",
+ prosemirrorPlugins: [
+ new Plugin({
+ key: pluginKey,
+ state: {
+ init: () => null,
+ apply(tr, prev) {
+ const meta = tr.getMeta(tableHandlesPluginKey);
+ if (meta === null) {
+ return null;
+ }
+ if (
+ meta &&
+ typeof meta === "object" &&
+ typeof (meta as DragSourceMeta).tablePos === "number" &&
+ typeof (meta as DragSourceMeta).originalIndex === "number"
+ ) {
+ return meta as DragSourceMeta;
+ }
+ if (!prev || !tr.docChanged) {
+ return prev;
+ }
+ // A transaction changed the document without setting our meta -
+ // e.g. a concurrent local or collaborative edit elsewhere in the
+ // doc while a drag is in progress. Remap the stored position
+ // through it instead of letting it go stale, so the highlight
+ // keeps tracking the table rather than just disappearing on the
+ // next `decorations()` call.
+ return { ...prev, tablePos: tr.mapping.map(prev.tablePos) };
+ },
+ },
+ props: {
+ decorations(state) {
+ const dragState = pluginKey.getState(state);
+ if (!dragState) {
+ return null;
+ }
+
+ const { draggedCellOrientation, originalIndex, tablePos } = dragState;
+
+ // `tablePos` is captured once at drag-start and isn't remapped
+ // against later transactions (matching BlockNote's own drop-cursor
+ // decoration, which has the same limitation). If a concurrent edit
+ // - locally or from another collaborator - shifts or removes the
+ // table while a drag is in progress, this resolve() would throw
+ // instead of just skipping the decoration; bail out safely instead.
+ let tableResolvedPos;
+ try {
+ tableResolvedPos = state.doc.resolve(tablePos + 1);
+ } catch {
+ return null;
+ }
+ const tableNode = tableResolvedPos.node();
+ if (tableNode.type.name !== "table") {
+ return null;
+ }
+ const decorations: Decoration[] = [];
+
+ if (draggedCellOrientation === "row") {
+ const rowNode = tableNode.maybeChild(originalIndex);
+ if (rowNode) {
+ const rowStart = tableResolvedPos.posAtIndex(originalIndex);
+ decorations.push(
+ Decoration.node(rowStart, rowStart + rowNode.nodeSize, {
+ class: SOURCE_ROW_CLASS,
+ }),
+ );
+ }
+ } else {
+ for (let row = 0; row < tableNode.childCount; row++) {
+ const rowNode = tableNode.child(row);
+ const cellNode = rowNode.maybeChild(originalIndex);
+ if (!cellNode) {
+ continue;
+ }
+ const rowStart = tableResolvedPos.posAtIndex(row);
+ const rowResolvedPos = state.doc.resolve(rowStart + 1);
+ const cellStart = rowResolvedPos.posAtIndex(originalIndex);
+ decorations.push(
+ Decoration.node(cellStart, cellStart + cellNode.nodeSize, {
+ class: SOURCE_COL_CLASS,
+ }),
+ );
+ }
+ }
+
+ return DecorationSet.create(state.doc, decorations);
+ },
+ },
+ }),
+ ],
+}));
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..56b3d3ec54
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css
@@ -0,0 +1,76 @@
+/**
+ * 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 #e2e2ea;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+ overflow: hidden;
+}
+.bn-editor [data-content-type="table"] th,
+.bn-editor [data-content-type="table"] td {
+ border: none;
+ border-right: 1px solid #e2e2ea;
+ border-bottom: 1px solid #e2e2ea;
+ 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: #f0f0f3;
+ color: #5d5d70;
+ font-weight: 600;
+ font-size: 0.8125em;
+ letter-spacing: 0.01em;
+}
+.bn-editor [data-content-type="table"] tr:hover > td {
+ background-color: #d3d4e0;
+}
+.bn-editor [data-content-type="table"] .selectedCell:after {
+ background: #eef1fa;
+ opacity: 0.6;
+}
+
+/**
+ * Row/column reordering: make the dragged row/column and the drop
+ * target clearly distinguishable from one another and from a plain
+ * hover/selection.
+ */
+.bn-editor [data-content-type="table"] tr.bn-table-drag-source-row > td,
+.bn-editor [data-content-type="table"] tr.bn-table-drag-source-row > th,
+.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: #eef1fa;
+ outline: 1.5px dashed #ced3f1;
+ outline-offset: -1.5px;
+}
+.bn-editor [data-content-type="table"] .bn-table-drop-cursor {
+ background-color: #5e5cd0;
+ border-radius: 2px;
+}
+
+/* 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: #eef1fa;
+ color: #5e5cd0;
+}
diff --git a/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts b/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts
new file mode 100644
index 0000000000..0da8c9b64b
--- /dev/null
+++ b/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts
@@ -0,0 +1,164 @@
+import { BlockNoteEditor, getNodeById } from "@blocknote/core";
+import { TableHandlesExtension } from "@blocknote/core/extensions";
+import { useEffect } from "react";
+
+const DRAG_IMAGE_STYLE = `
+ border-collapse: separate;
+ border-spacing: 0;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.1);
+ transform: rotate(-1deg);
+ overflow: hidden;
+`;
+
+const cloneCellWithSize = (cell: Element): HTMLElement => {
+ const rect = cell.getBoundingClientRect();
+ const clone = cell.cloneNode(true) as HTMLElement;
+ clone.style.width = `${rect.width}px`;
+ clone.style.height = `${rect.height}px`;
+ clone.style.boxSizing = "border-box";
+ // This clone is detached from the table's own stylesheet scope, so its
+ // cell borders need to be set inline.
+ clone.style.border = "1.5px solid #5e5cd0";
+ // Same reason as the border above: regular cells lose the real table's
+ // padding once detached, leaving text flush against the left edge.
+ if (clone.tagName === "TD") {
+ clone.style.paddingLeft = "16px";
+ }
+ // Header cells lose their muted background for the same reason; copy the
+ // real, already-rendered color instead of guessing which token backs it.
+ if (clone.tagName === "TH") {
+ clone.style.backgroundColor = getComputedStyle(cell).backgroundColor;
+ }
+ return clone;
+};
+
+const buildRowDragImage = (sourceRow: HTMLTableRowElement): HTMLElement => {
+ const table = document.createElement("table");
+ table.style.cssText = DRAG_IMAGE_STYLE;
+ const tbody = document.createElement("tbody");
+ const rowClone = document.createElement("tr");
+ Array.from(sourceRow.children).forEach((cell) => {
+ rowClone.appendChild(cloneCellWithSize(cell));
+ });
+ tbody.appendChild(rowClone);
+ table.appendChild(tbody);
+ return table;
+};
+
+const buildColumnDragImage = (
+ rows: HTMLTableRowElement[],
+ colIndex: number,
+): HTMLElement => {
+ const table = document.createElement("table");
+ table.style.cssText = DRAG_IMAGE_STYLE;
+ const tbody = document.createElement("tbody");
+ rows.forEach((row) => {
+ const cell = row.children[colIndex];
+ if (!cell) {
+ return;
+ }
+ const rowClone = document.createElement("tr");
+ rowClone.appendChild(cloneCellWithSize(cell));
+ tbody.appendChild(rowClone);
+ });
+ table.appendChild(tbody);
+ return table;
+};
+
+/**
+ * BlockNote drags table rows/columns with a hidden 1x1 native drag image
+ * (see TableHandlesExtension), so nothing visibly follows the cursor - the
+ * only feedback is the drop-cursor line and (with TableDragSourceExtension)
+ * a tint on the source. This adds a real drag image: a cloned snapshot of
+ * the row/column, styled like a lifted card, so the drag actually looks like
+ * you're carrying the row/column to its new position (Loop/Notion-style).
+ *
+ * It has to live on `document` in the bubble phase: BlockNote's own
+ * `dragstart` handler (which sets the hidden image and populates
+ * `draggingState`) runs when the native event reaches React's root, and a
+ * later `setDragImage` call always wins over an earlier one in the same
+ * `dragstart` - so this must observe the event *after* React's handler,
+ * which "after everything else, at the top of the bubble chain" guarantees
+ * regardless of where React's root happens to sit in the DOM.
+ */
+export const useTableDragImage = (editor: BlockNoteEditor) => {
+ useEffect(() => {
+ const handleDragStart = (event: DragEvent) => {
+ const target = event.target;
+ if (
+ !(target instanceof Element) ||
+ !target.closest(".bn-table-handle") ||
+ !event.dataTransfer
+ ) {
+ return;
+ }
+
+ const tableHandles = editor.getExtension(TableHandlesExtension);
+ const state = tableHandles?.store?.state;
+ const draggingState = state?.draggingState;
+ if (!state || !draggingState) {
+ return;
+ }
+
+ // Resolve the table's DOM node deterministically via its stable block
+ // ID, rather than hit-testing a point in `referencePosTable` - a
+ // handle, floating toolbar, or any other overlay covering that exact
+ // pixel would make `elementFromPoint` return the wrong element (or
+ // none), silently dropping the drag image.
+ const nodePosInfo = getNodeById(
+ state.block.id,
+ editor.prosemirrorState.doc,
+ );
+ if (!nodePosInfo) {
+ return;
+ }
+ const tableNode = editor.prosemirrorView.domAtPos(
+ nodePosInfo.posBeforeNode + 2,
+ ).node;
+ const table = (
+ tableNode instanceof Element ? tableNode : tableNode.parentElement
+ )?.closest("table");
+ if (!table) {
+ return;
+ }
+
+ const rows = Array.from(table.rows);
+ const { draggedCellOrientation, originalIndex } = draggingState;
+
+ const dragImage =
+ draggedCellOrientation === "row"
+ ? rows[originalIndex] &&
+ buildRowDragImage(rows[originalIndex] as HTMLTableRowElement)
+ : buildColumnDragImage(rows as HTMLTableRowElement[], originalIndex);
+ if (!dragImage) {
+ return;
+ }
+
+ // Positioned on-screen but invisible, rather than pushed far outside
+ // the viewport: some browsers skip rendering/rasterizing elements
+ // placed way off-screen, which would make the native drag-image
+ // capture silently produce a blank image.
+ dragImage.style.position = "fixed";
+ dragImage.style.top = "0";
+ dragImage.style.left = "0";
+ dragImage.style.opacity = "0.01";
+ dragImage.style.pointerEvents = "none";
+
+ try {
+ document.body.appendChild(dragImage);
+ event.dataTransfer.setDragImage(dragImage, 16, 16);
+ } finally {
+ setTimeout(() => {
+ dragImage.remove();
+ }, 0);
+ }
+ };
+
+ document.addEventListener("dragstart", handleDragStart);
+ return () => {
+ document.removeEventListener("dragstart", handleDragStart);
+ };
+ }, [editor]);
+};
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..8a4689b6bf
--- /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/pnpm-lock.yaml b/pnpm-lock.yaml
index 090c217f93..b67c789e2d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2258,6 +2258,55 @@ importers:
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/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)
+ prosemirror-state:
+ specifier: ^1.4.4
+ version: 1.4.4
+ prosemirror-view:
+ specifier: ^1.41.4
+ version: 1.41.8
+ 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':
@@ -27212,7 +27261,7 @@ snapshots:
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
- tinyexec: 1.0.4
+ tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@20.19.37)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
@@ -27242,7 +27291,7 @@ snapshots:
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
- tinyexec: 1.0.4
+ tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
diff --git a/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx
new file mode 100644
index 0000000000..85b2a6f5d4
--- /dev/null
+++ b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx
@@ -0,0 +1,408 @@
+import TableReorderingApp from "@examples/03-ui-components/21-table-reordering-visualization/src/App";
+import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+import { browserName, userEvent } from "../../utils/context.js";
+import { EDITOR_SELECTOR, TABLE_SELECTOR } from "../../utils/const.js";
+import { waitForSelector } from "../../utils/editor.js";
+import { mouseSequence, moveMouseOverElement } from "../../utils/mouse.js";
+import { executeSlashCommand } from "../../utils/slashmenu.js";
+
+// This example lives at examples/03-ui-components/21-table-reordering-visualization.
+// It adds two ProseMirror-decoration-based enhancements on top of BlockNote's
+// own table drag handles (a tint on the row/column being dragged, and a real
+// floating drag image instead of the default invisible one), plus a
+// slash-menu override so new tables default to a header row. These tests
+// cover the parts that enhancement actually touches; they don't re-test
+// BlockNote's own move/reorder logic (already 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";
+
+async function getRowHandle(cell: HTMLElement): Promise {
+ await moveMouseOverElement(cell);
+ return vi.waitFor(() => {
+ const candidate = Array.from(
+ document.querySelectorAll(".bn-table-handle"),
+ ).find((el) => !el.style.transform.includes("rotate"));
+ if (!candidate) {
+ throw new Error("Row drag handle not visible");
+ }
+ return candidate;
+ });
+}
+
+async function getColumnHandle(cell: HTMLElement): Promise {
+ await moveMouseOverElement(cell);
+ return vi.waitFor(() => {
+ const candidate = Array.from(
+ document.querySelectorAll(".bn-table-handle"),
+ ).find((el) => el.style.transform.includes("rotate"));
+ if (!candidate) {
+ throw new Error("Column drag 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 };
+}
+
+beforeEach(async () => {
+ await render();
+ await waitForSelector(EDITOR_SELECTOR);
+ await waitForSelector(TABLE_SELECTOR);
+});
+
+describe("Table reordering visualization", () => {
+ test.skipIf(skipDrag)(
+ "dragging a row tints it and shows a colored drop cursor",
+ async () => {
+ const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`);
+ const cell = rows[1].querySelector("td") as HTMLElement;
+ const handle = await getRowHandle(cell);
+ const handleCenter = centerOf(handle);
+
+ await mouseSequence([
+ { type: "move", x: handleCenter.x, y: handleCenter.y, steps: 5 },
+ { type: "down" },
+ ]);
+
+ // Move onto a different row to trigger the drop-cursor decoration and
+ // confirm the source row is tinted while the drag is in progress.
+ const targetRow = rows[3].querySelector("td") as HTMLElement;
+ const targetCenter = centerOf(targetRow);
+ await mouseSequence([
+ { type: "move", x: targetCenter.x, y: targetCenter.y, steps: 10 },
+ ]);
+
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(".bn-table-drag-source-row").length === 0
+ ) {
+ throw new Error("Source row not tinted yet");
+ }
+ });
+ await vi.waitFor(() => {
+ if (document.querySelectorAll(".bn-table-drop-cursor").length === 0) {
+ throw new Error("Drop cursor not shown yet");
+ }
+ });
+
+ await mouseSequence([{ type: "up" }]);
+
+ // Both decorations are transient - once the drop completes, neither
+ // should remain on any row/column. Cleanup runs off a `dragend`/state
+ // update, not synchronously with the mouseup, so wait for it.
+ await vi.waitFor(() => {
+ expect(
+ document.querySelectorAll(".bn-table-drag-source-row"),
+ ).toHaveLength(0);
+ expect(document.querySelectorAll(".bn-table-drop-cursor")).toHaveLength(
+ 0,
+ );
+ });
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "dragging a column tints every cell in that column",
+ async () => {
+ const firstRowCells = document.querySelectorAll(
+ `${TABLE_SELECTOR} tbody tr:first-child td, ${TABLE_SELECTOR} tbody tr:first-child th`,
+ );
+ const cell = firstRowCells[0] as HTMLElement;
+ const handle = await getColumnHandle(cell);
+ const handleCenter = centerOf(handle);
+
+ await mouseSequence([
+ { type: "move", x: handleCenter.x, y: handleCenter.y, steps: 5 },
+ { type: "down" },
+ ]);
+
+ const targetCell = firstRowCells[2] as HTMLElement;
+ const targetCenter = centerOf(targetCell);
+ await mouseSequence([
+ { type: "move", x: targetCenter.x, y: targetCenter.y, steps: 10 },
+ ]);
+
+ const rowCount = document.querySelectorAll(
+ `${TABLE_SELECTOR} tbody tr`,
+ ).length;
+ await vi.waitFor(() => {
+ const marked = document.querySelectorAll(".bn-table-drag-source-col");
+ if (marked.length !== rowCount) {
+ throw new Error(
+ `Expected ${rowCount} tinted cells, got ${marked.length}`,
+ );
+ }
+ });
+
+ await mouseSequence([{ type: "up" }]);
+ await vi.waitFor(() => {
+ expect(
+ document.querySelectorAll(".bn-table-drag-source-col"),
+ ).toHaveLength(0);
+ });
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "cancelling a drag with Escape still cleans up the tint",
+ async () => {
+ const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`);
+ const cell = rows[1].querySelector("td") as HTMLElement;
+ const handle = await getRowHandle(cell);
+ const handleCenter = centerOf(handle);
+
+ await mouseSequence([
+ { type: "move", x: handleCenter.x, y: handleCenter.y, steps: 5 },
+ { type: "down" },
+ ]);
+ const targetRow = rows[2].querySelector("td") as HTMLElement;
+ const targetCenter = centerOf(targetRow);
+ await mouseSequence([
+ { type: "move", x: targetCenter.x, y: targetCenter.y, steps: 10 },
+ ]);
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(".bn-table-drag-source-row").length === 0
+ ) {
+ throw new Error("Source row not tinted yet");
+ }
+ });
+
+ // Escape cancels a native HTML5 drag: the browser fires `dragend`
+ // without a `drop`. Our cleanup is tied to the same lifecycle BlockNote
+ // itself uses (`dragEnd()`), so it should fire here too.
+ await userEvent.keyboard("{Escape}");
+ // Release the mouse button so it doesn't leak into the next test.
+ await mouseSequence([{ type: "up" }]);
+
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(".bn-table-drag-source-row").length !== 0
+ ) {
+ throw new Error("Tint was not cleaned up after cancelled drag");
+ }
+ });
+ },
+ );
+
+ test.skipIf(skipDrag)(
+ "dragging a row with rich/nested cell content doesn't throw",
+ async () => {
+ // Put the text cursor in the first data row and format it, to give the
+ // dragged row non-trivial (bold) inline content rather than plain text.
+ const rows = document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`);
+ const cell = rows[1].querySelector("td") as HTMLElement;
+ await userEvent.click(cell);
+ await userEvent.keyboard("{Control>}a{/Control}");
+ await userEvent.keyboard("{Control>}b{/Control}");
+
+ const handle = await getRowHandle(cell);
+ const handleCenter = centerOf(handle);
+ await mouseSequence([
+ { type: "move", x: handleCenter.x, y: handleCenter.y, steps: 5 },
+ { type: "down" },
+ ]);
+ const targetRow = rows[2].querySelector("td") as HTMLElement;
+ const targetCenter = centerOf(targetRow);
+ await mouseSequence([
+ { type: "move", x: targetCenter.x, y: targetCenter.y, steps: 10 },
+ { type: "up" },
+ ]);
+
+ // No assertion beyond "didn't throw" - vitest-browser surfaces any
+ // uncaught page error as a test failure on its own.
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(".bn-table-drag-source-row").length !== 0
+ ) {
+ throw new Error("Tint should have cleared after the drop");
+ }
+ });
+ },
+ );
+
+ // Not covered by an automated test: BlockNote's own TableHandlesExtension
+ // stores `view.tablePos` (and `state.block`) once per mousemove and never
+ // remaps them through `tr.mapping`. A transaction that changes the
+ // document elsewhere while a drag is in progress - a concurrent local or
+ // collaborative edit - leaves them stale; the *next* dragover recomputes
+ // BlockNote's own drop-cursor decoration from that stale position and
+ // throws (confirmed via a manual repro: dispatching an unrelated
+ // transaction mid-drag throws a RangeError out of
+ // `TableHandles.ts`'s `decorations()`, before our own plugin's
+ // decorations ever run in that same view update). Our `tr.mapping` fix
+ // above keeps *our* plugin's state correct for when this is fixed
+ // upstream, but there's no way to exercise it in isolation while core's
+ // own code throws first - see the PR discussion for the upstream report.
+
+ test.skipIf(skipDrag)(
+ "dragging a column with a merged (rowspan) cell doesn't throw",
+ async () => {
+ // Build a deterministic 3-row x 2-col table where the first cell of
+ // column 0 spans 2 rows, the same way tables.test.tsx's row-drag test
+ // seeds a deterministic table directly via ProseMirror rather than
+ // driving the merge-cells UI.
+ const cellAttrs = {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ colspan: 1,
+ rowspan: 1,
+ colwidth: null,
+ };
+ const mergedCellAttrs = { ...cellAttrs, rowspan: 2 };
+ const rowsContent = [
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ attrs: mergedCellAttrs,
+ content: [
+ {
+ type: "tableParagraph",
+ content: [{ type: "text", text: "Merged" }],
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ attrs: cellAttrs,
+ content: [
+ {
+ type: "tableParagraph",
+ content: [{ type: "text", text: "R1C2" }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ attrs: cellAttrs,
+ content: [
+ {
+ type: "tableParagraph",
+ content: [{ type: "text", text: "R2C2" }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ attrs: cellAttrs,
+ content: [
+ {
+ type: "tableParagraph",
+ content: [{ type: "text", text: "R3C1" }],
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ attrs: cellAttrs,
+ content: [
+ {
+ type: "tableParagraph",
+ content: [{ type: "text", text: "R3C2" }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ (
+ window as unknown as {
+ ProseMirror: { commands: { setContent: (doc: unknown) => void } };
+ }
+ ).ProseMirror.commands.setContent({
+ type: "doc",
+ content: [
+ {
+ type: "blockGroup",
+ content: [
+ {
+ type: "blockContainer",
+ attrs: { id: "0" },
+ content: [
+ {
+ type: "table",
+ attrs: { textColor: "default" },
+ content: rowsContent,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`).length !== 3
+ ) {
+ throw new Error("Table not yet replaced");
+ }
+ });
+
+ // Drag column 1 (the non-merged column) across the merged column.
+ const secondColCell = document.querySelectorAll(
+ `${TABLE_SELECTOR} tbody tr:first-child td`,
+ )[1] as HTMLElement;
+ const handle = await getColumnHandle(secondColCell);
+ const handleCenter = centerOf(handle);
+ await mouseSequence([
+ { type: "move", x: handleCenter.x, y: handleCenter.y, steps: 5 },
+ { type: "down" },
+ ]);
+ const firstColCell = document.querySelectorAll(
+ `${TABLE_SELECTOR} tbody tr:first-child td`,
+ )[0] as HTMLElement;
+ const targetCenter = centerOf(firstColCell);
+ await mouseSequence([
+ { type: "move", x: targetCenter.x, y: targetCenter.y, steps: 10 },
+ { type: "up" },
+ ]);
+
+ // No assertion beyond "didn't throw" - vitest-browser surfaces any
+ // uncaught page error as a test failure on its own. BlockNote's own
+ // canColumnBeDraggedInto guard is expected to block this move (you
+ // can't drag a column across one containing a rowspan cell), so we
+ // only assert the table wasn't left in a broken/empty state.
+ await vi.waitFor(() => {
+ if (
+ document.querySelectorAll(`${TABLE_SELECTOR} tbody tr`).length !== 3
+ ) {
+ throw new Error("Table should still have 3 rows after the drag");
+ }
+ });
+ },
+ );
+
+ test("/table defaults to a header row", async () => {
+ await userEvent.click(document.querySelector(EDITOR_SELECTOR)!);
+ await userEvent.keyboard("{Control>}{End}{/Control}");
+ await executeSlashCommand("table");
+
+ await vi.waitFor(() => {
+ const headerCells = document.querySelectorAll(
+ `${TABLE_SELECTOR} thead th, ${TABLE_SELECTOR} tbody tr:first-child th`,
+ );
+ if (headerCells.length === 0) {
+ throw new Error("New table has no header row");
+ }
+ });
+ });
+});