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/.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..860942a982 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/README.md @@ -0,0 +1,55 @@ +# Table Reordering Visualization + +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. +- **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. + +## 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: + +| 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 | + +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-operable reorder path, and native HTML5 drag-and-drop isn't + 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. + +**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 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..c2e5c9198b --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocknote/example-ui-components-table-reordering-visualization", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "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", + "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..2ad9e61f5f --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx @@ -0,0 +1,86 @@ +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 "./tableStyles.css"; + +// 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, + }, + initialContent: [ + { + type: "heading", + props: { level: 2 }, + content: "Restyling BlockNote.js Table Reordering", + }, + { + 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"] }, + ], + }, + }, + ], + }); + + return ( + + + filterSuggestionItems(getCustomSlashMenuItems(editor), query) + } + /> + + ); +} 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..7c70fde262 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css @@ -0,0 +1,124 @@ +/** + * 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, + * 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 var(--table-border); + border-radius: 8px; + 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 var(--table-border); + border-bottom: 1px solid var(--table-border); + 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: 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: var(--table-row-hover); +} +.bn-editor [data-content-type="table"] .selectedCell:after { + background: var(--table-selected); + opacity: 1; +} + +/** + * 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"] 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: var(--table-drag-tint); + outline-color: var(--table-drag-outline); +} +.bn-editor [data-content-type="table"] .bn-table-drop-cursor { + 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. */ +.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: var(--table-handle-hover-bg); + color: var(--table-handle-hover-text); +} 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/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..c501c00016 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -58,13 +58,55 @@ -moz-osx-font-smoothing: grayscale; } +/* Marks the edge a dragged table row/column would be dropped at. */ .bn-table-drop-cursor { position: absolute; z-index: 20; background-color: #adf; + border-radius: 2px; pointer-events: none; } +/* Marks the table row/column currently being dragged. */ +.bn-editor [data-content-type="table"] .bn-table-drag-source-row, +.bn-editor [data-content-type="table"] .bn-table-drag-source-col { + /* Translucent rather than a flat tint, so it reads as a wash over whatever + the cell's own background is - including a dark theme, where an opaque + light fill would blot out the text. */ + background-color: rgb(170 221 255 / 22%); + /* An outline rather than a border, so it doesn't shift the cell's contents, + and inset so adjacent cells in the dragged row/column don't double it up. */ + outline: 1.5px dashed #adf; + outline-offset: -1.5px; +} + +/* Snapshot of the row/column being dragged, shown next to the cursor. Built in + `TableHandles/dragPreview.ts`, and appended outside the editor - so it can't + rely on any of the `.bn-editor` scoped table styling above. */ +.bn-table-drag-preview table { + border-collapse: separate; + border-spacing: 0; + background: var(--bn-colors-editor-background, #fff); + color: var(--bn-colors-editor-text, inherit); + border-radius: 8px; + box-shadow: + 0 8px 24px rgb(0 0 0 / 18%), + 0 2px 6px rgb(0 0 0 / 10%); + overflow: hidden; +} + +.bn-table-drag-preview th, +.bn-table-drag-preview td { + border: 2px solid #adf; + padding: 5px 10px; + box-shadow: 0 1px 4px rgb(0 0 0 / 12%); +} + +.bn-table-drag-preview th { + font-weight: bold; + text-align: left; +} + .bn-drag-preview { position: absolute; top: 0; diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 25d09380f1..75591d0a44 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -10,7 +10,7 @@ import { mergeCells, splitCell, } from "prosemirror-tables"; -import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import { DecorationSet, EditorView } from "prosemirror-view"; import { RelativeCellIndices, addRowsOrColumns, @@ -42,6 +42,8 @@ import { BlockSchemaWithBlock, } from "../../schema/index.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; +import { getTableDragDecorations } from "./dragDecorations.js"; +import { setTableDragImage, unsetTableDragImage } from "./dragPreview.js"; let dragImageElement: HTMLElement | undefined; @@ -96,6 +98,38 @@ function unsetHiddenDragImage(rootEl: Document | ShadowRoot) { } } +// Sets the image shown next to the cursor while dragging a table row or column +// to a snapshot of that row/column. Falls back to the hidden 1x1 image if the +// snapshot can't be built, since leaving the drag image unset makes the browser +// fill in its own - a ghost of the entire editor. +function setDragImage( + editor: BlockNoteEditor, + view: TableHandlesView, + cells: RelativeCellIndices[], + orientation: "row" | "col", + dataTransfer: DataTransfer, +) { + const dragImage = view.tableElement + ? setTableDragImage( + editor.prosemirrorView, + view.tableElement, + cells, + orientation, + ) + : undefined; + + if (dragImage) { + // Offset so the snapshot trails the cursor instead of sitting centered + // under it, which would hide the cell being pointed at. + dataTransfer.setDragImage(dragImage, 16, 16); + + return; + } + + setHiddenDragImage(editor.prosemirrorView.root); + dataTransfer.setDragImage(dragImageElement!, 0, 0); +} + function getChildIndex(node: Element) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } @@ -609,6 +643,10 @@ export class TableHandlesView implements PluginView { "drop", this.dropHandler as unknown as EventListener, ); + + // The drag image is normally cleaned up on `dragEnd`, which won't fire if + // the editor unmounts mid-drag. + unsetTableDragImage(); } } @@ -640,148 +678,40 @@ export const TableHandlesExtension = createExtension(({ editor }) => { }); return view; }, - // We use decorations to render the drop cursor when dragging a table row - // or column. The decorations are updated in the `dragOverHandler` method. + // We use decorations to highlight the row or column being dragged, and + // to render the drop cursor at the position it would be dropped into. + // The decorations are updated in the `dragOverHandler` method. props: { decorations: (state) => { if ( view === undefined || view.state === undefined || view.state.draggingState === undefined || - view.tablePos === undefined + view.tablePos === undefined || + !view.state.block ) { return; } - const newIndex = - view.state.draggingState.draggedCellOrientation === "row" - ? view.state.rowIndex - : view.state.colIndex; - - if (newIndex === undefined) { - return; - } - - const decorations: Decoration[] = []; - const { block, draggingState } = view.state; - const { originalIndex, draggedCellOrientation } = draggingState; - - // Return empty decorations if: - // - Dragging to same position - // - No block exists - // - Row drag not allowed - // - Column drag not allowed - if ( - newIndex === originalIndex || - !block || - (draggedCellOrientation === "row" && - !canRowBeDraggedInto(block, originalIndex, newIndex)) || - (draggedCellOrientation === "col" && - !canColumnBeDraggedInto(block, originalIndex, newIndex)) - ) { - return DecorationSet.create(state.doc, decorations); - } - - // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(view.tablePos + 1); + const { draggedCellOrientation, originalIndex } = + view.state.draggingState; - if (view.state.draggingState.draggedCellOrientation === "row") { - const cellsInRow = getCellsAtRowHandle( - view.state.block, - newIndex, - ); - - cellsInRow.forEach(({ row, col }) => { - // Gets each row in the table. - const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(row) + 1, - ); - - // Gets the cell within the row. - const cellResolvedPos = state.doc.resolve( - rowResolvedPos.posAtIndex(col) + 1, - ); - const cellNode = cellResolvedPos.node(); - // Creates a decoration at the start or end of each cell, - // depending on whether the new index is before or after the - // original index. - const decorationPos = - cellResolvedPos.pos + - (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); - decorations.push( - // The widget is a small bar which spans the width of the cell. - Decoration.widget(decorationPos, () => { - const widget = document.createElement("div"); - widget.className = "bn-table-drop-cursor"; - widget.style.left = "0"; - widget.style.right = "0"; - // This is only necessary because the drop indicator's height - // is an even number of pixels, whereas the border between - // table cells is an odd number of pixels. So this makes the - // positioning slightly more consistent regardless of where - // the row is being dropped. - if (newIndex > originalIndex) { - widget.style.bottom = "-2px"; - } else { - widget.style.top = "-3px"; - } - widget.style.height = "4px"; - - return widget; - }), - ); - }); - } else { - const cellsInColumn = getCellsAtColumnHandle( + return DecorationSet.create( + state.doc, + getTableDragDecorations( + state.doc, + view.tablePos, view.state.block, - newIndex, - ); - - cellsInColumn.forEach(({ row, col }) => { - // Gets each row in the table. - const rowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(row) + 1, - ); - - // Gets the cell within the row. - const cellResolvedPos = state.doc.resolve( - rowResolvedPos.posAtIndex(col) + 1, - ); - const cellNode = cellResolvedPos.node(); - - // Creates a decoration at the start or end of each cell, - // depending on whether the new index is before or after the - // original index. - const decorationPos = - cellResolvedPos.pos + - (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); - - decorations.push( - // The widget is a small bar which spans the height of the cell. - Decoration.widget(decorationPos, () => { - const widget = document.createElement("div"); - widget.className = "bn-table-drop-cursor"; - widget.style.top = "0"; - widget.style.bottom = "0"; - // This is only necessary because the drop indicator's width - // is an even number of pixels, whereas the border between - // table cells is an odd number of pixels. So this makes the - // positioning slightly more consistent regardless of where - // the column is being dropped. - if (newIndex > originalIndex) { - widget.style.right = "-2px"; - } else { - widget.style.left = "-3px"; - } - widget.style.width = "4px"; - - return widget; - }), - ); - }); - } - - return DecorationSet.create(state.doc, decorations); + { + draggedCellOrientation, + originalIndex, + newIndex: + draggedCellOrientation === "row" + ? view.state.rowIndex + : view.state.colIndex, + }, + ), + ); }, }, }), @@ -805,9 +735,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => { ); } + const originalIndex = view.state.colIndex; + view.state.draggingState = { draggedCellOrientation: "col", - originalIndex: view.state.colIndex, + originalIndex, mousePos: event.clientX, }; view.emitUpdate(); @@ -826,8 +758,13 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - setHiddenDragImage(editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + setDragImage( + editor, + view, + getCellsAtColumnHandle(view.state.block, originalIndex), + "col", + event.dataTransfer!, + ); event.dataTransfer!.effectAllowed = "move"; }, @@ -845,9 +782,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => { ); } + const originalIndex = view!.state.rowIndex; + view!.state.draggingState = { draggedCellOrientation: "row", - originalIndex: view!.state.rowIndex, + originalIndex, mousePos: event.clientY, }; view!.emitUpdate(); @@ -866,8 +805,13 @@ export const TableHandlesExtension = createExtension(({ editor }) => { return; } - setHiddenDragImage(editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + setDragImage( + editor, + view!, + getCellsAtRowHandle(view!.state.block, originalIndex), + "row", + event.dataTransfer!, + ); event.dataTransfer!.effectAllowed = "copyMove"; }, @@ -892,6 +836,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => { } unsetHiddenDragImage(editor.prosemirrorView.root); + unsetTableDragImage(); }, /** diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.test.ts b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts new file mode 100644 index 0000000000..e830ff5a41 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts @@ -0,0 +1,320 @@ +import { Decoration } from "prosemirror-view"; +import { describe, expect, it } from "vite-plus/test"; + +import { getNodeById } from "../../api/nodeUtil.js"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { TableDragState, getTableDragDecorations } from "./dragDecorations.js"; + +/** + * @vitest-environment jsdom + */ + +/** + * | 1-1 | 1-2 | 1-3 | + * | 2-1 | 2-2 | 2-3 | + * | 3-1 | 3-2 | 3-3 | + */ +const simpleTable: PartialBlock[] = [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["1-1", "1-2", "1-3"] }, + { cells: ["2-1", "2-2", "2-3"] }, + { cells: ["3-1", "3-2", "3-3"] }, + ], + }, + }, +]; + +const cell = (text: string, colspan = 1, rowspan = 1) => + ({ + type: "tableCell", + props: { colspan, rowspan }, + content: text, + }) as any; + +/** + * | 1-1 | 1-2 | + * | 2-1 | 2-2 | 2-3 | + * "1-2" spans two columns. + */ +const colspanTable: PartialBlock[] = [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: [cell("1-1"), cell("1-2", 2)] }, + { cells: [cell("2-1"), cell("2-2"), cell("2-3")] }, + ], + }, + }, +]; + +/** + * | 1-1 | 1-2 | 1-3 | + * | 2-1 | | 2-3 | + * "1-2" spans two rows. + */ +const rowspanTable: PartialBlock[] = [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: [cell("1-1"), cell("1-2", 1, 2), cell("1-3")] }, + { cells: [cell("2-1"), cell("2-3")] }, + ], + }, + }, +]; + +function setup(initialContent: PartialBlock[]) { + const editor = BlockNoteEditor.create({ initialContent }); + const doc = editor.prosemirrorState.doc; + const block = editor.getBlock("table-0")! as any; + const tablePos = getNodeById("table-0", doc)!.posBeforeNode + 1; + + const decorationsFor = (dragState: TableDragState) => + getTableDragDecorations(doc, tablePos, block, dragState); + + return { editor, doc, block, tablePos, decorationsFor }; +} + +// Node decorations span the cell they highlight; widget decorations are a +// single point. +const sourceDecorations = (decorations: Decoration[]) => + decorations.filter((decoration) => decoration.from !== decoration.to); +const dropCursors = (decorations: Decoration[]) => + decorations.filter((decoration) => decoration.from === decoration.to); + +// `Decoration.type` isn't part of prosemirror-view's public typings. +const typeOf = (decoration: Decoration) => (decoration as any).type; + +const classesOf = (decorations: Decoration[]) => + decorations.map((decoration) => typeOf(decoration).attrs?.class); + +describe("getTableDragDecorations", () => { + describe("source highlight", () => { + it("highlights every cell of the dragged row", () => { + const { decorationsFor, doc } = setup(simpleTable); + + const source = sourceDecorations( + decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 1, + newIndex: 2, + }), + ); + + expect(classesOf(source)).toEqual([ + "bn-table-drag-source-row", + "bn-table-drag-source-row", + "bn-table-drag-source-row", + ]); + expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([ + "2-1", + "2-2", + "2-3", + ]); + }); + + it("highlights every cell of the dragged column", () => { + const { decorationsFor, doc } = setup(simpleTable); + + const source = sourceDecorations( + decorationsFor({ + draggedCellOrientation: "col", + originalIndex: 1, + newIndex: 0, + }), + ); + + expect(classesOf(source)).toEqual([ + "bn-table-drag-source-col", + "bn-table-drag-source-col", + "bn-table-drag-source-col", + ]); + expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([ + "1-2", + "2-2", + "3-2", + ]); + }); + + // The drag image is set on `dragstart`, but the first `dragover` (which is + // what produces a target index) doesn't arrive until the cursor moves. + it("is shown before the drag has been over the table", () => { + const { decorationsFor } = setup(simpleTable); + + const decorations = decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 0, + newIndex: undefined, + }); + + expect(sourceDecorations(decorations)).toHaveLength(3); + expect(dropCursors(decorations)).toHaveLength(0); + }); + + it("is shown when hovering the row's own position", () => { + const { decorationsFor } = setup(simpleTable); + + const decorations = decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 1, + newIndex: 1, + }); + + expect(sourceDecorations(decorations)).toHaveLength(3); + expect(dropCursors(decorations)).toHaveLength(0); + }); + }); + + describe("drop cursor", () => { + it("is rendered across the target row", () => { + const { decorationsFor } = setup(simpleTable); + + expect( + dropCursors( + decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 0, + newIndex: 2, + }), + ), + ).toHaveLength(3); + }); + + it("is rendered down the target column", () => { + const { decorationsFor } = setup(simpleTable); + + expect( + dropCursors( + decorationsFor({ + draggedCellOrientation: "col", + originalIndex: 2, + newIndex: 0, + }), + ), + ).toHaveLength(3); + }); + + it("renders a bar spanning the cell", () => { + const { decorationsFor } = setup(simpleTable); + + const [widget] = dropCursors( + decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 0, + newIndex: 1, + }), + ); + const element = typeOf(widget).toDOM( + null, + () => widget.from, + ) as HTMLElement; + + expect(element.className).toBe("bn-table-drop-cursor"); + expect(element.style.height).toBe("4px"); + // Dropping below the original position, so the bar sits on the bottom + // edge of the target row. + expect(element.style.bottom).toBe("-2px"); + }); + }); + + describe("merged cells", () => { + it("highlights the cell spanning the dragged column", () => { + const { decorationsFor, doc } = setup(colspanTable); + + const source = sourceDecorations( + decorationsFor({ + draggedCellOrientation: "col", + originalIndex: 1, + newIndex: undefined, + }), + ); + + // The spanning cell is part of both column 1 and column 2, so dragging + // column 1 highlights it alongside the regular cell below. + expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([ + "1-2", + "2-2", + ]); + }); + + it("highlights the cell spanning the dragged row", () => { + const { decorationsFor, doc } = setup(rowspanTable); + + const source = sourceDecorations( + decorationsFor({ + draggedCellOrientation: "row", + originalIndex: 1, + newIndex: undefined, + }), + ); + + expect(source.map((d) => doc.nodeAt(d.from)?.textContent)).toEqual([ + "2-1", + "1-2", + "2-3", + ]); + }); + + it("omits the drop cursor when the column can't be dropped there", () => { + const { decorationsFor } = setup(colspanTable); + + const decorations = decorationsFor({ + draggedCellOrientation: "col", + originalIndex: 0, + newIndex: 1, + }); + + // Dropping column 0 into the middle of the column-spanning cell would + // tear it in half, so the move is blocked and only the source highlight + // is shown. + expect(sourceDecorations(decorations).length).toBeGreaterThan(0); + expect(dropCursors(decorations)).toHaveLength(0); + }); + }); + + describe("stale table position", () => { + // `tablePos` is captured on mousemove, which doesn't fire during a native + // drag - so a concurrent edit elsewhere in the document can leave it + // pointing past the end of the doc, or at some other node. + it("returns no decorations when the position is out of range", () => { + const { doc, block } = setup(simpleTable); + + expect( + getTableDragDecorations(doc, doc.content.size + 100, block, { + draggedCellOrientation: "row", + originalIndex: 0, + newIndex: 1, + }), + ).toEqual([]); + }); + + it("returns no decorations when the position isn't a table", () => { + const { doc, block } = setup([ + { id: "paragraph-0", type: "paragraph", content: "Hello" }, + ...simpleTable, + ]); + + const paragraphPos = getNodeById("paragraph-0", doc)!.posBeforeNode + 1; + + expect( + getTableDragDecorations(doc, paragraphPos, block, { + draggedCellOrientation: "row", + originalIndex: 0, + newIndex: 1, + }), + ).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.ts b/packages/core/src/extensions/TableHandles/dragDecorations.ts new file mode 100644 index 0000000000..045984c6a7 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragDecorations.ts @@ -0,0 +1,169 @@ +import type { Node, ResolvedPos } from "prosemirror-model"; +import { Decoration } from "prosemirror-view"; + +import { + RelativeCellIndices, + canColumnBeDraggedInto, + canRowBeDraggedInto, + getCellsAtColumnHandle, + getCellsAtRowHandle, +} from "../../api/blockManipulation/tables/tables.js"; +import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; +import { BlockFromConfigNoChildren } from "../../schema/index.js"; + +/** Marks each cell of the row being dragged. */ +export const DRAG_SOURCE_ROW_CLASS = "bn-table-drag-source-row"; +/** Marks each cell of the column being dragged. */ +export const DRAG_SOURCE_COL_CLASS = "bn-table-drag-source-col"; +/** Marks the edge the dragged row/column would be dropped at. */ +export const DROP_CURSOR_CLASS = "bn-table-drop-cursor"; + +export type TableDragState = { + draggedCellOrientation: "row" | "col"; + /** + * The index of the row/column being dragged. + */ + originalIndex: number; + /** + * The index the row/column would be dropped into, or `undefined` if the drag + * hasn't been over the table yet. + */ + newIndex: number | undefined; +}; + +/** + * Builds the decorations shown while dragging a table row or column: + * + * - `bn-table-drag-source-row` / `bn-table-drag-source-col` on each cell of the + * row/column being dragged, so it's clear what's moving. Shown for the whole + * drag, including before the first `dragover` and while hovering a position + * the row/column can't be dropped into. + * - `bn-table-drop-cursor` widgets marking the edge the row/column would be + * dropped at. Only shown once the drag is over a valid, different position. + * + * Returns an empty array if the table can't be resolved at `tablePos`. + */ +export function getTableDragDecorations( + doc: Node, + /** + * Position just before the table node, i.e. `TableHandlesView`'s `tablePos`. + */ + tablePos: number, + block: BlockFromConfigNoChildren, + { draggedCellOrientation, originalIndex, newIndex }: TableDragState, +): Decoration[] { + // `tablePos` is only updated on mousemove, and mousemove doesn't fire during + // a native drag - so a transaction which shifts or removes the table mid-drag + // (a concurrent local or collaborative edit) leaves it stale. Resolving a + // stale position throws, which would take down the whole view update, so drop + // the decorations instead. + let tableResolvedPos: ResolvedPos; + try { + tableResolvedPos = doc.resolve(tablePos + 1); + } catch { + return []; + } + if (tableResolvedPos.node().type.name !== "table") { + return []; + } + + // Resolves the relative indices returned by `getCellsAtRowHandle` / + // `getCellsAtColumnHandle` to a position inside that cell. + const resolveCell = ({ row, col }: RelativeCellIndices) => { + // Gets each row in the table. + const rowResolvedPos = doc.resolve(tableResolvedPos.posAtIndex(row) + 1); + + // Gets the cell within the row. + return doc.resolve(rowResolvedPos.posAtIndex(col) + 1); + }; + + const decorations: Decoration[] = []; + + const draggedCells = + draggedCellOrientation === "row" + ? getCellsAtRowHandle(block, originalIndex) + : getCellsAtColumnHandle(block, originalIndex); + + draggedCells.forEach((cell) => { + const cellResolvedPos = resolveCell(cell); + const cellStart = cellResolvedPos.before(); + + decorations.push( + Decoration.node(cellStart, cellStart + cellResolvedPos.node().nodeSize, { + class: + draggedCellOrientation === "row" + ? DRAG_SOURCE_ROW_CLASS + : DRAG_SOURCE_COL_CLASS, + }), + ); + }); + + // Only the source highlight is shown if: + // - The drag hasn't been over the table yet + // - Dragging to the same position + // - Row drag not allowed + // - Column drag not allowed + if ( + newIndex === undefined || + newIndex === originalIndex || + (draggedCellOrientation === "row" && + !canRowBeDraggedInto(block, originalIndex, newIndex)) || + (draggedCellOrientation === "col" && + !canColumnBeDraggedInto(block, originalIndex, newIndex)) + ) { + return decorations; + } + + const cellsAtNewIndex = + draggedCellOrientation === "row" + ? getCellsAtRowHandle(block, newIndex) + : getCellsAtColumnHandle(block, newIndex); + + cellsAtNewIndex.forEach((cell) => { + const cellResolvedPos = resolveCell(cell); + + // Creates a decoration at the start or end of each cell, depending on + // whether the new index is before or after the original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > originalIndex ? cellResolvedPos.node().nodeSize - 2 : 0); + + decorations.push( + // The widget is a small bar which spans the width (for a row) or height + // (for a column) of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = DROP_CURSOR_CLASS; + + // The offsets below are only necessary because the drop indicator's + // size is an even number of pixels, whereas the border between table + // cells is an odd number of pixels. So this makes the positioning + // slightly more consistent regardless of where the row/column is being + // dropped. + if (draggedCellOrientation === "row") { + widget.style.left = "0"; + widget.style.right = "0"; + if (newIndex > originalIndex) { + widget.style.bottom = "-2px"; + } else { + widget.style.top = "-3px"; + } + widget.style.height = "4px"; + } else { + widget.style.top = "0"; + widget.style.bottom = "0"; + if (newIndex > originalIndex) { + widget.style.right = "-2px"; + } else { + widget.style.left = "-3px"; + } + widget.style.width = "4px"; + } + + return widget; + }), + ); + }); + + return decorations; +} diff --git a/packages/core/src/extensions/TableHandles/dragPreview.test.ts b/packages/core/src/extensions/TableHandles/dragPreview.test.ts new file mode 100644 index 0000000000..7c81e39162 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragPreview.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + getCellsAtColumnHandle, + getCellsAtRowHandle, +} from "../../api/blockManipulation/tables/tables.js"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { TableHandlesExtension } from "./TableHandles.js"; +import { setTableDragImage, unsetTableDragImage } from "./dragPreview.js"; + +/** + * @vitest-environment jsdom + */ + +/** + * | 1-1 | 1-2 | 1-3 | + * | 2-1 | 2-2 | 2-3 | + */ +const testDocument: PartialBlock[] = [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["1-1", "1-2", "1-3"] }, + { cells: ["2-1", "2-2", "2-3"] }, + ], + }, + }, +]; + +let editor: BlockNoteEditor; +let mountPoint: HTMLDivElement; + +beforeEach(() => { + // jsdom does no layout, so it implements neither of these. Hovering a cell + // means dispatching a real bubbling mousemove, which other plugins (the side + // menu, prosemirror-tables' cell selection) also listen for - and they hit + // test the pointer position. + (document as any).elementFromPoint = () => null; + (document as any).elementsFromPoint = () => []; + + mountPoint = document.createElement("div"); + document.body.appendChild(mountPoint); + + editor = BlockNoteEditor.create({ initialContent: testDocument }); + editor.mount(mountPoint); +}); + +afterEach(() => { + unsetTableDragImage(); + editor._tiptapEditor.destroy(); + editor = undefined as any; + mountPoint.remove(); +}); + +const blockElement = () => + editor.prosemirrorView.dom.querySelector('[data-id="table-0"]')!; + +const previewRows = (preview: HTMLElement) => + Array.from(preview.querySelectorAll("tr")).map((row) => + Array.from(row.children).map((cell) => cell.textContent), + ); + +describe("setTableDragImage", () => { + it("builds a single-row snapshot of the dragged row", () => { + const block = editor.getBlock("table-0")! as any; + + const preview = setTableDragImage( + editor.prosemirrorView, + blockElement(), + getCellsAtRowHandle(block, 1), + "row", + )!; + + expect(preview).toBeDefined(); + expect(previewRows(preview)).toEqual([["2-1", "2-2", "2-3"]]); + }); + + it("builds a single-column snapshot of the dragged column", () => { + const block = editor.getBlock("table-0")! as any; + + const preview = setTableDragImage( + editor.prosemirrorView, + blockElement(), + getCellsAtColumnHandle(block, 2), + "col", + )!; + + expect(previewRows(preview)).toEqual([["1-3"], ["2-3"]]); + }); + + it("attaches the snapshot to the document so it can be captured", () => { + const block = editor.getBlock("table-0")! as any; + + // `DataTransfer.setDragImage` only works with an element that's in the + // document. + const preview = setTableDragImage( + editor.prosemirrorView, + blockElement(), + getCellsAtRowHandle(block, 0), + "row", + )!; + + expect(preview.isConnected).toBe(true); + expect(preview.className).toContain("bn-drag-preview"); + expect(preview.className).toContain("bn-table-drag-preview"); + + unsetTableDragImage(); + + expect(preview.isConnected).toBe(false); + }); + + it("replaces a previous snapshot rather than stacking them", () => { + const block = editor.getBlock("table-0")! as any; + const cells = getCellsAtRowHandle(block, 0); + + const first = setTableDragImage( + editor.prosemirrorView, + blockElement(), + cells, + "row", + )!; + const second = setTableDragImage( + editor.prosemirrorView, + blockElement(), + cells, + "row", + )!; + + expect(first.isConnected).toBe(false); + expect(second.isConnected).toBe(true); + }); + + it("returns undefined when the table's DOM can't be read", () => { + const block = editor.getBlock("table-0")! as any; + + expect( + setTableDragImage( + editor.prosemirrorView, + document.createElement("div"), + getCellsAtRowHandle(block, 0), + "row", + ), + ).toBeUndefined(); + }); +}); + +describe("drag start", () => { + // The handles only know which row/column they're on from having been hovered, + // so a drag can't start without a mousemove over the table first. + function hoverCell(row: number, col: number) { + const cell = blockElement().querySelectorAll("tr")[row].children[col]; + + cell.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, clientX: 0, clientY: 0 }), + ); + } + + function stubDataTransfer() { + const setDragImage: { + calls: [Element, number, number][]; + } = { calls: [] }; + + return { + dataTransfer: { + setDragImage: (image: Element, x: number, y: number) => + setDragImage.calls.push([image, x, y]), + effectAllowed: "", + } as unknown as DataTransfer, + setDragImage, + }; + } + + it("hands the row snapshot to the drag event", () => { + const tableHandles = editor.getExtension(TableHandlesExtension)!; + hoverCell(1, 0); + + const { dataTransfer, setDragImage } = stubDataTransfer(); + tableHandles.rowDragStart({ dataTransfer, clientY: 0 }); + + expect(setDragImage.calls).toHaveLength(1); + const [image, x, y] = setDragImage.calls[0]; + expect(previewRows(image as HTMLElement)).toEqual([["2-1", "2-2", "2-3"]]); + expect([x, y]).toEqual([16, 16]); + + tableHandles.dragEnd(); + + expect(image.isConnected).toBe(false); + }); + + it("hands the column snapshot to the drag event", () => { + const tableHandles = editor.getExtension(TableHandlesExtension)!; + hoverCell(0, 1); + + const { dataTransfer, setDragImage } = stubDataTransfer(); + tableHandles.colDragStart({ dataTransfer, clientX: 0 }); + + const [image] = setDragImage.calls[0]; + expect(previewRows(image as HTMLElement)).toEqual([["1-2"], ["2-2"]]); + + tableHandles.dragEnd(); + + expect(image.isConnected).toBe(false); + }); + + it("leaves the source highlight out of the snapshot", () => { + const tableHandles = editor.getExtension(TableHandlesExtension)!; + hoverCell(1, 0); + + const { dataTransfer, setDragImage } = stubDataTransfer(); + tableHandles.rowDragStart({ dataTransfer, clientY: 0 }); + + // The cells are cloned after the highlight decoration has been applied, so + // the snapshot has to drop it - it should look like the row, not like the + // row's drag state. + const [image] = setDragImage.calls[0]; + expect(image.querySelectorAll(".bn-table-drag-source-row")).toHaveLength(0); + + tableHandles.dragEnd(); + }); + + it("renders the source highlight from the moment the drag starts", () => { + const tableHandles = editor.getExtension(TableHandlesExtension)!; + hoverCell(1, 0); + + const { dataTransfer } = stubDataTransfer(); + tableHandles.rowDragStart({ dataTransfer, clientY: 0 }); + + expect( + editor.prosemirrorView.dom.querySelectorAll(".bn-table-drag-source-row"), + ).toHaveLength(3); + + tableHandles.dragEnd(); + + expect( + editor.prosemirrorView.dom.querySelectorAll(".bn-table-drag-source-row"), + ).toHaveLength(0); + }); +}); diff --git a/packages/core/src/extensions/TableHandles/dragPreview.ts b/packages/core/src/extensions/TableHandles/dragPreview.ts new file mode 100644 index 0000000000..2c47ba1a94 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragPreview.ts @@ -0,0 +1,158 @@ +import { EditorView } from "prosemirror-view"; + +import { RelativeCellIndices } from "../../api/blockManipulation/tables/tables.js"; +import { + DRAG_SOURCE_COL_CLASS, + DRAG_SOURCE_ROW_CLASS, + DROP_CURSOR_CLASS, +} from "./dragDecorations.js"; + +let dragImageElement: HTMLElement | undefined; + +// Clones a single table cell for use in the drag preview. The clone is pulled +// out of the real table, so it loses everything the table's own layout was +// giving it. +function cloneCellWithSize(cell: Element): HTMLElement { + // Read the size before cloning: column widths live on the table's + // `` and row heights are implied by the tallest cell in the row, + // neither of which survives into a table built from a handful of cells. + const { width, height } = cell.getBoundingClientRect(); + + const clone = cell.cloneNode(true) as HTMLElement; + clone.style.width = `${width}px`; + clone.style.height = `${height}px`; + clone.style.boxSizing = "border-box"; + + // The snapshot is taken after the drag decorations have been applied, so the + // cell being cloned is already marked up as the drag source (and may contain + // a drop cursor widget). The preview should look like the row/column itself, + // not like its drag state. + clone.classList.remove( + DRAG_SOURCE_ROW_CLASS, + DRAG_SOURCE_COL_CLASS, + "ProseMirror-selectednode", + ); + clone + .querySelectorAll(`.${DROP_CURSOR_CLASS}`) + .forEach((widget) => widget.remove()); + + // The preview only holds the dragged row (or column), so a span pointing at + // cells that aren't in it would stretch the clone out of shape. The size set + // above already accounts for the space the span was taking up. + clone.removeAttribute("colspan"); + clone.removeAttribute("rowspan"); + + return clone; +} + +/** + * Builds the image shown next to the cursor while dragging a table row or + * column: a snapshot of the row/column itself, styled like a lifted card, so + * the drag actually looks like you're carrying it to its new position. + * + * `cells` are the cells making up the dragged row/column, as returned by + * `getCellsAtRowHandle` / `getCellsAtColumnHandle` - using those (rather than + * indexing the DOM directly) means merged cells resolve to the right elements. + * + * Returns the element to hand to `DataTransfer.setDragImage`, or `undefined` + * if the table's DOM couldn't be read, in which case the caller should fall + * back to the hidden drag image. + */ +export function setTableDragImage( + view: EditorView, + // The block container element for the table, i.e. `TableHandlesView`'s + // `tableElement`. + blockElement: HTMLElement, + cells: RelativeCellIndices[], + orientation: "row" | "col", +): HTMLElement | undefined { + const tableBody = blockElement.querySelector("tbody"); + + if (!tableBody) { + return undefined; + } + + const cellClones: HTMLElement[] = []; + for (const { row, col } of cells) { + // Relative cell indices line up with the DOM here for the same reason they + // line up with the ProseMirror node tree: one `` per row node, one + // cell element per cell node. + const cell = tableBody.children[row]?.children[col]; + + if (cell) { + cellClones.push(cloneCellWithSize(cell)); + } + } + + if (cellClones.length === 0) { + return undefined; + } + + const table = document.createElement("table"); + const tableBodyClone = document.createElement("tbody"); + + if (orientation === "row") { + const rowClone = document.createElement("tr"); + cellClones.forEach((cell) => rowClone.appendChild(cell)); + tableBodyClone.appendChild(rowClone); + } else { + cellClones.forEach((cell) => { + const rowClone = document.createElement("tr"); + rowClone.appendChild(cell); + tableBodyClone.appendChild(rowClone); + }); + } + + table.appendChild(tableBodyClone); + + const wrapper = document.createElement("div"); + wrapper.appendChild(table); + + // The preview is appended outside the editor, so the theme variables (which + // `@blocknote/react` defines on `.bn-root`) only resolve if it carries the + // class, and the colour scheme, itself. + const colorScheme = view.dom + .closest(".bn-root") + ?.getAttribute("data-color-scheme"); + if (colorScheme) { + wrapper.setAttribute("data-color-scheme", colorScheme); + } + + // TODO: This is hacky, need a better way of assigning classes to the editor + // so that they can also be applied to the drag preview. Same caveat as the + // equivalent code in `SideMenu/dragging.ts`. + const inheritedClasses = view.dom.className + .split(" ") + .filter( + (className) => + className !== "ProseMirror" && + className !== "bn-root" && + className !== "bn-editor", + ) + .join(" "); + + wrapper.className = + `bn-root bn-drag-preview bn-table-drag-preview ${inheritedClasses}`.trim(); + + // dataTransfer.setDragImage(element) only works if element is attached to the + // DOM. + unsetTableDragImage(); + dragImageElement = wrapper; + + if (view.root instanceof ShadowRoot) { + view.root.appendChild(wrapper); + } else { + view.root.body.appendChild(wrapper); + } + + return wrapper; +} + +export function unsetTableDragImage() { + // `remove()` rather than `removeChild()` on the root the element was added + // to: the preview outlives a single drag only when something went wrong (a + // missed `dragend`, an editor unmounting mid-drag), and in those cases the + // root it was attached to may no longer be its parent. + dragImageElement?.remove(); + dragImageElement = undefined; +} diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index c0eb48c336..4cf23dddb1 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -892,6 +892,31 @@ export const examples = { readme: "By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container`. The `portalElements` prop on `BlockNoteView` lets you change that — globally via `default`, or per element by key.\n\nThis example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default — the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so floating UI escapes the wrapper and renders fully.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)", }, + { + projectSlug: "table-reordering-visualization", + fullSlug: "ui-components/table-reordering-visualization", + pathFromRoot: + "examples/03-ui-components/21-table-reordering-visualization", + config: { + playground: true, + docs: false, + author: "must", + tags: [ + "Intermediate", + "UI Components", + "Tables", + "Drag & Drop", + "Appearance & Styling", + ], + }, + title: "Table Reordering Visualization", + group: { + pathFromRoot: "examples/03-ui-components", + slug: "ui-components", + }, + readme: + "BlockNote gives table row/column dragging visual feedback out of the box: a\nsnapshot of the row/column follows the cursor, the row/column being dragged is\ntinted and outlined, and a drop indicator marks where it would land.\n\nThis example shows how to restyle a table - and those built-in drag\naffordances - to match your own product, using a Microsoft Loop-inspired look:\n\n- **Restyled tables**: rounded card look, muted header row, hairline\n borders, and a row-hover highlight instead of a harsh black grid.\n- **Retuned drag affordances**: the built-in drag source highlight, drop\n indicator and drag snapshot recolored to the same palette.\n- **Header row by default**: the `/table` command starts new tables with\n a header row already enabled, so the header styling is visible right away.\n\n## How It Works\n\nEverything here is CSS plus one slash-menu tweak - no extensions, no event\nhandling. BlockNote's `TableHandlesExtension` owns the whole drag lifecycle and\nexposes it through classes you can target:\n\n| Class | What it's on |\n| -------------------------- | ---------------------------------------------- |\n| `bn-table-drag-source-row` | every cell of the row being dragged |\n| `bn-table-drag-source-col` | every cell of the column being dragged |\n| `bn-table-drop-cursor` | a bar on the edge the row/column would drop at |\n| `bn-table-drag-preview` | the snapshot shown next to the cursor |\n\nThe first three are ProseMirror decorations inside the editor, so they're\nscoped under `.bn-editor [data-content-type=\"table\"]` like any other table\nstyle. `bn-table-drag-preview` is different: it's appended outside the editor\n(the browser can only use an attached element as a drag image), so it has to be\nstyled through its own class rather than through the table selectors.\n\n`tableStyles.css` does the restyling; `App.tsx` overrides the default `/table`\nslash-menu item so new tables start with `headerRows: 1`.\n\n## Known Limitations\n\n- **Keyboard and touch**: BlockNote's table drag handles are `draggable` +\n `onDragStart` only today (see `TableHandle.tsx`) - there's no\n keyboard-operable reorder path, and native HTML5 drag-and-drop isn't\n supported on touch browsers at all. Both are gaps in BlockNote's table-drag\n feature as a whole, not something this example introduces or fixes.\n- **Accessibility**: for the same reason, there's no keyboard focus\n restoration to verify after a reorder - the interaction can't be reached by\n keyboard in the first place yet.\n\n**Relevant Docs:**\n\n- [Tables](/docs/features/blocks/tables)\n- [Overriding CSS](/docs/react/styling-theming/overriding-css)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Slash Menu](/docs/react/components/suggestion-menus)", + }, ], }, theming: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f902ae81a..c9eb8924a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2258,6 +2258,49 @@ 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) + 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': 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 0000000000..eaa1c87435 Binary files /dev/null and b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-chromium-linux.png differ 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 0000000000..00eeac7540 Binary files /dev/null and b/tests/src/end-to-end/tables/__screenshots__/tableDragVisuals.test.tsx/tableRowDragInProgress-webkit-linux.png differ diff --git a/tests/src/end-to-end/tables/tableDragVisuals.test.tsx b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx new file mode 100644 index 0000000000..d954abe2d1 --- /dev/null +++ b/tests/src/end-to-end/tables/tableDragVisuals.test.tsx @@ -0,0 +1,332 @@ +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 { EDITOR_SELECTOR, TABLE_SELECTOR } from "../../utils/const.js"; +import { browserName, userEvent } from "../../utils/context.js"; +import { + expectElement, + focusOnEditor, + waitForSelector, +} from "../../utils/editor.js"; +import { mouseSequence, moveMouseOverElement } from "../../utils/mouse.js"; + +// Feedback shown while dragging a table row/column, all of which BlockNote's +// TableHandlesExtension renders by default: a highlight on the row/column being +// dragged (`bn-table-drag-source-row` / `-col`), a bar marking where it would +// land (`bn-table-drop-cursor`), and a snapshot of it next to the cursor +// (`bn-table-drag-preview`). The reorder itself is 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"; + +const CELL_ATTRS = { + textColor: "default", + backgroundColor: "default", + textAlignment: "left", + colspan: 1, + rowspan: 1, + colwidth: null, +}; + +// Replaces the document with a deterministic table, the same way +// tables.test.tsx seeds its row-drag test - driving the table UI to build one +// is slower and leaves the row/column count dependent on the default table. +async function seedTable(rows: string[][]) { + const rowsContent = rows.map((cells) => ({ + 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" }]); + }); +});