Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"playground": true,
"docs": false,
"author": "must",
"tags": [
"Intermediate",
"UI Components",
"Tables",
"Drag & Drop",
"Appearance & Styling"
]
}
107 changes: 107 additions & 0 deletions examples/03-ui-components/21-table-reordering-visualization/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Table Reordering Visualization</title>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Original file line number Diff line number Diff line change
@@ -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:"
}
}
Original file line number Diff line number Diff line change
@@ -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<any, any, any>,
): 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 (
<BlockNoteView editor={editor} slashMenu={false}>
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query) =>
filterSuggestionItems(getCustomSlashMenuItems(editor), query)
}
/>
</BlockNoteView>
);
}
Original file line number Diff line number Diff line change
@@ -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<DragSourceMeta | null>(
"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<DragSourceMeta | null>({
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) };
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
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);
},
},
}),
],
}));
Loading