From 5ea838c818c905243296b8ef75cb0e2448922594 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 08:37:06 +0300 Subject: [PATCH 1/4] feat(examples): add table reordering visualization example Ports the enhanced table drag-and-drop feedback originally built as a customization on top of La Suite Docs into a standalone BlockNote.js example, using only public BlockNote/ProseMirror APIs and plain colors (no external design-token dependency): - Restyled tables: rounded card look, muted header row, hairline borders, row-hover highlight. - Drag source highlight: the row/column being dragged is tinted and outlined via a ProseMirror decoration (survives redraws, unlike a direct DOM class mutation). - Colored drop-position indicator. - Floating drag image: a real snapshot of the row/column follows the cursor, replacing BlockNote's default hidden native drag image. - New tables via "/table" now default to a header row, so the header styling is visible immediately instead of requiring a manual toggle. Co-Authored-By: Claude Sonnet 5 --- .../.bnexample.json | 12 ++ .../README.md | 37 +++++ .../index.html | 14 ++ .../main.tsx | 11 ++ .../package.json | 32 ++++ .../src/App.tsx | 91 +++++++++++ .../src/tableDragSourceExtension.ts | 95 ++++++++++++ .../src/tableStyles.css | 76 +++++++++ .../src/useTableDragImage.ts | 144 ++++++++++++++++++ .../src/vite-env.d.ts | 1 + .../tsconfig.json | 29 ++++ .../vite-env.d.ts | 1 + .../vite.config.ts | 31 ++++ pnpm-lock.yaml | 53 ++++++- 14 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 examples/03-ui-components/21-table-reordering-visualization/.bnexample.json create mode 100644 examples/03-ui-components/21-table-reordering-visualization/README.md create mode 100644 examples/03-ui-components/21-table-reordering-visualization/index.html create mode 100644 examples/03-ui-components/21-table-reordering-visualization/main.tsx create mode 100644 examples/03-ui-components/21-table-reordering-visualization/package.json create mode 100644 examples/03-ui-components/21-table-reordering-visualization/src/App.tsx create mode 100644 examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts create mode 100644 examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css create mode 100644 examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts create mode 100644 examples/03-ui-components/21-table-reordering-visualization/src/vite-env.d.ts create mode 100644 examples/03-ui-components/21-table-reordering-visualization/tsconfig.json create mode 100644 examples/03-ui-components/21-table-reordering-visualization/vite-env.d.ts create mode 100644 examples/03-ui-components/21-table-reordering-visualization/vite.config.ts 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..f77dbcb52a --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/README.md @@ -0,0 +1,37 @@ +# 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. + +## 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..24dca1c75e --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/index.html @@ -0,0 +1,14 @@ + + + + + Table Reordering Visualization + + + +
+ + + diff --git a/examples/03-ui-components/21-table-reordering-visualization/main.tsx b/examples/03-ui-components/21-table-reordering-visualization/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/03-ui-components/21-table-reordering-visualization/package.json b/examples/03-ui-components/21-table-reordering-visualization/package.json new file mode 100644 index 0000000000..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..58056e2c8c --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts @@ -0,0 +1,95 @@ +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") { + return meta as DragSourceMeta; + } + return prev; + }, + }, + props: { + decorations(state) { + const dragState = pluginKey.getState(state); + if (!dragState) { + return null; + } + + const { draggedCellOrientation, originalIndex, tablePos } = dragState; + + const tableResolvedPos = state.doc.resolve(tablePos + 1); + const tableNode = tableResolvedPos.node(); + 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..beb0cb23cd --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts @@ -0,0 +1,144 @@ +import { BlockNoteEditor } 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; + } + + const anchorEl = document.elementFromPoint( + state.referencePosTable.x + 1, + state.referencePosTable.y + 1, + ); + const table = anchorEl?.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; + } + + dragImage.style.position = "fixed"; + dragImage.style.top = "-9999px"; + dragImage.style.left = "-9999px"; + dragImage.style.pointerEvents = "none"; + document.body.appendChild(dragImage); + + event.dataTransfer.setDragImage(dragImage, 16, 16); + + 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..0133a6da9e --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/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) From ceedb6b84d56c37f2f6e034df187304e0093faa5 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 09:19:50 +0300 Subject: [PATCH 2/4] test(tables): cover the table-reordering-visualization example Adds e2e coverage for the parts the new example actually changes: - source-highlight + colored drop-cursor appearance during row/column drags - per-cell tinting for column drags - cleanup after a cancelled (Escape) drag - dragging a row with rich inline content - dragging a column across a merged (rowspan) cell - the /table slash command defaulting new tables to a header row Also documents the interaction model and known limitations (no keyboard/touch reordering, no focus-restoration path, merged-cell index fidelity, and stale-snapshot behavior on concurrent edits mid-drag) in the example's README, since those are pre-existing characteristics of BlockNote's own table-drag implementation that this example doesn't introduce or change. Co-Authored-By: Claude Sonnet 5 --- .../README.md | 60 +++ .../tableReorderingVisualization.test.tsx | 389 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx diff --git a/examples/03-ui-components/21-table-reordering-visualization/README.md b/examples/03-ui-components/21-table-reordering-visualization/README.md index f77dbcb52a..8d36791907 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/README.md +++ b/examples/03-ui-components/21-table-reordering-visualization/README.md @@ -15,6 +15,66 @@ than BlockNote's default, matching the feel of tools like Microsoft Loop: - **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**: BlockNote's `dropHandler` snapshots the + table's content once at drag-start and doesn't refresh it while the drag + is in progress (only `mousemove`, which stops firing on the dragged-over + element during a native drag, triggers a refresh). If another + collaborator edits the same table while a drag is in progress, the drop + can overwrite their change with the pre-drag snapshot. This is existing + BlockNote core behavior this example doesn't touch or change. + +## 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`. + ## How It Works - `tableDragSourceExtension.ts` adds a small ProseMirror plugin that reads 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..ef331bb271 --- /dev/null +++ b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx @@ -0,0 +1,389 @@ +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. + 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" }]); + 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"); + } + }); + }, + ); + + 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"); + } + }); + }); +}); From 7af99b6bb1cb5aff19fa68a264644057022d31e6 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 10:08:50 +0300 Subject: [PATCH 3/4] fix(examples): address CodeRabbit review findings on #2920 - vite.config.ts: fix the local-source alias path (was 2 levels up, needed 3 to actually reach packages/core|react/src - tsconfig.json already had the correct depth, so this was a silent no-op before, always falling back to node_modules resolution) - index.html: add missing , move the generator marker comment out of the +
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 index 58056e2c8c..cb9c5fdf48 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts +++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts @@ -55,8 +55,22 @@ export const TableDragSourceExtension = createExtension(() => ({ const { draggedCellOrientation, originalIndex, tablePos } = dragState; - const tableResolvedPos = state.doc.resolve(tablePos + 1); + // `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") { 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 index beb0cb23cd..0da8c9b64b 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts +++ b/examples/03-ui-components/21-table-reordering-visualization/src/useTableDragImage.ts @@ -1,4 +1,4 @@ -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, getNodeById } from "@blocknote/core"; import { TableHandlesExtension } from "@blocknote/core/extensions"; import { useEffect } from "react"; @@ -102,11 +102,24 @@ export const useTableDragImage = (editor: BlockNoteEditor) => { return; } - const anchorEl = document.elementFromPoint( - state.referencePosTable.x + 1, - state.referencePosTable.y + 1, + // 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, ); - const table = anchorEl?.closest("table"); + 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; } @@ -123,17 +136,24 @@ export const useTableDragImage = (editor: BlockNoteEditor) => { 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 = "-9999px"; - dragImage.style.left = "-9999px"; + dragImage.style.top = "0"; + dragImage.style.left = "0"; + dragImage.style.opacity = "0.01"; dragImage.style.pointerEvents = "none"; - document.body.appendChild(dragImage); - event.dataTransfer.setDragImage(dragImage, 16, 16); - - setTimeout(() => { - dragImage.remove(); - }, 0); + try { + document.body.appendChild(dragImage); + event.dataTransfer.setDragImage(dragImage, 16, 16); + } finally { + setTimeout(() => { + dragImage.remove(); + }, 0); + } }; document.addEventListener("dragstart", handleDragStart); 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 index 0133a6da9e..8a4689b6bf 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts +++ b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts @@ -13,18 +13,18 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !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/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx index ef331bb271..ebc74ab5b8 100644 --- a/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx +++ b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx @@ -94,13 +94,16 @@ describe("Table reordering visualization", () => { await mouseSequence([{ type: "up" }]); // Both decorations are transient - once the drop completes, neither - // should remain on any row/column. - expect( - document.querySelectorAll(".bn-table-drag-source-row"), - ).toHaveLength(0); - expect(document.querySelectorAll(".bn-table-drop-cursor")).toHaveLength( - 0, - ); + // 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, + ); + }); }, ); @@ -138,9 +141,11 @@ describe("Table reordering visualization", () => { }); await mouseSequence([{ type: "up" }]); - expect( - document.querySelectorAll(".bn-table-drag-source-col"), - ).toHaveLength(0); + await vi.waitFor(() => { + expect( + document.querySelectorAll(".bn-table-drag-source-col"), + ).toHaveLength(0); + }); }, ); From a28373755d653382cd0c0782f4be71bc6839622b Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 10:26:52 +0300 Subject: [PATCH 4/4] fix(examples): remap tablePos through tr.mapping in the drag decoration Addresses review comment on #2920 (r3651925104): apply() cast any object transaction meta straight to DragSourceMeta without checking tablePos/ originalIndex were actually numbers, and never remapped a stored tablePos across later transactions. - Validate the meta shape before accepting it, matching the suggested fix. - When a transaction changes the document without setting our meta (a concurrent local or collaborative edit while a drag is in progress), remap the stored tablePos through tr.mapping instead of leaving it stale. While writing a regression test for this, dispatching an unrelated transaction mid-drag surfaced a pre-existing bug in BlockNote's own TableHandlesExtension: view.tablePos (used for its drop-cursor decoration) has the same never-remapped issue, but throws a RangeError instead of failing safely, since it's a plain instance property rather than plugin state going through tr.mapping. That's out of scope for this example to fix, so the test was dropped (it can't pass while core's own decorations() throws first in the same view update) and the README's "Concurrent edits mid-drag" section was corrected - it previously understated this as "drops can overwrite a concurrent edit" when it can actually throw and break the editor. Co-Authored-By: Claude Sonnet 5 --- .../README.md | 26 +++++++++++++------ .../src/tableDragSourceExtension.ts | 18 +++++++++++-- .../tableReorderingVisualization.test.tsx | 14 ++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/examples/03-ui-components/21-table-reordering-visualization/README.md b/examples/03-ui-components/21-table-reordering-visualization/README.md index 8d36791907..3323362d58 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/README.md +++ b/examples/03-ui-components/21-table-reordering-visualization/README.md @@ -57,13 +57,22 @@ that existing lifecycle: `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**: BlockNote's `dropHandler` snapshots the - table's content once at drag-start and doesn't refresh it while the drag - is in progress (only `mousemove`, which stops firing on the dragged-over - element during a native drag, triggers a refresh). If another - collaborator edits the same table while a drag is in progress, the drop - can overwrite their change with the pre-drag snapshot. This is existing - BlockNote core behavior this example doesn't touch or change. +- **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 @@ -73,7 +82,8 @@ 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`. +covered by `tables.test.tsx`, and it doesn't cover the concurrent-edit +scenario above - see that section for why. ## How It Works 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 index cb9c5fdf48..5b3494d203 100644 --- a/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts +++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts @@ -40,10 +40,24 @@ export const TableDragSourceExtension = createExtension(() => ({ if (meta === null) { return null; } - if (meta && typeof meta === "object") { + if ( + meta && + typeof meta === "object" && + typeof (meta as DragSourceMeta).tablePos === "number" && + typeof (meta as DragSourceMeta).originalIndex === "number" + ) { return meta as DragSourceMeta; } - return prev; + 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: { diff --git a/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx index ebc74ab5b8..85b2a6f5d4 100644 --- a/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx +++ b/tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx @@ -227,6 +227,20 @@ describe("Table reordering visualization", () => { }, ); + // 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 () => {