From a6f2c11a548ae49a2b80a797ec12968dc579afd4 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 08:37:06 +0300 Subject: [PATCH 1/8] 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 6decb347ed..9b6773ba03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2288,6 +2288,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 3b211982ba27e6d151265614700c7c24cb026edb Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 09:19:50 +0300 Subject: [PATCH 2/8] 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 cd48fc70b4a49df3b015d4960990221a5519537c Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 10:08:50 +0300 Subject: [PATCH 3/8] 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 a574198069c0e43fd732e5163ecffa054a930297 Mon Sep 17 00:00:00 2001 From: mustafa-yilmaz Date: Sun, 26 Jul 2026 10:26:52 +0300 Subject: [PATCH 4/8] 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 82497191db8ce7cde20b94454031f998726f53ee Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 11 Aug 2026 17:41:04 +0200 Subject: [PATCH 5/8] 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 155a460786..8b62f27d0c 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 9b6773ba03..8c072a7f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2311,12 +2311,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 zcmcJ0cU%<9wl0X0bCQf?kT}FaU{rF>IS33nJD^Ag$r%a4kc9yRMRJY;1IUmhNCwHM zpyZ758r=8Zea_wI{?2`GzkizP?pmu>RadQE-`5{en(7J!xHPzEXlMjVin7{hXg3}M zzZ0-AfuoP&x;z>hh*nAVp{^JDRtJ%@>0$QH<}mea`-@#BWCSZDE*Znq4q^RDpfS|4 ze+bvH#GdsK7z|WZ(fdQz)_Y0PWQg zn+e%M3LLL4aQ~ebvD~d z1~J~))D)F;WH;M&JfU!d-|<9x{~Z?!Gw?cJ3(WOD=#djrNf%WYmX}PtLsF}iCEL6z zk4ZgrhMcXh6t{K9ZC4acX3ykaK4bS}Rc;YiVs^{}$9eX};R|LRM%BAnc?`&Axk18~ z9$^adWf-AqVELfssGCv6am%bTY+`~R9#3#Aw8agAl66kxs?vq0O-UxjDp|Ca2bD;* z@VXr?NE=l%RLdgWlGJKu^>Dvnpk_08vYVnNyr5bo9&g1AOf>5! zQ(~HO-B*jo!eYG@TP+_+vlLi$8cY zD{G}i*N=F?z$zuED*36mZi17&`U0Bqd?8>|jsOnNkvGNudz?PuQggGrZ$!_;;Gy{_ z{`n9|c(U2SQ(-G^q<7w~@-5slIS&tfmFGv%s|r|(L`0+x->rP_hU1)c3uCnYEQp~I z07(t@U36m4XJf3^stHqeS4!f3eK^X}h1BtPc8Y`U@wLCV*w7D;VNdjmjokY{;3+6M zH&FN8On4R-Sclbb8iP{k?L2%XNRo|a*d#tG9e=s=HT8Q8`HZ^iWo64hkWXjfAQdXID24sNhu?_r5aFv(8s#>kFv>Ga05x z`)qzqXgqn{x+$fo-uH!eX~=nf?INeGL)w;wm{dqBzT6Kc3L$hgE+J$wE!$H!#&;xg zVQGaMM-1We(MTOFRba!Vgq?u7G7SYE-b@SL3~^WVoa_4OpBXjF9-Vx;su2Exl;fpi zhgOTiH0#Vg`${uGgtw$EXM}aH)ZFL#^ahuFVnzuYG8Oj7MeWD_nuLAvz76eeLapx(PcIP#UTM@! ziLaztAP)b!4S0w-@f%A=NsIRLBvB+I$XDH|E(L%cEB<7?i0wmJdFjEmmj_*ktc3kONq7v+Og;J*GOR~ULZ+T_5_mziD z2xZ&5T3Qpa$(>ak(wF<8A0!O#NvKPX&4|QPZC)KSQnpPQjPEmYAeuA)YhfjOtfqa4 zE#gX1UiFY0NOgI_N>2`?#`w4EH%7a)n(EgDxW-OgaSS9_o3&Y;ht!>FFbm0XcO431 z5Kr6&Ewzhw=Sj;v6+H_Tr?Rpq>}KYFD!uzv;21rcRNz(U)PG@ zcGKM=0y51u?k-G=B*{goR68S26=yI)Q*?L2cA!&0#_0B7$%S@!;uCb2S$L#IR`d&J z73j-Rd>dtZNN815y{HvsI5n!tUVSZ1Ftz~Cezs>7oZI=BND7f*Jt-=xpoZ*~l~b%^ zt5kLf7i1G=l%7VKWPd=ae(XObrl3dY-D3EWMlKYc~H$KE3R6X6ib4#LcJv2_*kH)Z4 ze`fgwBLn-!ZeFPuZAk(1m~vG0iGpoG+B*|0 zt|=u^4rg+q6tx!d@Voe<^AgGH72DBi&U57k6QWsv?x_ANMQRz~t=)P$~R&D=- zX$*XPf{)4THznHA+sZ_tl+{n7w(KbrsMFD#gt9tar-xeUL8{1Gh=0$TW2%p@PDt_6 z;(q=7s76>-RP7U(fiFH8Fx|b>9r)`O&d#w#MeGo9;2qj|C|pF=QAxghK}}1GC@G23 zi0|Qk`B*CMtL$T*FmPZ`PY+NaEhi&(mDx-+tnQJ}slY^MxR=h@>#XvK8Et>N3MGzF z5ZMp!bZ0gsgg(?ZRKMB(kVg@>KgvRUAgYRv4iD&>@y_2_O1%-s0!p^XFehhc{L06_ zt2(G-Cuh`nqF(yT164%ug6q93kA!2F?kNuEuuL53$LGIx}f3)Z)NItj%%Ipr$s-exM(C$xN(v&SzIi*82(9D@)r#q zUU36VBoyCkG^{>B8|E)%s!PxgL5mQf0L%n$4Y#4YwWxIi-lAg`yK%f+qN5L=R@}qq zZ^bF6D_XAZU#yC;EGKHeFWYTQ_G^oDO6*oMM{zMx7sXS7UBWG&?C2)83fL{WoN)8) z*UQT^4W1M&9^?UEwJQs7e{~iy3k{t+y1rVze~CTTTYS<#{6GymXND+wOxLs?*U@QF z3~TXK5p)H&*yKhths9@d3WM>7{!~YFp#~f!hV^t4NrXQ3M@RHY{447F&#~F9Ez6!bn=c=;dl! zm2HIj5le+)OE0^0{XD5nJwaQCOUDt#hEAQiW;f1@ERJtd99ja;j(y>|S-Fl@{Y}xb zpsul<&7ZoarWliD!O!A5`OWU8>UNk<>00-QFVXZ6OY`u_3xtTDYE08BAR5P6GJh;ULsUPp%vfImP9a|L_=_%kpf5(~hib~m-!t@PWAgIG^+}| z?Wu7vWVBj2#95|wCg<2y+47N8JNnA3V;g!xptL-BZ+s7O!K@*b!+!iE5hIzMJ2T(8 z1*GH`61FV-7qKAwJT=h%z7h0r?@V`ekpbr!-)+O^5{Np8M$*X4x;a) zrG_p#`|BPTv%?IFY3DQzwmK}edRYReo!HO(t1|=rQ3%_@kk&2Y_Te9^Be~nFY(hOn z1l(E!8Gc-Y_uV#Ef2k&7lruuV7EzL>@ylg>UF*^n=Cg|Ti~dS~anW693Uk4?s~r?H zedGsCQ3!;fgnn%(nmQrSTEzx6el#RUcsl5p)|Ghgi~ss=N3_%LPheVIk~ni$9qwlt z+T1oh*bZ6KsvhllB1CYt)w>tYx)Y zK?|Ad9M9(ZWSWJ!^=HRhPU$X>sDI8@sNI?XKrX%Ms&PS^Yh4u? zIN!c~0~R*JrvWW`h=n_!Kvle*VHsfuBfpzAN>u+KwRp zh1q>y$gKQ^fgi(J;AkX~J|m{o8V37Dei3Booym-YaC)Ix&e?1AGf%?3Dc;#en|H%y zSP>r*SDTNYRE*?9FIVR0PKjhB_qxF*WujbTzGL`)fiHI7-aF|#Ha0}S0qdEO!GvcA zdlq9-&W$rSB|W9Uvhl2+TVIF7QBs^UZm$moQY|O)_e{IjM#1&|e|Ilj4uK%sw`2A` z`JOebRY&9Ut6WYnF!(fl=y8)RkxTg$@tg}tII5B2j?nvz=~az;F*tm^HVK$Vd{Srv}{!Tm3XpX1~jL7EvEm=&_h16qOU1u*Ak+h~W{Az%Akx?M0eYN^LxIny1#=iJhm{*;oV)Hrw1ea*w7(=K)RM?}O2 zl8v|KZkBP&QmFN&@+XgVTKoB*t*0-V)e_RWx!PuT(gJ@{E(PH&Ly(dTNzgh6+0Q zt2T`i)-sb!Ej`$UP^t6@RbkK4Ny?gYaN|x`$6ui(Z?=u}KFxB3x;=-vkd_OT?8I#O zOjw;3u^LQe^~F}%^dg*>z%l>SisZIF0j@yf4_bfI-V*jBN~%fr-FwNn?H|hQq*C3K z-NOdc2RKbUB~g?MI!+3Py(I?=bd9ai{q0{gV}MQL3cL-3ghvJx3e0jY!Fb@@aXBTrMQ?c~V@{bXm>PR*^U#Sn8%6RZB-;tWmv zgR9}5Z515_(!O+XUE$Ja#cLX?YhGX4gp+A!PCGHlc5?>6LK*h!ETeqGm(2YQ)N2#9 z?A!hQyC3}>*u`r0!wA?ZW>(%8db`Cozs@^J2F+YAqa^rr!YXE^Yt@r9$ygiB;) z3ue*rnz!=+bATP==a!Lm{28|MG1q`Pe)VryT%yJ;3hCn+6@l6a^sp1%;##A6rk*_- z_(Ix7B`D@3+uv_rK2yj(ZA+v1bh-^(!dNh6zsq2w?bP{8DGrl-<3Y1^;LPCgQMc01 zJKgelu(t7;(ph3FZvxfr{{1Pq+{F;2%^NXDU88dJdj7)MJZqdt+quRoL9j%`^0Gi4 zBih5F`(%&k$69}oXZ1z@=fj=h`|b@FSno=;fYK(P0vcat8S9~^!?-cQ%T?#c(mmNW z*_}3VzU5;Py5X3WV3>AMZM?b8$lA`Q^3mn~dfLu`*X%y>XNud0AC|Vn-5gNS{#?N8&%P}K@fj>`t$Zj}9>R@}^Z)tY+WRrm1 zPE)k9-LdE>%#y)pRL=T7l)Y1LY$dX<{~=hodQHsU-QL3tuEjvNE$zwoE-$)3{2^l~)=~hw%e~7Gr$OYz`3XNYsNbv46vUSl9w^&vZWtPE zIaB0+_BrkKQ8;;J2I=s9lVz|Tqh+>>w*E60TJu&ejn71GRu^AIU0azt-3!IRsY^>( zqZfH?FSpIz5X2kbyoriR>{uAp=0Cl=_+EsMK}1l!dB#^(4ZK}O`{$3>N?MuZN{f*D z!>M5PPYQ;1nJJ&lW_t|WP-Dkkvs8giaVBn&6{Ze^@ity3&xHev-PX=0XE{;-(mc`# zsJhjpXix3GmAS6DiP=zj*~85ypTV*Vy$=%~{b=LY_)fqD6=BN>tV6=yKsxWu7L@*& z5pDC)$!TL;=s6j4ne&keT~qdW%+u9HRB$Te&;7Z5K^`PC?cBoDGaMlNVgAx$Z{Dr; zqy1US*(&kzcb>{`F1|FWvFd0_{R07+jZ7<(%#k)pXW9 zWEclh9bEbgE!m9z-159Ol#~9g2|v!zf2}=754gZnH*9nentxUUD-1g!+qwb?e%2_a zUu%?nmqXY@QgfXz&9zoNZN)#ta$tV(4b~}b@s_xMrXOpJ;wd}#VVpn2xah~gxH!3; zRpCBn+q7-Jq8ww?B~yZ zWqbCcRPdrhrxQylK*VUIQNPY_e6E2rh+H7Wl2r3@2^C3QcCImiUKaGdsink)0d~M{ zS5s31Fj0czdU2Hh1F2lX=%!kdZxhTzA>6$1y(9EPzk8hDCgh7vV!%XYH5RB@tnvM? zuZm4*^IUm7@_8aeD3>uz9ni0!89S2h6DZ0MuFo5Gj~&2kZwfX4fLTmPW^!o!KjEqO zc#c4$wyPQl09`)QSBS5fW6=h|0HC{^ptLw20EPM9xU#*%j@KXY){6-lD+NA98#Q!rg>}&Krd+2?rWz$s!lwb%iE0)2o#0L1aD-Zk1KtJK^$j%h%2s?Y=Yh60b z>-VX`pBssISfeGx>FlpND44rUW#|pzi^X;WX)W zG98$Wt&GM|MbU)#8}_jJ8g&GuYSpDq8g*x0B(AlD%caw+ezZ{Cx!3O%Mv?MvQH^iq zDIw`Pn=^_Jhdqkw9Bz%nKl;Fkce^47wosHY4^H95DQX;@>es^G6V}$3dk~w(bxsg> zlPbjpzXzLohmJ7A5d~4e=_Yyf>N!`cW*x-qrLl|-a-G-`#oS=pNnT!&G}#ER>Ic8a zB@~Je>vu=d>{N(<2;j5iPt&f8mML8$kW_tSOJPF^iQC!`P?_$s<7|f48}>4$OYh!r z;>1HtzkjQ$)9uz6TSXJ^PEoFTgs+yxBc$Ff603E@ljadhY-6yG^+vqMbz(;}y2`#H zt*}Sf*Kp91FW;HI90qa|>bc7MgT+ys!g|91H@hgBn zj42vXpo1P2ZHWn!T4>gRs93Dudw}iVaGle!kt=vT$1F0Go<3XDb0knB$&?A@idl5W z(P`;aezXBa@vWe7g9pHreEnQb@4MFToHvHHnkrAEzCa&ddR0=i<&mOEw{(jJ4#lzA3^6DX z5tef%Bq`zv?q^fo#pU+Sj^S#K#VB1ASTStid0mQ5lo_(ML3-rQW%Cw{G+ub$wQ8fU zWxF)?A%L(tManO{D#knJ=|v(JIe$fXl|TkT3rbk;BDJ6M?GmTKF0Re+$53MEu3#|r zKu14+JMa;l1!_ri%>(093IL?)$oP1uA^bN7ER9|1rMnLazO(rA=EL{rAbgB#+GlsS zc-`zF@#CsH*x+v{H{CLH{yl)gYUc- z!2iu0g=x{>M7IIF9DLELnV=JQ4+UCISK0J~{Z0g43*m=+`{tOUt*fVJNrm~4*n$f4 zIxQ?;;DG?o@eER^)m7H9u`!@?(_0VU9~=lLCnwWM`J~*z=EMs|yN(m~D=hd95LeBF zpYC?cKH)=R;_EJdv(iK5-|k>z2M2xq>HvAeq7V<<>!l8Vew}{6?a~9lt$jA^8-GNr z14O%R=ja$66@`NWfH-zUR|=4hK_JjWBQrDlrN{8GTMvUQsZy?w*Euk9p9L8U3@UrW zTUA3tNJ=Vh!<+87ht^F%N@@@YIA;UabRBKb9qivpZ^Pgz@y(XpJJ=^Ww>rrAFVBBo z^skgJw(k=HBMs#|RkBzZS6?Dn6kM&}H$n1y!M;Y9a5>zhxvdoof z-;!?aLD##g8j$GBt|n@LJXG6KO!4wak{y?Z(X`9*C9@}UFK3dF0*V3{7TNB|33<3L z^@j#~TduL5sGdj;Qr3LY=wz>3qb!KkXuh1PsGdwE(O#J9(CY0z+fi!|In&R`c-bg1 z4kcM$^4I@;99Ols!QpY>$pQYwGLhgiK_-d2^Si zKfkCbJXz1cz=~>H)5kp}x19?5Q;+uX)=M5d)13Z`z*vYRs{CSjgsxs|Uq4H&* z-!hsWK^$0xXm{=lvBW4_7IMQu_$_z{P$Hbxx`qd?<>}uOHg%8eB$J@__u^<5vhss8 zk#_U6!;3D5)PL>XmYc(fFr;zIYlLwv|5Q^|)if~ZojbX3-5_M(;bEgp7@4y0z~LLV zkq69E%ujO>;;snz(rv01EAGn^B>Ec~8dxgBa^nb-XMqYhYIw|EAC_fKmACISkAI1h zdH2M=VU;+H!&C5VM?Ju<=fv*`TeMMA$~xBPmgGD;VQyOI@w^Q$uP^9=t{AxqVo-f1 zbwOYm0;|wtWxqLF!mFYp#ZXe-DrAtYK{Acn=3q%HU@b6yy&(=7Zf>y?|L$Ui46E4D z9%&0Xnj2y2;jl0FtVQ{5GJ`!tG~r493V!xri{1x*T|DoJW9=(^`)ZWBGr%s zp_?g>%}Ob9O?3R6HGWK9U8CYzk(tx+D4hBDMRa;lQ3^G5JT9rJK4Bs|MhcyJ@ccFywv9Zl{k{bJL^k!ipMnuy z2~|opPd`GNt>yQ<2L7yuV1iJh*i%)BI|>)if;yw?$Ld>SQX`jdjxjkt-}bgg`R%dD zSVtu*1L}#o6q8?n)Dymrie&Dj5&z2AF zcoJ#6=*vI@@C9>kPSJF9bX0+@q;FuL0H!N){)bF^!9^Y|I2KR5rrVUlVxldZM941m zuZ0yjxN_+Z3X$%2%}|NFT+B*X$T&g z*S3$coW-q?chXeOBhID#3&BRoe-~=lUrRW4Id)=V4-Q_i_nFNaM{y#(U;TwLMA?^o zU%aZDwCpv=!w8ob@OFGGwhg9QowzfypiJ`?<9SV`fnB6|66;DZu{-#G|vak zMws_}zidB^Jg8mf1nRR9++Fm}B>@5g0%2icAd`v%X>{!4#|JVp7(k^_U1H=d61X2Y z*E8Av>*=rUAL=vtkj6ZcJ&0Z57WmyeEzWgLub}@fEMbE1T_s2?cI)yWn&M*NV)AEVbD>!VG#q9&jx* zi`Km>*IUQ;@AdnSx)j54X+KllP-_#?w<)%q06_jLnaIDAs)YVYs$y%OHZo+=@b$ zklO|Jfru!1AI*l$Ej|x7(P*M6eOizr+tH@|)BX zYF8vXt4{%RQsKy>CaisU6y@?8Sx7EN?l7RZ;c9>^UsV4c{%tuky*%*ss0x~&rQ{Fe z=W2UKJ|7|V^uPlFiV(wbAkzbN@9GBgRyNq%7Yro?o&C+1hit&KX9 zvl4*%Oy&FGm|zL0WMeY(Y6;`%C3;NUTD$v>e$4leAQZUXmX+BKW{9MGpKne>C?sg> zo0$QpPT=X$LKU1a=}>7V{4M}N;dfc^)fjXhsyX*c!$kiq=|TJ<>A_R-Wdq7=Pabl+ zKgoEQAQ`{4&J8dA(%cTwN?PUym7v7k4frzxMCWTg&}L_6=Y0G2?Mj0Re{0GgC40NhJacTNane* z#6tL8i+=wBFkwrf_+76`-@J)VxJ!8C4dirfs^7CZL08x0QjW(FT@sZ zsY!_&W>{s7lcyQ!LGhnI1-k^mL;nFgPjv?Iy97A#!lXG10fhG(e^vGMDLFbhi36=r ze@yW8EdN%hn}R=uTRt|u2C>(862P^uwx%krfX4wq-VngQ06xZ_4}YLJ^IA))EWjtw zoCiR|S5KSqbIJNMH~kex5HVg?x5(o#x$Xs}sqY<24#*oY7=fv!R^>BNPv6I0G?fnk zEr)^OwFUy1oa=b*kmCYqI8PJ1O91h5nxL4ZhR{#uVQ^9KUIiuw+=0Dj2I^$JvoM`uEI?oAwkPzxCl%ur&yev`3YPG6@p zD&R8|kDD4JK+K)!3ITI}4d5XkfHi3-GYwJ)o(u;7`az#VgV+H9y$-g0KENV`1+fDQ zTt#GC5a{9BVjcA#^WTfeI0nY>8_bUZgyPk&ze{Srds&Z##R~kF19njUC1RGO814N_ zOtc_w0yH!O8Z@*rVKg*T2Q;*XQ8cv2h3IID${2sqfLhD&oV)M)Q5fI{O-W8&woKY0 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"); - } - }); - }); -}); From e07c2e52ab33a16d64884525477022cc97536585 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Aug 2026 14:47:51 +0200 Subject: [PATCH 6/8] fix(core): make drag previews render like the block they copy The native drag preview for blocks and table rows/columns is built by cloning the content and handing it to `setDragImage`, which rasterizes the clone in place. Three things stopped it looking like what it was a picture of: - It was attached to `document.body`, so anything the editor inherits from an ancestor stopped applying - theme classes, and custom properties set on a wrapper element. It now goes into `editor.portalElement`, which is mounted inside the editor's own container, so the surrounding cascade still reaches it, and apps that need it elsewhere can already move it with the existing `portalTarget` option. Fixes #1685. - Pulled out of `.bn-editor` the clone had nothing constraining its width, so the block reflowed to whatever the container gave it. It's now pinned to the width the block has in the editor, with `box-sizing` set alongside it so `.bn-drag-preview`'s own padding doesn't eat into it under an app-wide `border-box`. - It was hidden with `opacity: 0.001`. Firefox and WebKit rasterize the element as painted, so that made the drag image near-invisible in both - Chrome ignoring opacity here is what made the workaround look like it worked. The preview is now rendered normally and removed once the browser has taken its snapshot, parked behind the page's content until then. The table preview no longer has to carry `bn-root` and copy `data-color-scheme` itself to resolve the theme variables, since the portal container already has both. Tests spy on `DataTransfer.setDragImage` rather than looking for the preview in the DOM: the element is gone by the next tick, and the spy asserts on exactly what the browser was asked to draw. The composited image itself is drawn by the OS and can't be captured, so the new screenshot baselines cover the element that gets rasterized. --- packages/core/src/editor/BlockNoteEditor.ts | 16 +- packages/core/src/editor/editor.css | 26 ++- .../extensions-shared/dragPreviewContainer.ts | 60 ++++++ .../core/src/extensions/SideMenu/SideMenu.ts | 2 +- .../src/extensions/SideMenu/dragging.test.ts | 154 ++++++++++++++ .../core/src/extensions/SideMenu/dragging.ts | 55 +++-- .../extensions/TableHandles/TableHandles.ts | 7 +- .../TableHandles/dragPreview.test.ts | 38 ++-- .../extensions/TableHandles/dragPreview.ts | 34 +-- .../blockDragPreview-chromium-linux.png | Bin 0 -> 1770 bytes .../blockDragPreview-firefox-linux.png | Bin 0 -> 1737 bytes .../blockDragPreview-webkit-linux.png | Bin 0 -> 1737 bytes .../end-to-end/dragdrop/dragPreview.test.tsx | 200 ++++++++++++++++++ .../tables/tableDragVisuals.test.tsx | 76 +++++-- 14 files changed, 563 insertions(+), 105 deletions(-) create mode 100644 packages/core/src/extensions-shared/dragPreviewContainer.ts create mode 100644 packages/core/src/extensions/SideMenu/dragging.test.ts create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-chromium-linux.png create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-firefox-linux.png create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-webkit-linux.png create mode 100644 tests/src/end-to-end/dragdrop/dragPreview.test.tsx diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..c0fed2e871 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -720,10 +720,11 @@ export class BlockNoteEditor< * * @param element The DOM element to mount the editor's contenteditable into. * @param options.portalTarget Where to mount `editor.portalElement` — the - * container that floating UI (toolbars, menus, etc) portals into. When - * omitted, defaults to `element.parentElement` (which is the editor's - * `bn-container` in typical React usage), or to `document.body` / - * the surrounding shadow root when no parent is available. + * container that floating UI (toolbars, menus, etc) portals into, and that + * drag previews are attached to. When omitted, defaults to + * `element.parentElement` (which is the editor's `bn-container` in typical + * React usage), or to `document.body` / the surrounding shadow root when no + * parent is available. * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ @@ -777,8 +778,11 @@ export class BlockNoteEditor< private _portalElement: HTMLElement | undefined; /** - * The portal container element at `document.body` used by floating UI - * elements (menus, toolbars) to escape overflow:hidden ancestors. + * The portal container element used by floating UI elements (menus, + * toolbars) to escape overflow:hidden ancestors, and by drag previews, which + * have to render with the same inherited CSS as the content they're a preview + * of. Mounted inside the editor's container by default; see `mount`'s + * `portalTarget` option to put it elsewhere. * Set by BlockNoteView; undefined in headless mode. */ public get portalElement() { diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index c501c00016..45c7c37ca4 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -112,20 +112,18 @@ top: 0; left: 0; padding: 10px; - /* Sort of a hack but seems like the most reliable solution. */ - /* Drag preview element needs to be within bounds of the document area or it - won't work in some cases. */ - /* Negative z-index covers most cases, but the element can still be visible - if UI elements are translucent. */ - /* Setting opacity has no effect on the drag preview but does affect the - element. Unless it's set to 0, in which case the drag preview also becomes - hidden. So setting it to an extremely low value instead makes the element - functionally invisible while not affecting the drag preview itself. */ - opacity: 0.001; - /* The element is kept in the DOM after setDragImage captures its snapshot, - so it can overlap the editor and block drops in that area. Disabling - pointer-events lets drag/drop events pass through to the editor while - leaving the captured preview unaffected. */ + /* The drag preview element needs to be within the bounds of the document area + or it won't be captured in some cases. */ + /* This element exists only to be rasterized by `setDragImage`, so it can't be + hidden the usual ways: `display` and `visibility` stop it being captured at + all, and `opacity` erases it from the captured image too in Firefox and + WebKit (Chrome is the odd one out in ignoring it). It's removed as soon as + the browser has taken its snapshot instead - see `attachDragPreview` - and + parked behind the page's content until then so it never becomes visible. */ + z-index: -1; + /* The element overlaps the editor while it's being captured, so it could + block drops in that area. Disabling pointer-events lets drag/drop events pass + through to the editor while leaving the captured preview unaffected. */ pointer-events: none; } diff --git a/packages/core/src/extensions-shared/dragPreviewContainer.ts b/packages/core/src/extensions-shared/dragPreviewContainer.ts new file mode 100644 index 0000000000..9744c65154 --- /dev/null +++ b/packages/core/src/extensions-shared/dragPreviewContainer.ts @@ -0,0 +1,60 @@ +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +/** + * Returns the element that native drag previews (`DataTransfer.setDragImage`) + * should be attached to. + * + * `setDragImage` rasterizes the element in place, so whatever is passed to it + * has to be in the DOM *and* has to resolve the same CSS as the content it's a + * preview of. Attaching it to `document.body` breaks the second half: styling + * that comes from an ancestor of the editor - theme classes, and especially + * custom properties set on a wrapper element - stops applying, and the preview + * renders unstyled. + * + * `editor.portalElement` avoids that: it's mounted inside the editor's + * container by default, so it inherits the same cascade, and apps that need it + * somewhere else can already move it with the `portalTarget` option. + * + * Falls back to the document/shadow root when the portal element isn't in the + * DOM (an editor that was never mounted), since an unattached drag image is + * worse than a badly styled one - the browser replaces it with a ghost of the + * entire editor. + */ +function getDragPreviewContainer( + editor: BlockNoteEditor, +): HTMLElement | ShadowRoot { + const portalElement = editor.portalElement; + + if (portalElement.isConnected) { + return portalElement; + } + + const root = editor.prosemirrorView.root; + + return root instanceof ShadowRoot ? root : root.body; +} + +/** + * Puts `preview` where the browser can rasterize it for `setDragImage`, and + * takes it back out once it has. + * + * The preview can't be hidden with `opacity`, `visibility` or `display` while + * it sits there: Firefox and WebKit rasterize the element as painted, so + * anything that makes it invisible on the page makes it invisible in the drag + * image too (Chrome ignores `opacity` here, which is why an `opacity: 0.001` + * workaround looked like it worked). It's rendered normally instead and removed + * on the next tick - the snapshot is taken as `dragstart` finishes, so that's + * all the time it needs to exist, and `.bn-drag-preview` keeps it behind the + * page's content in the meantime. + * + * Callers still clear the preview on `dragend` as a backstop, which by then is + * normally a no-op. + */ +export function attachDragPreview( + editor: BlockNoteEditor, + preview: Element, +) { + getDragPreviewContainer(editor).appendChild(preview); + + setTimeout(() => preview.remove(), 0); +} diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index fddd2712e9..4a5d354d14 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -765,7 +765,7 @@ export const SideMenuExtension = createExtension(({ editor }) => { * Handles drag & drop events for blocks. */ blockDragEnd() { - unsetDragImage(editor.prosemirrorView.root); + unsetDragImage(); if (view) { view.isDragOrigin = false; } diff --git a/packages/core/src/extensions/SideMenu/dragging.test.ts b/packages/core/src/extensions/SideMenu/dragging.test.ts new file mode 100644 index 0000000000..aebfa5a041 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/dragging.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { SideMenuExtension } from "./SideMenu.js"; + +/** + * @vitest-environment jsdom + */ + +const testDocument: PartialBlock[] = [ + { id: "paragraph-0", type: "paragraph", content: "First" }, + { id: "paragraph-1", type: "paragraph", content: "Second" }, +]; + +let editor: BlockNoteEditor; +let themeWrapper: HTMLDivElement; +let mountPoint: HTMLDivElement; + +beforeEach(() => { + // Stands in for the app's own theming layer - the case from #1685, where the + // block's styling comes from a custom property set above the editor. + themeWrapper = document.createElement("div"); + themeWrapper.style.setProperty("--app-block-color", "rebeccapurple"); + document.body.appendChild(themeWrapper); + + mountPoint = document.createElement("div"); + themeWrapper.appendChild(mountPoint); + + editor = BlockNoteEditor.create({ initialContent: testDocument }); + editor.mount(mountPoint); +}); + +afterEach(() => { + editor.unmount(); + editor._tiptapEditor.destroy(); + editor = undefined as any; + themeWrapper.remove(); +}); + +function stubDataTransfer() { + const setDragImage: { calls: [Element, number, number][] } = { calls: [] }; + + return { + dataTransfer: { + clearData: () => {}, + setData: () => {}, + setDragImage: (image: Element, x: number, y: number) => + setDragImage.calls.push([image, x, y]), + effectAllowed: "", + } as unknown as DataTransfer, + setDragImage, + }; +} + +describe("block drag preview", () => { + it("attaches the preview inside the editor's portal container", () => { + const sideMenu = editor.getExtension(SideMenuExtension)!; + const { dataTransfer, setDragImage } = stubDataTransfer(); + + sideMenu.blockDragStart( + { dataTransfer, clientY: 0 }, + editor.getBlock("paragraph-0")!, + ); + + expect(setDragImage.calls).toHaveLength(1); + const [preview] = setDragImage.calls[0]; + + // `setDragImage` rasterizes the element in place, so a preview parked on + // `document.body` renders without anything the editor inherits from its + // ancestors. The portal container sits inside the editor's own tree, so the + // cascade above the editor still reaches it. + expect(editor.portalElement.contains(preview)).toBe(true); + expect(themeWrapper.contains(preview)).toBe(true); + expect(preview.className).toContain("bn-drag-preview"); + + sideMenu.blockDragEnd(); + + expect(preview.isConnected).toBe(false); + }); + + // The preview can't be hidden with `opacity`/`visibility`/`display` - Firefox + // and WebKit rasterize the element as painted, so hiding it on the page hides + // it in the drag image too. It's rendered normally and taken out again once + // the browser has its snapshot, which is the end of the `dragstart` task. + it("removes the preview once the browser has snapshotted it", async () => { + const sideMenu = editor.getExtension(SideMenuExtension)!; + const { dataTransfer, setDragImage } = stubDataTransfer(); + + sideMenu.blockDragStart( + { dataTransfer, clientY: 0 }, + editor.getBlock("paragraph-0")!, + ); + + const [preview] = setDragImage.calls[0]; + // Still there for the duration of the event itself, which is when the + // snapshot is taken. + expect(preview.isConnected).toBe(true); + expect(getComputedStyle(preview as HTMLElement).opacity).not.toBe("0"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // ...and gone by the next tick, without waiting for `dragend`. + expect(preview.isConnected).toBe(false); + + sideMenu.blockDragEnd(); + }); + + it("pins the preview to the width the blocks have in the editor", () => { + const sideMenu = editor.getExtension(SideMenuExtension)!; + const blockGroup = + editor.prosemirrorView.dom.querySelector(".bn-block-group")!; + // jsdom does no layout, so an explicit width is the only way for + // `getComputedStyle` to report one. + blockGroup.style.width = "512px"; + + const { dataTransfer, setDragImage } = stubDataTransfer(); + sideMenu.blockDragStart( + { dataTransfer, clientY: 0 }, + editor.getBlock("paragraph-0")!, + ); + + // Without this the clone shrink-wraps to the drag preview container, and + // the block re-wraps to a width it never had in the editor. + const [preview] = setDragImage.calls[0]; + expect((preview as HTMLElement).style.width).toBe("512px"); + // `.bn-drag-preview` has padding of its own, which an app-wide + // `box-sizing: border-box` would otherwise take out of that width. + expect((preview as HTMLElement).style.boxSizing).toBe("content-box"); + + sideMenu.blockDragEnd(); + }); + + it("replaces a previous preview rather than stacking them", () => { + const sideMenu = editor.getExtension(SideMenuExtension)!; + + const first = stubDataTransfer(); + sideMenu.blockDragStart( + { dataTransfer: first.dataTransfer, clientY: 0 }, + editor.getBlock("paragraph-0")!, + ); + + const second = stubDataTransfer(); + sideMenu.blockDragStart( + { dataTransfer: second.dataTransfer, clientY: 0 }, + editor.getBlock("paragraph-1")!, + ); + + expect(first.setDragImage.calls[0][0].isConnected).toBe(false); + expect(second.setDragImage.calls[0][0].isConnected).toBe(true); + + sideMenu.blockDragEnd(); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index f8ba326538..cc72fbeb83 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -8,6 +8,7 @@ import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js" import { getNodeById } from "../../api/nodeUtil.js"; import { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { attachDragPreview } from "../../extensions-shared/dragPreviewContainer.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import { BlockSchema, @@ -65,7 +66,12 @@ function blockPositionsFromSelection(selection: Selection, doc: Node) { return { from: beforeFirstBlockPos, to: afterLastBlockPos }; } -function setDragImage(view: EditorView, from: number, to = from) { +function setDragImage( + editor: BlockNoteEditor, + view: EditorView, + from: number, + to = from, +) { if (from === to) { // Moves to position to be just after the first (and only) selected block. to += view.state.doc.resolve(from + 1).node().nodeSize; @@ -95,8 +101,26 @@ function setDragImage(view: EditorView, from: number, to = from) { } } + // The clone is rendered outside `.bn-editor`, so nothing there constrains its + // width any more and the blocks re-wrap to fit whatever the drag preview + // container happens to give them. Pinning it to the width the blocks actually + // have in the editor keeps the preview a picture of the block rather than a + // reflowed copy of it. + // + // `getComputedStyle().width` resolves to the used content-box width, so + // `box-sizing` has to be pinned alongside it: `.bn-drag-preview` adds its own + // padding, and apps that set `box-sizing: border-box` globally (most of them) + // would otherwise have that padding eat into the width rather than sit + // outside it, leaving the preview narrower than the block by twice the + // padding. + const parentWidth = getComputedStyle(parent).width; + if (parentWidth.endsWith("px")) { + (parentClone as HTMLElement).style.boxSizing = "content-box"; + (parentClone as HTMLElement).style.width = parentWidth; + } + // dataTransfer.setDragImage(element) only works if element is attached to the DOM. - unsetDragImage(view.root); + unsetDragImage(); dragImageElement = parentClone; // Browsers may have CORS policies which prevents iframes from being @@ -124,23 +148,16 @@ function setDragImage(view: EditorView, from: number, to = from) { dragImageElement.className = dragImageElement.className + " bn-drag-preview " + inheritedClasses; - if (view.root instanceof ShadowRoot) { - view.root.appendChild(dragImageElement); - } else { - view.root.body.appendChild(dragImageElement); - } + attachDragPreview(editor, dragImageElement); } -export function unsetDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement !== undefined) { - if (rootEl instanceof ShadowRoot) { - rootEl.removeChild(dragImageElement); - } else { - rootEl.body.removeChild(dragImageElement); - } - - dragImageElement = undefined; - } +export function unsetDragImage() { + // `remove()` rather than `removeChild()` on the container it was added to: + // the preview only outlives a single drag when something went wrong (a missed + // `dragend`, an editor unmounting mid-drag), and in those cases the container + // may no longer be its parent. + dragImageElement?.remove(); + dragImageElement = undefined; } export function dragStart< @@ -182,12 +199,12 @@ export function dragStart< view.dispatch( view.state.tr.setSelection(MultipleNodeSelection.create(doc, from, to)), ); - setDragImage(view, from, to); + setDragImage(editor, view, from, to); } else { view.dispatch( view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)), ); - setDragImage(view, pos); + setDragImage(editor, view, pos); } const selectedSlice = view.state.selection.content(); diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 75591d0a44..060ba105ab 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -110,12 +110,7 @@ function setDragImage( dataTransfer: DataTransfer, ) { const dragImage = view.tableElement - ? setTableDragImage( - editor.prosemirrorView, - view.tableElement, - cells, - orientation, - ) + ? setTableDragImage(editor, view.tableElement, cells, orientation) : undefined; if (dragImage) { diff --git a/packages/core/src/extensions/TableHandles/dragPreview.test.ts b/packages/core/src/extensions/TableHandles/dragPreview.test.ts index 7c81e39162..0071c9d9b9 100644 --- a/packages/core/src/extensions/TableHandles/dragPreview.test.ts +++ b/packages/core/src/extensions/TableHandles/dragPreview.test.ts @@ -69,7 +69,7 @@ describe("setTableDragImage", () => { const block = editor.getBlock("table-0")! as any; const preview = setTableDragImage( - editor.prosemirrorView, + editor, blockElement(), getCellsAtRowHandle(block, 1), "row", @@ -83,7 +83,7 @@ describe("setTableDragImage", () => { const block = editor.getBlock("table-0")! as any; const preview = setTableDragImage( - editor.prosemirrorView, + editor, blockElement(), getCellsAtColumnHandle(block, 2), "col", @@ -98,7 +98,7 @@ describe("setTableDragImage", () => { // `DataTransfer.setDragImage` only works with an element that's in the // document. const preview = setTableDragImage( - editor.prosemirrorView, + editor, blockElement(), getCellsAtRowHandle(block, 0), "row", @@ -113,23 +113,31 @@ describe("setTableDragImage", () => { expect(preview.isConnected).toBe(false); }); - it("replaces a previous snapshot rather than stacking them", () => { + // The browser rasterizes the snapshot in place, so it has to sit somewhere + // that resolves the same CSS as the table it's a picture of. `document.body` + // doesn't: anything inherited from an ancestor of the editor - a theme class, + // or custom properties on a wrapper element - stops applying there. + it("attaches the snapshot inside the editor's portal container", () => { 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, + const preview = setTableDragImage( + editor, blockElement(), - cells, + getCellsAtRowHandle(block, 0), "row", )!; + expect(editor.portalElement.contains(preview)).toBe(true); + expect(mountPoint.parentElement!.contains(editor.portalElement)).toBe(true); + }); + + 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, blockElement(), cells, "row")!; + const second = setTableDragImage(editor, blockElement(), cells, "row")!; + expect(first.isConnected).toBe(false); expect(second.isConnected).toBe(true); }); @@ -139,7 +147,7 @@ describe("setTableDragImage", () => { expect( setTableDragImage( - editor.prosemirrorView, + editor, document.createElement("div"), getCellsAtRowHandle(block, 0), "row", diff --git a/packages/core/src/extensions/TableHandles/dragPreview.ts b/packages/core/src/extensions/TableHandles/dragPreview.ts index 2c47ba1a94..d6bb76e219 100644 --- a/packages/core/src/extensions/TableHandles/dragPreview.ts +++ b/packages/core/src/extensions/TableHandles/dragPreview.ts @@ -1,6 +1,6 @@ -import { EditorView } from "prosemirror-view"; - import { RelativeCellIndices } from "../../api/blockManipulation/tables/tables.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { attachDragPreview } from "../../extensions-shared/dragPreviewContainer.js"; import { DRAG_SOURCE_COL_CLASS, DRAG_SOURCE_ROW_CLASS, @@ -59,7 +59,7 @@ function cloneCellWithSize(cell: Element): HTMLElement { * back to the hidden drag image. */ export function setTableDragImage( - view: EditorView, + editor: BlockNoteEditor, // The block container element for the table, i.e. `TableHandlesView`'s // `tableElement`. blockElement: HTMLElement, @@ -108,20 +108,10 @@ export function setTableDragImage( 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 + const inheritedClasses = editor.prosemirrorView.dom.className .split(" ") .filter( (className) => @@ -132,27 +122,23 @@ export function setTableDragImage( .join(" "); wrapper.className = - `bn-root bn-drag-preview bn-table-drag-preview ${inheritedClasses}`.trim(); + `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); - } + attachDragPreview(editor, 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. + // `remove()` rather than `removeChild()` on the container it 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 container + // may no longer be its parent. dragImageElement?.remove(); dragImageElement = undefined; } diff --git a/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-chromium-linux.png b/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..a9803f5742d6a9555a2ce3ea85af50a25b8232df GIT binary patch literal 1770 zcmb7Fc~sK*7AKq1d}d`TmZmkL;x3`(f@^N*LrPpiQ_GD~)1-u41}Zf}16NW^$XBRi zA-Uw>Qcf5ZjoV0C*`}F^j3t39n$!F7=I@zv-ap@S&pn@W@ArJ}=YB5baFE|t_1)?q z5NIpX-#ZutQppA4{c4+m*9K%9!E8T&VQaN}!2v@NG(_y#Xn)D%BE`^KwLPmPdcSIsY5!%8TdU* z)tuC7GIC#GC`F4;txZ6T=2F?GQ9snFY7mSj@S~d5diCTPn-n(>>GgJYrhvLlGd*@f z_pzQZL^Pr}a5^$fOHQ9$vC9>2-qq>Kw+;JNSbxnL;YO_L)7Kn+6M+;Gp{~c9*Fcqb z2xZ#}Bt~72aZ^X%+%M-2>`JY1L4I%V&M^#YU8FLnX2XF}=O@*3H-bCA$)XGk=qX95 z#^1Xa*#MIfIV!pWtrrh^a=T`1jN>rD_QQBp8u6I8<$&1Z&A1B0sMRdZdCBFt1!gKPRIoGimsHYq+D~Nm zubmgU)E$6cijWU4QPhmDG{nVD=k3s0q;mScIyfaoj3K}-z3$}lZ5Da=># z*-JW4FO`*`tf>_~{S;_+BNb)oMLMi^JbO(hdsb{boJSB z#fS#O+dis)sgKoxncd{L4b?CFaEoVlMLD)(!B!O_?ea}W!&v*KX1#hE1^w-$!J1jz zlql4VLEG2giojgZLFj<%p=BQ{$4b(K6_{((j-i~qoJb+hC&%0dQ46U1WT1&yk(O+v zt0X!FAo>}33GN&f)MF7ST(0WyM~kCVl_YrQhU)N44u!Qvu~-upTowt zgQ2%mT_?q&J) zKMeMvE7ZTOy|;(8^`iOG_q|$9PR=j-<-T1+2PT@+l|b|(Bg zJ#NAd_e8zbXu~L zY$d}tXwOaD-q@jhlJjk2V4Zc#wcSM@v&BxY@T`VjjlMg(R~F|;SrNOj^bZB}v^aDc zi#te&cI(5~_KbMGnZ}zM?FUAtfaKoMcO=~_=C|?G!tI;IpQGo_@!-8}+gomb((`*l zY$g+}9l9Ah+!K*fn0)kqvKshT41$k%`<)~7Dr}g;i{ERrEwSND<9I{$@R_mqgELE> ztIHg-sWE8K+o{1vDtWuYLYr{dJf$(!DQ(eTM%J3PXnYnbe`lEfaU_~X8*RNy1Uopf zps>uw;!=v$_1pM7go{MJI6^l@mff4FTfs(nVMDD0hxSY^>(h?3)YmlI^Bb0HxxC`_ zNIc-ul7>JTyq!Y=n_d28_`sRc(rir0;BZU2K5Ef&i_($3{AFi#o7mh8aE20&{X1{g po)2lUrz$C}{70|g$Ig!BpW=6W5!^k1;;O+Nqt literal 0 HcmV?d00001 diff --git a/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-firefox-linux.png b/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-firefox-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..4adf77118e1ad279ba9fffbfc1fde191a4da5e67 GIT binary patch literal 1737 zcmb7Fdpy%^6d#ImODe=GWTqv#tWin|v88R9$ve4}+;SO~ikOwqlItwx9>$uAHrG{K zw^1<_+WVH6+$QEyrZ>61KlSJP`Skg`|2)t0Ip;j*oadbH`94>VqMcO~wG}}ikO~sv zhy{UUF9T=GHS)k}9Mo|h1d`7}Iv&Ku$V@zq4a-fIdD7qaJwb0ZtB#-Bpik@6cV4fB z*h-OA^tok<*!*nYfygG7FiLrcv#!2Ag_5YyipxOxml4APLm)9nHaM!mRRdP1c=?5H zqjc&Q_35Fs*{6}I*}TUiS8?$QNGdoPdl8JJ+M67pf{~@0^pRBhTg5Ce5*xf4K?OtW zWC7(yPZ>a|cn`E37pHYrs&;OZX3q7Z z_TxIfgSiV!E3ROWzGlYq+|5S2_vjE$Z9T%r)&)Fl_Jl;EHSXmLGw%Mek?79P1L}udpe9SPfoe~Dec)Vij}Bf zbt)`a!P@uEbi2;Xv%*MI-kmT@t>e3|OXQX5Rm0@?!~hqKT5_L{r+?#6<$A--@m=?T z!V8aCgdj3a<|5IHbB`9qPHc89Gx~c{R2nDL5>;Sr%slA;JOs9@QBT*#mruI3#n8au zQnQk#x!+5Dt1yw1y}hh4rg*49NBU#o(?LZ>JGkCe`%Ve|>7%@^+ZnMi28_^&gP=-Y zomyx6q^YC)B_UR3^`NmO_gj3fDPAz!QU3WY6PAZJrNl)}hUM;KXZ2;2ARhjs`+j;7 zGFfb%G1&LVNN)CHpjAw(R96Reb#=s>V8Muj>QUj$ytu1=J^*(Zi8?t)l8zQwUrb6` zHINT~Q|56aBmVxd?al{HCN;bn4-q*6Mvhwy_iPA3d1abhfbFW$6K2~DX$}o97dAZ% zdNcgvI=S)-H4yn_AYbffZ^%h=A;5OGaLDINsb=if7BhUURpIhO`OalYKAVcQzI}5E`i6C zx`*lvtINs~o2U*~UIr8QGY-387@k|#SCy{_JE`it{pZrGP}m>7ydBU>O29l;o%MT{ zrBcHaSo@Mu-?wmQ_g@pY_gB=LO@DD~8$E|#SJtu3IfKR$bKG%<4-*hyGcbGHPoG{J zWdB_H+HkVj!%FR1^KSPpfp`U7PBQQl%RH`Nd;XD<(7DdLu^N3r->ZwF$9s}p;=Iwj zw2cUDl_;DL0Dl8P#248vmsUNl@omeCjB1>8EO$K@&n`L^ekNFGbp_ZcLgsTGH;#F{ zW(zR6^{#1M(c^c@AJu<_545W4iOa*Mq~oa-bh?#a%OizZ15IBd6b8WzF`F~9x_EMX z8zO=}P@8c=>j+S$*@dkB%L-WY(kvQn<6qSY8MY74pMlUTE25mAYC+OXPfQit0$Jvf zN=Plw=0~WaCJXMI*HwV8!Z!}?nhS?615N^gR~2m~_ikkVsQ zNHtSs4fQ9}uIpBkUZ4d&_bCTSHFRw6gR}Ur3pRmhQ~$A9i}<*KRh%D}wcK_mCuTpV zIBtDc$I7Xs=62lNgxUYpK{{!)hhxBzQ-~T#vR^^j9Ypi_+bIKPt+N)owilB4e0kir znZZnUr05STGFu7Y%ti7~icBlH1$4Ih0bR+%i)6~NRxb15UWjiBH&GG`#4QC8hk)Y2 z4O=j8W1-M}93mVL`_FtS*pslJV_EAN2^4^^j9Ovhs?8P&Wj4`cf*cbMLjZFy#Gx;?89O@buA7X?BdLOWL5pGf={yWKj& literal 0 HcmV?d00001 diff --git a/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-webkit-linux.png b/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/blockDragPreview-webkit-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..4adf77118e1ad279ba9fffbfc1fde191a4da5e67 GIT binary patch literal 1737 zcmb7Fdpy%^6d#ImODe=GWTqv#tWin|v88R9$ve4}+;SO~ikOwqlItwx9>$uAHrG{K zw^1<_+WVH6+$QEyrZ>61KlSJP`Skg`|2)t0Ip;j*oadbH`94>VqMcO~wG}}ikO~sv zhy{UUF9T=GHS)k}9Mo|h1d`7}Iv&Ku$V@zq4a-fIdD7qaJwb0ZtB#-Bpik@6cV4fB z*h-OA^tok<*!*nYfygG7FiLrcv#!2Ag_5YyipxOxml4APLm)9nHaM!mRRdP1c=?5H zqjc&Q_35Fs*{6}I*}TUiS8?$QNGdoPdl8JJ+M67pf{~@0^pRBhTg5Ce5*xf4K?OtW zWC7(yPZ>a|cn`E37pHYrs&;OZX3q7Z z_TxIfgSiV!E3ROWzGlYq+|5S2_vjE$Z9T%r)&)Fl_Jl;EHSXmLGw%Mek?79P1L}udpe9SPfoe~Dec)Vij}Bf zbt)`a!P@uEbi2;Xv%*MI-kmT@t>e3|OXQX5Rm0@?!~hqKT5_L{r+?#6<$A--@m=?T z!V8aCgdj3a<|5IHbB`9qPHc89Gx~c{R2nDL5>;Sr%slA;JOs9@QBT*#mruI3#n8au zQnQk#x!+5Dt1yw1y}hh4rg*49NBU#o(?LZ>JGkCe`%Ve|>7%@^+ZnMi28_^&gP=-Y zomyx6q^YC)B_UR3^`NmO_gj3fDPAz!QU3WY6PAZJrNl)}hUM;KXZ2;2ARhjs`+j;7 zGFfb%G1&LVNN)CHpjAw(R96Reb#=s>V8Muj>QUj$ytu1=J^*(Zi8?t)l8zQwUrb6` zHINT~Q|56aBmVxd?al{HCN;bn4-q*6Mvhwy_iPA3d1abhfbFW$6K2~DX$}o97dAZ% zdNcgvI=S)-H4yn_AYbffZ^%h=A;5OGaLDINsb=if7BhUURpIhO`OalYKAVcQzI}5E`i6C zx`*lvtINs~o2U*~UIr8QGY-387@k|#SCy{_JE`it{pZrGP}m>7ydBU>O29l;o%MT{ zrBcHaSo@Mu-?wmQ_g@pY_gB=LO@DD~8$E|#SJtu3IfKR$bKG%<4-*hyGcbGHPoG{J zWdB_H+HkVj!%FR1^KSPpfp`U7PBQQl%RH`Nd;XD<(7DdLu^N3r->ZwF$9s}p;=Iwj zw2cUDl_;DL0Dl8P#248vmsUNl@omeCjB1>8EO$K@&n`L^ekNFGbp_ZcLgsTGH;#F{ zW(zR6^{#1M(c^c@AJu<_545W4iOa*Mq~oa-bh?#a%OizZ15IBd6b8WzF`F~9x_EMX z8zO=}P@8c=>j+S$*@dkB%L-WY(kvQn<6qSY8MY74pMlUTE25mAYC+OXPfQit0$Jvf zN=Plw=0~WaCJXMI*HwV8!Z!}?nhS?615N^gR~2m~_ikkVsQ zNHtSs4fQ9}uIpBkUZ4d&_bCTSHFRw6gR}Ur3pRmhQ~$A9i}<*KRh%D}wcK_mCuTpV zIBtDc$I7Xs=62lNgxUYpK{{!)hhxBzQ-~T#vR^^j9Ypi_+bIKPt+N)owilB4e0kir znZZnUr05STGFu7Y%ti7~icBlH1$4Ih0bR+%i)6~NRxb15UWjiBH&GG`#4QC8hk)Y2 z4O=j8W1-M}93mVL`_FtS*pslJV_EAN2^4^^j9Ovhs?8P&Wj4`cf*cbMLjZFy#Gx;?89O@buA7X?BdLOWL5pGf={yWKj& literal 0 HcmV?d00001 diff --git a/tests/src/end-to-end/dragdrop/dragPreview.test.tsx b/tests/src/end-to-end/dragdrop/dragPreview.test.tsx new file mode 100644 index 0000000000..3b57ac5cf2 --- /dev/null +++ b/tests/src/end-to-end/dragdrop/dragPreview.test.tsx @@ -0,0 +1,200 @@ +import App from "@examples/01-basic/testing/src/App"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { DRAG_HANDLE_SELECTOR, EDITOR_SELECTOR } from "../../utils/const.js"; +import { expectElement, sleep, waitForSelector } from "../../utils/editor.js"; +import { moveMouseOverElement } from "../../utils/mouse.js"; + +// What the browser shows next to the cursor during a block drag is an image it +// rasterizes from a clone of the block. The composited image itself is drawn by +// the OS and can't be captured here, so these tests screenshot the element that +// was handed to `setDragImage` instead - if that renders wrong, the image the +// user sees is wrong in the same way. +// +// The dragged block gets a background colour on purpose: a preview that has +// been cut off from the editor's CSS still carries the right classes and +// attributes, so it looks correct in the DOM and only gives itself away as a +// transparent box when it's actually painted. + +const COLORED_BLOCK_SELECTOR = '[data-background-color="blue"]'; +const DRAG_PREVIEW_SELECTOR = ".bn-drag-preview"; + +// Replaces the document with a single blue-backgrounded paragraph. +async function seedColoredBlock() { + ( + 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: "paragraph", + attrs: { + backgroundColor: "blue", + textColor: "default", + textAlignment: "left", + }, + content: [{ type: "text", text: "Drag me" }], + }, + ], + }, + ], + }, + ], + }); + + return waitForSelector(`${EDITOR_SELECTOR} ${COLORED_BLOCK_SELECTOR}`); +} + +/** + * Starts a drag on `block` and returns the element the browser was given to + * rasterize, reattached where it was so it can be inspected and screenshotted. + * + * The element itself only lives until the end of the `dragstart` task - it + * can't be hidden with `opacity` (Firefox and WebKit rasterize the element as + * painted, so that would erase it from the drag image too), so it's removed as + * soon as the browser has its snapshot. Spying on `setDragImage` is therefore + * both the only way to get hold of it and the most direct assertion available: + * this is exactly the element the browser was asked to draw. + * + * The event is dispatched directly rather than driven with the mouse because + * Playwright can't simulate a native drag in Firefox - which is why the drag & + * drop tests next door skip it - and the preview is built in the `dragstart` + * handler, so this covers the part under test in all three browsers. + */ +async function captureDragPreview(block: Element): Promise { + await moveMouseOverElement(block); + const handle = await waitForSelector(DRAG_HANDLE_SELECTOR); + await sleep(100); + + const captured: { image?: Element; container?: Element | null } = {}; + // Deliberately unbound - it's called back with `.call(this, ...)` below, and + // reassigned to the prototype afterwards. + // eslint-disable-next-line @typescript-eslint/unbound-method + const setDragImage = DataTransfer.prototype.setDragImage; + DataTransfer.prototype.setDragImage = function (image, x, y) { + captured.image = image; + captured.container = image.parentElement; + return setDragImage.call(this, image, x, y); + }; + + try { + handle.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }), + ); + } finally { + DataTransfer.prototype.setDragImage = setDragImage; + } + + if (!captured.image || !captured.container) { + throw new Error("No drag preview was handed to setDragImage"); + } + + // Wait out the removal, then put it back in the container it came from - + // reattaching it anywhere else would change the CSS that applies to it, which + // is half of what's under test. + await vi.waitFor(() => { + if (captured.image!.isConnected) { + throw new Error("Drag preview not cleaned up yet"); + } + }); + captured.container.appendChild(captured.image); + + return captured.image as HTMLElement; +} + +function endDrag() { + document + .querySelector(DRAG_HANDLE_SELECTOR) + ?.dispatchEvent(new DragEvent("dragend", { bubbles: true })); +} + +describe("Block drag preview", () => { + beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + }); + + test("renders the dragged block with its background intact", async () => { + const block = await seedColoredBlock(); + const expectedBackground = getComputedStyle(block).backgroundColor; + + const preview = await captureDragPreview(block); + + const previewBlock = preview.querySelector( + COLORED_BLOCK_SELECTOR, + )!; + expect(getComputedStyle(previewBlock).backgroundColor).toBe( + expectedBackground, + ); + + // Pin it somewhere deterministic for the capture. `.bn-drag-preview` sits + // behind the page's content, which is what keeps it out of sight while the + // browser snapshots it, but would also put it behind the editor here. + preview.style.cssText += + ";position:fixed;top:200px;left:40px;z-index:9999;"; + + await expectElement(preview).toMatchScreenshot("blockDragPreview"); + + endDrag(); + }); + + test("is visible rather than hidden with opacity", async () => { + // The old approach - `opacity: 0.001` - only worked in Chrome. Firefox and + // WebKit rasterize the element as painted, so a near-transparent element + // produced a near-invisible drag image in both. + const block = await seedColoredBlock(); + const preview = await captureDragPreview(block); + + const { opacity, visibility, display } = getComputedStyle(preview); + expect(Number(opacity)).toBeGreaterThan(0.99); + expect(visibility).toBe("visible"); + expect(display).not.toBe("none"); + + endDrag(); + }); + + test("matches the width the block has in the editor", async () => { + const block = await seedColoredBlock(); + const widthInEditor = block.getBoundingClientRect().width; + + const preview = await captureDragPreview(block); + + // Pulled out of `.bn-editor` the clone has nothing constraining it, so + // without an explicit width it reflows to whatever the container gives it. + const previewBlock = preview.querySelector(".bn-block-outer")!; + expect(previewBlock.getBoundingClientRect().width).toBeCloseTo( + widthInEditor, + 0, + ); + + endDrag(); + }); + + test("is taken back out of the page once the drag has started", async () => { + const block = await seedColoredBlock(); + + // `captureDragPreview` only resolves once the preview has been removed, so + // reaching here at all is the assertion; this pins down that nothing is + // left behind afterwards either. + const preview = await captureDragPreview(block); + preview.remove(); + + endDrag(); + await sleep(100); + + expect(document.querySelectorAll(DRAG_PREVIEW_SELECTOR)).toHaveLength(0); + }); +}); diff --git a/tests/src/end-to-end/tables/tableDragVisuals.test.tsx b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx index d954abe2d1..3496c30998 100644 --- a/tests/src/end-to-end/tables/tableDragVisuals.test.tsx +++ b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx @@ -1,5 +1,12 @@ import App from "@examples/01-basic/testing/src/App"; -import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vite-plus/test"; import { render } from "vitest-browser-react"; import { EDITOR_SELECTOR, TABLE_SELECTOR } from "../../utils/const.js"; import { browserName, userEvent } from "../../utils/context.js"; @@ -167,6 +174,50 @@ async function startDrag( await dragOver(onto); } +/** + * Records the element handed to `DataTransfer.setDragImage` for the next drag. + * + * The snapshot is removed from the page as soon as the browser has rasterized + * it (it can't be left there hidden - Firefox and WebKit rasterize the element + * as painted, so hiding it would hide the drag image too), which makes the spy + * the only way to get hold of it. It's also the most direct assertion + * available: this is the exact element the browser was asked to draw. The image + * the user ends up seeing is composited by the OS and can't be inspected here. + * + * Restores itself after each test. + */ +function spyOnDragImage() { + // Deliberately unbound - it's called back with `.call(this, ...)` below, and + // reassigned to the prototype afterwards. + // eslint-disable-next-line @typescript-eslint/unbound-method + const original = DataTransfer.prototype.setDragImage; + let image: Element | undefined; + + DataTransfer.prototype.setDragImage = function (element, x, y) { + image = element; + return original.call(this, element, x, y); + }; + restoreDragImageSpy = () => { + DataTransfer.prototype.setDragImage = original; + }; + + return { + captured() { + if (!image) { + throw new Error("No drag image was handed to setDragImage"); + } + return image; + }, + }; +} + +let restoreDragImageSpy: (() => void) | undefined; + +afterEach(() => { + restoreDragImageSpy?.(); + restoreDragImageSpy = undefined; +}); + async function dragOver(cell: HTMLElement): Promise { const { x, y } = centerOf(cell); await mouseSequence([{ type: "move", x, y, steps: 10 }]); @@ -240,18 +291,9 @@ describe("Table drag visuals", () => { test.skipIf(skipDrag)( "shows a snapshot of the dragged row next to the cursor", async () => { + const dragImage = spyOnDragImage(); 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; - }); + const preview = dragImage.captured(); // One row, holding a copy of each cell in it. expect(preview.querySelectorAll("tr")).toHaveLength(1); @@ -277,15 +319,9 @@ describe("Table drag visuals", () => { test.skipIf(skipDrag)( "shows a snapshot of the dragged column next to the cursor", async () => { + const dragImage = spyOnDragImage(); 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; - }); + const preview = dragImage.captured(); // One row per cell in the column. expect(preview.querySelectorAll("tr")).toHaveLength(3); From 11f29e9e0fcc5af798a5e8fbfe908d36138d55dc Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Aug 2026 15:13:04 +0200 Subject: [PATCH 7/8] fix(core): scope table styling to `.bn-root` so drag previews keep it A table dragged with the side menu lost its cell borders in the preview. The preview is mounted into `editor.portalElement`, which is a sibling of `.bn-editor` rather than a descendant, so the table's appearance - scoped to `.bn-editor [data-content-type="table"]` - stopped matching and the block rendered as bare text. Scoping those rules to `.bn-root` instead covers both the editor and the portal container, so a table renders the same wherever it's mounted and the preview doesn't have to live in the editor's layout to be styled like it. What stays on `.bn-editor` is the editor chrome rather than the table's own appearance: the `.tableWrapper` padding that reserves room for the row/column handles and the add-row/column buttons, and the drag source highlight. Neither belongs in a preview. --- packages/core/src/editor/editor.css | 23 +++-- .../tableBlockDragPreview-chromium-linux.png | Bin 0 -> 3961 bytes .../tableBlockDragPreview-firefox-linux.png | Bin 0 -> 4395 bytes .../tableBlockDragPreview-webkit-linux.png | Bin 0 -> 4395 bytes .../end-to-end/dragdrop/dragPreview.test.tsx | 81 ++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-chromium-linux.png create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-firefox-linux.png create mode 100644 tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-webkit-linux.png diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 45c7c37ca4..5049f14a77 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -81,8 +81,8 @@ } /* 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. */ + `TableHandles/dragPreview.ts` out of bare cell clones, so it sits outside any + `[data-content-type="table"]` and gets none of the table styling below. */ .bn-table-drag-preview table { border-collapse: separate; border-spacing: 0; @@ -187,23 +187,30 @@ } /* table related: */ -.bn-editor [data-content-type="table"] table { +/* Scoped to `.bn-root` rather than `.bn-editor`: a table can be rendered + outside the contenteditable but still inside the editor's root - most notably + in the drag preview, which is mounted into `editor.portalElement` so that it + doesn't have to live in the editor's layout to be styled like it. Anything + here that's editor chrome rather than the table's own appearance (the + `.tableWrapper` padding that makes room for the handles, the drag source + highlight) stays scoped to `.bn-editor` above. */ +.bn-root [data-content-type="table"] table { width: auto !important; word-break: break-word; } -.bn-editor [data-content-type="table"] th, -.bn-editor [data-content-type="table"] td { +.bn-root [data-content-type="table"] th, +.bn-root [data-content-type="table"] td { border: 1px solid #ddd; padding: 5px 10px; } -.bn-editor [data-content-type="table"] th { +.bn-root [data-content-type="table"] th { font-weight: bold; text-align: left; } -.bn-editor [data-content-type="table"] th > p, -.bn-editor [data-content-type="table"] td > p { +.bn-root [data-content-type="table"] th > p, +.bn-root [data-content-type="table"] td > p { min-height: 1.5rem; } diff --git a/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-chromium-linux.png b/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..51fd857dcf320bf3be18b7191b6bce7a8d93d8a2 GIT binary patch literal 3961 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 D@ZeHG literal 0 HcmV?d00001 diff --git a/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-webkit-linux.png b/tests/src/end-to-end/dragdrop/__screenshots__/dragPreview.test.tsx/tableBlockDragPreview-webkit-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..d75dd83354176139758f7fb212e6470f9cf219f3 GIT binary patch literal 4395 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 D@ZeHG literal 0 HcmV?d00001 diff --git a/tests/src/end-to-end/dragdrop/dragPreview.test.tsx b/tests/src/end-to-end/dragdrop/dragPreview.test.tsx index 3b57ac5cf2..6fa70e4d8d 100644 --- a/tests/src/end-to-end/dragdrop/dragPreview.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragPreview.test.tsx @@ -17,6 +17,7 @@ import { moveMouseOverElement } from "../../utils/mouse.js"; // transparent box when it's actually painted. const COLORED_BLOCK_SELECTOR = '[data-background-color="blue"]'; +const TABLE_BLOCK_SELECTOR = '[data-content-type="table"]'; const DRAG_PREVIEW_SELECTOR = ".bn-drag-preview"; // Replaces the document with a single blue-backgrounded paragraph. @@ -54,6 +55,61 @@ async function seedColoredBlock() { return waitForSelector(`${EDITOR_SELECTOR} ${COLORED_BLOCK_SELECTOR}`); } +const CELL_ATTRS = { + textColor: "default", + backgroundColor: "default", + textAlignment: "left", + colspan: 1, + rowspan: 1, + colwidth: null, +}; + +// Replaces the document with a single table block. +async function seedTableBlock() { + ( + 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: [ + ["R1C1", "R1C2"], + ["R2C1", "R2C2"], + ].map((cells) => ({ + type: "tableRow", + content: cells.map((text) => ({ + type: "tableCell", + attrs: CELL_ATTRS, + content: [ + { + type: "tableParagraph", + content: [{ type: "text", text }], + }, + ], + })), + })), + }, + ], + }, + ], + }, + ], + }); + + return waitForSelector(`${EDITOR_SELECTOR} ${TABLE_BLOCK_SELECTOR}`); +} + /** * Starts a drag on `block` and returns the element the browser was given to * rasterize, reattached where it was so it can be inspected and screenshotted. @@ -151,6 +207,31 @@ describe("Block drag preview", () => { endDrag(); }); + // The table's cell borders come from BlockNote's own stylesheet rather than + // from the block's attributes, so they only survive into the preview if that + // styling is scoped somewhere the preview can reach. It used to be scoped to + // `.bn-editor`, which the preview is deliberately outside of, so a dragged + // table came out as unstyled text. + test("keeps the table's cell borders", async () => { + const table = await seedTableBlock(); + const borderInEditor = getComputedStyle( + document.querySelector(`${EDITOR_SELECTOR} ${TABLE_BLOCK_SELECTOR} td`)!, + ).border; + + const preview = await captureDragPreview(table); + + const previewCell = preview.querySelector(`${TABLE_BLOCK_SELECTOR} td`); + expect(previewCell).not.toBeNull(); + expect(getComputedStyle(previewCell!).border).toBe(borderInEditor); + expect(borderInEditor).not.toBe(""); + + preview.style.cssText += + ";position:fixed;top:200px;left:40px;z-index:9999;"; + await expectElement(preview).toMatchScreenshot("tableBlockDragPreview"); + + endDrag(); + }); + test("is visible rather than hidden with opacity", async () => { // The old approach - `opacity: 0.001` - only worked in Chrome. Firefox and // WebKit rasterize the element as painted, so a near-transparent element From 1431c660fcf2f70737d9e93026e218eb4c38f215 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Aug 2026 15:13:14 +0200 Subject: [PATCH 8/8] fix(deps): restore the missing vite-plus entry in the lockfile `a6f2c11a5` left `pnpm-lock.yaml` referencing `vite-plus@0.1.24` from the root importer while the packages section only has `0.2.9`, which the workspace catalog pins. A frozen install therefore fails outright: ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY Broken lockfile: no entry for 'vite-plus@0.1.24(...)' Regenerating the entry points the importer back at the catalog version. Note this restores the toolchain the repo actually declares, which surfaces 31 lint errors that the stale resolution had been hiding. They are all pre-existing and in files identical to `main` (math-block, diagram-block, code-block, exporters); none come from this branch, and they are left for separate cleanup. --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c072a7f3e..ee136f3605 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2329,7 +2329,7 @@ importers: 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) + version: 0.2.9(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.6.0)(typescript@7.0.2))(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(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: