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