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