diff --git a/docs/content/docs/react/styling-theming/overriding-css.mdx b/docs/content/docs/react/styling-theming/overriding-css.mdx index cbc1f70935..7e37cfc6d4 100644 --- a/docs/content/docs/react/styling-theming/overriding-css.mdx +++ b/docs/content/docs/react/styling-theming/overriding-css.mdx @@ -38,6 +38,15 @@ BlockNote uses classes with the `bn-` prefix to style editor elements. Here are Menus and popovers may be portalled outside the toolbar or button that opened them. Target their own classes under `.bn-root` rather than relying on a toolbar or `.bn-container` ancestor. See [Configuring Portal Targets](/docs/react/components#configuring-portal-targets). +#### 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. It copies the editor's styling context, so normal table styles apply to it too. + +Set `--bn-table-drag-source-color` on the editor to customize the translucent source tint without replacing cell background colors. + ### 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..c12323e388 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/README.md @@ -0,0 +1,53 @@ +# 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. + +## How It Works + +Everything here is CSS - no additional extensions or 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. The snapshot lives outside the live editor, but copies its DOM styling +context, so those same table styles apply to it. Its outer wrapper also carries +`bn-table-drag-preview` for preview-specific overrides. The source tint uses +`--bn-table-drag-source-color` so it layers over existing cell backgrounds. + +`tableStyles.css` does the restyling; `App.tsx` provides an initial table with +`headerRows: 1` so the header styling is visible right away. + +## 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) 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..2f2746abde --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/index.html @@ -0,0 +1,15 @@ + + + + + + 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..97a0f90589 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/package.json @@ -0,0 +1,31 @@ +{ + "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", + "lint": "vp lint --type-aware src main.tsx vite.config.ts", + "build:prod": "vp run lint && 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..7ec3111444 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/App.tsx @@ -0,0 +1,40 @@ +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; + +import "./tableStyles.css"; + +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 ; +} 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..611a7dd9a3 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/src/tableStyles.css @@ -0,0 +1,116 @@ +/** + * 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 { + --bn-table-drag-source-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); +} + +/* 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/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..87c50de6e2 --- /dev/null +++ b/examples/03-ui-components/21-table-reordering-visualization/vite.config.ts @@ -0,0 +1,34 @@ +// 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 }) => ({ + // This example's standalone build lints its sources, unlike the workspace + // lint command which excludes examples. + lint: { ignorePatterns: [] }, + 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 be9ed67002..eb03da51f0 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -59,6 +59,7 @@ -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; @@ -66,6 +67,21 @@ pointer-events: none; } +/* Highlights the source row/column, separately from the destination cursor. */ +.bn-editor [data-content-type="table"] .bn-table-drag-source-row, +.bn-editor [data-content-type="table"] .bn-table-drag-source-col { + /* A separate layer preserves the cell's own background color beneath the + translucent tint, in both light and dark themes. */ + background-image: linear-gradient( + var(--bn-table-drag-source-color, rgb(170 221 255 / 22%)), + var(--bn-table-drag-source-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; +} + .bn-editor .bn-collaboration-cursor__base { position: relative; } diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 086b3609ff..61bd6d53e1 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -11,7 +11,7 @@ import { mergeCells, splitCell, } from "prosemirror-tables"; -import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import { DecorationSet, EditorView } from "prosemirror-view"; import { RelativeCellIndices, addRowsOrColumns, @@ -44,8 +44,12 @@ import { BlockSchemaWithBlock, } from "../../schema/index.js"; import { getBlockFromElement } from "../blockDOM.js"; - -let dragImageElement: HTMLElement | undefined; +import { getTableDragDecorations } from "./dragDecorations.js"; +import { + attachDragPreview, + removeDragPreview, + setTableDragImage, +} from "./dragPreview.js"; // TODO consider switching this to jotai, it is a bit messy and noisy export type TableHandlesState = { @@ -70,32 +74,35 @@ export type TableHandlesState = { widgetContainer: HTMLElement | undefined; }; -function setHiddenDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement) { - return; - } +// 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, 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); - dragImageElement = document.createElement("div"); - dragImageElement.innerHTML = "_"; - dragImageElement.style.opacity = "0"; - dragImageElement.style.height = "1px"; - dragImageElement.style.width = "1px"; - if (rootEl instanceof Document) { - rootEl.body.appendChild(dragImageElement); - } else { - rootEl.appendChild(dragImageElement); + return; } -} -function unsetHiddenDragImage(rootEl: Document | ShadowRoot) { - if (dragImageElement) { - if (rootEl instanceof Document) { - rootEl.body.removeChild(dragImageElement); - } else { - rootEl.removeChild(dragImageElement); - } - dragImageElement = undefined; - } + const hidden = document.createElement("div"); + hidden.textContent = "_"; + hidden.style.cssText = + "opacity:0;height:1px;width:1px;position:absolute;pointer-events:none"; + attachDragPreview(editor.prosemirrorView, hidden); + dataTransfer.setDragImage(hidden, 0, 0); } function getChildIndex(node: Element) { @@ -566,6 +573,9 @@ export class TableHandlesView implements PluginView { // because yjs replaces the element when for example you change the color via the side menu !this.tableElement?.isConnected ) { + // Removing/replacing the table can also remove the handle that would + // receive dragend, so don't leave its native-capture DOM attached. + removeDragPreview(this.pmView); this.state = undefined; this.tableId = undefined; this.tablePos = undefined; @@ -637,6 +647,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. + removeDragPreview(this.pmView); } } @@ -668,165 +682,45 @@ 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.state.draggingState === undefined || + !view.state.block ) { return; } - // Resolved against the state being rendered rather than read from - // `view.tablePos`, as ProseMirror computes decorations for a - // transaction before it calls the plugin view's `update` - so the - // cached position is still the pre-transaction one here. - const tablePos = view.getTablePos(state.doc); + const { draggedCellOrientation, originalIndex } = + view.state.draggingState; + // Decorations are computed before the plugin view updates, so use + // the table's position in the document being rendered. + const tablePos = view.getTablePos(state.doc); if (tablePos === undefined) { 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; - - // Reuse the drop target's cells for validation and decorations. - // Skip the lookup when there is no block or the position is unchanged. - const cellsAtDropTarget = - block && newIndex !== originalIndex - ? draggedCellOrientation === "row" - ? getCellsAtRowHandle(block, newIndex) - : getCellsAtColumnHandle(block, newIndex) - : []; - - // Return empty decorations if: - // - Dragging to the same position - // - No block exists - // - The row or column drag would split merged cells - if ( - newIndex === originalIndex || - !block || - (draggedCellOrientation === "row" && - !canRowBeDraggedInto( - block, - originalIndex, - newIndex, - cellsAtDropTarget, - )) || - (draggedCellOrientation === "col" && - !canColumnBeDraggedInto( - block, + return DecorationSet.create( + state.doc, + getTableDragDecorations( + state.doc.resolve(tablePos + 1), + view.state.block, + { + draggedCellOrientation, originalIndex, - newIndex, - cellsAtDropTarget, - )) - ) { - return DecorationSet.create(state.doc, decorations); - } - - // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(tablePos + 1); - - if (view.state.draggingState.draggedCellOrientation === "row") { - cellsAtDropTarget.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 { - cellsAtDropTarget.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); + newIndex: + draggedCellOrientation === "row" + ? view.state.rowIndex + : view.state.colIndex, + }, + ), + ); }, }, }), @@ -850,9 +744,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(); @@ -871,8 +767,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"; }, @@ -890,9 +791,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(); @@ -911,8 +814,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"; }, @@ -921,22 +829,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => { * used as the row drag handle, and the one used as the column drag handle. */ dragEnd() { - if (view!.state === undefined) { - throw new Error( - "Attempted to drag table row, but no table block was hovered prior.", - ); + if (!editor.headless) { + removeDragPreview(editor.prosemirrorView); } - - view!.state.draggingState = undefined; - view!.emitUpdate(); - - editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); - - if (editor.headless) { + // A collaborator may have deleted the table (and cleared the view state) + // while the browser was dragging. Ending that drag is still valid. + if (view?.state === undefined) { return; } - unsetHiddenDragImage(editor.prosemirrorView.root); + view.state.draggingState = undefined; + view.emitUpdate(); + + editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); }, /** diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png new file mode 100644 index 0000000000..4e83fe8d95 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-chromium-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png new file mode 100644 index 0000000000..a5d5d9d023 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-firefox-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png new file mode 100644 index 0000000000..b12912bc43 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-col-preview-wrapped-text-webkit-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png new file mode 100644 index 0000000000..155c95c5fd Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-chromium-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png new file mode 100644 index 0000000000..94a148f39c Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-firefox-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png new file mode 100644 index 0000000000..56d90f6527 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-dark-webkit-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png new file mode 100644 index 0000000000..fcf7a57a54 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-chromium-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png new file mode 100644 index 0000000000..ac427fad3c Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-firefox-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png new file mode 100644 index 0000000000..e9b96ee702 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-drag-in-progress-light-webkit-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png new file mode 100644 index 0000000000..f1b7b2bb75 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-chromium-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png new file mode 100644 index 0000000000..34e1ba2911 Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-firefox-linux.png differ diff --git a/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png new file mode 100644 index 0000000000..5dbda6b5db Binary files /dev/null and b/packages/core/src/extensions/TableHandles/__screenshots__/dragPreview.browser.test.ts/table-row-preview-wrapped-text-webkit-linux.png differ 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..b786a635f8 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragDecorations.test.ts @@ -0,0 +1,352 @@ +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"; + +/** + * | 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.resolve(tablePos + 1), 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.each([ + { + orientation: "row", + from: 0, + to: 1, + edge: "bottom", + offset: "-2px", + size: "height", + }, + { + orientation: "row", + from: 1, + to: 0, + edge: "top", + offset: "-3px", + size: "height", + }, + { + orientation: "col", + from: 0, + to: 1, + edge: "right", + offset: "-2px", + size: "width", + }, + { + orientation: "col", + from: 1, + to: 0, + edge: "left", + offset: "-3px", + size: "width", + }, + ] as const)( + "centers a $orientation cursor on the $edge border", + ({ orientation, from, to, edge, offset, size }) => { + const { decorationsFor } = setup(simpleTable); + + const [widget] = dropCursors( + decorationsFor({ + draggedCellOrientation: orientation, + originalIndex: from, + newIndex: to, + }), + ); + const element = typeOf(widget).toDOM( + null, + () => widget.from, + ) as HTMLElement; + + expect(element.className).toBe("bn-table-drop-cursor"); + expect(element.style[size]).toBe("4px"); + expect(element.style[edge]).toBe(offset); + }, + ); + }); + + 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); + }); + }); + + it.each(["row", "col"] as const)( + "skips deleted source and target cells during a %s drag", + (orientation) => { + const { editor, block } = setup(simpleTable); + // Decorations run before the view refreshes its cached block. + editor.updateBlock("table-0", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["remaining"] }], + }, + }); + const doc = editor.prosemirrorState.doc; + const tablePos = getNodeById("table-0", doc)!.posBeforeNode + 1; + for (const [originalIndex, newIndex] of [ + [0, 2], + [2, 0], + ]) { + const decorations = getTableDragDecorations( + doc.resolve(tablePos + 1), + block, + { + draggedCellOrientation: orientation, + originalIndex, + newIndex, + }, + ); + expect(decorations).toHaveLength(1); + expect(sourceDecorations(decorations)).toHaveLength( + originalIndex === 0 ? 1 : 0, + ); + } + editor._tiptapEditor.destroy(); + }, + ); +}); diff --git a/packages/core/src/extensions/TableHandles/dragDecorations.ts b/packages/core/src/extensions/TableHandles/dragDecorations.ts new file mode 100644 index 0000000000..989314eb4d --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragDecorations.ts @@ -0,0 +1,167 @@ +import type { Node, ResolvedPos } from "prosemirror-model"; +import { Decoration } from "prosemirror-view"; + +import { + canColumnBeDraggedInto, + canRowBeDraggedInto, + getCellsAtColumnHandle, + getCellsAtRowHandle, + getTableCellOccupancyGrid, +} 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"; + +const DROP_CURSOR_THICKNESS = 4; +// Mirrors the 1px cell border in editor.css. +const CELL_BORDER_WIDTH = 1; +const cursorAxes = { + row: { + before: "top", + after: "bottom", + crossStart: "left", + crossEnd: "right", + size: "height", + }, + col: { + before: "left", + after: "right", + crossStart: "top", + crossEnd: "bottom", + size: "width", + }, +} as const; + +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. + * + * `tableResolvedPos` points inside the table in the current document. + */ +export function getTableDragDecorations( + tableResolvedPos: ResolvedPos, + block: BlockFromConfigNoChildren, + { draggedCellOrientation, originalIndex, newIndex }: TableDragState, +): Decoration[] { + // Resolve the document path only once. Resolving every cell repeats the + // search through blocks before this table, while posAtIndex also scans all + // preceding rows/cells. A table-local pass gives constant-time cell lookups. + // These are physical child indices, not TableMap's logical (span-expanded) + // coordinates, matching the existing row/column handle helpers. + const cellPositions: { node: Node; pos: number }[][] = []; + tableResolvedPos.node().forEach((row, rowOffset) => { + const cells: { node: Node; pos: number }[] = []; + row.forEach((node, cellOffset) => { + cells.push({ + node, + pos: tableResolvedPos.start() + rowOffset + 1 + cellOffset, + }); + }); + cellPositions.push(cells); + }); + + const decorations: Decoration[] = []; + // Both helpers accept a prebuilt grid. Reuse it for the source and target + // rather than laying out the entire (possibly merged) table twice. + const occupancyGrid = getTableCellOccupancyGrid(block); + const getCellsAtHandle = + draggedCellOrientation === "row" + ? getCellsAtRowHandle + : getCellsAtColumnHandle; + const canBeDraggedInto = + draggedCellOrientation === "row" + ? canRowBeDraggedInto + : canColumnBeDraggedInto; + + const draggedCells = getCellsAtHandle(block, originalIndex, occupancyGrid); + draggedCells.forEach((cell) => { + const cellResolvedPos = cellPositions[cell.row]?.[cell.col]; + if (!cellResolvedPos) { + return; + } + const cellStart = cellResolvedPos.pos; + + 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 until hovering a different, valid target. + if (newIndex === undefined || newIndex === originalIndex) { + return decorations; + } + + // Reuse the target cells for validation and decorations, as the occupancy + // lookup can be expensive for large tables. + const cellsAtNewIndex = getCellsAtHandle(block, newIndex, occupancyGrid); + if (!canBeDraggedInto(block, originalIndex, newIndex, cellsAtNewIndex)) { + return decorations; + } + + cellsAtNewIndex.forEach((cell) => { + const cellResolvedPos = cellPositions[cell.row]?.[cell.col]; + if (!cellResolvedPos) { + return; + } + + // 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 + + 1 + + (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; + + const axis = cursorAxes[draggedCellOrientation]; + const after = newIndex > originalIndex; + widget.style[axis.crossStart] = "0"; + widget.style[axis.crossEnd] = "0"; + widget.style[axis.size] = `${DROP_CURSOR_THICKNESS}px`; + // Center the bar on the shared border; the leading edge includes the + // cell's own border, while the trailing edge is already outside it. + widget.style[after ? axis.after : axis.before] = + `${-DROP_CURSOR_THICKNESS / 2 - (after ? 0 : CELL_BORDER_WIDTH)}px`; + + return widget; + }), + ); + }); + + return decorations; +} diff --git a/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts b/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts new file mode 100644 index 0000000000..03125ddae3 --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragPreview.browser.test.ts @@ -0,0 +1,249 @@ +import { afterEach, expect, test } from "vite-plus/test"; + +import "../../style.css"; +import "../../fonts/inter.css"; +import "../../../../react/src/editor/styles.css"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { TableHandlesExtension } from "./TableHandles.js"; + +const fixtures: { editor: BlockNoteEditor; container: HTMLElement }[] = []; + +// Bridge the browser matcher to the repository's bundled vitest typings. +const expectElement = ( + expect as unknown as { + element( + this: void, + element: Element, + ): { + toMatchScreenshot(name: string): Promise; + }; + } +).element; + +afterEach(() => { + for (const { editor, container } of fixtures.splice(0)) { + editor._tiptapEditor.destroy(); + container.remove(); + } +}); + +function createEditor() { + const container = document.createElement("div"); + container.className = "bn-root bn-container"; + document.body.appendChild(container); + const initialContent: PartialBlock[] = [ + { + id: "table", + type: "table", + props: { textColor: "red" }, + content: { + type: "tableContent", + columnWidths: [140, 160, 140], + rows: [ + { + cells: [ + { + type: "tableCell", + props: { backgroundColor: "blue" }, + content: "first line\nsecond line", + }, + { type: "tableCell", content: "https://example.com/".repeat(8) }, + { type: "tableCell", content: "last cell" }, + ], + }, + { cells: ["another\nmultiline cell", "middle", "right"] }, + { cells: ["bottom left", "bottom middle", "bottom right"] }, + ], + }, + }, + ]; + const editor = BlockNoteEditor.create({ initialContent }); + editor.mount(container); + fixtures.push({ editor, container }); + return editor; +} + +function startDrag(editor: BlockNoteEditor, orientation: "row" | "col") { + editor.prosemirrorView.dom + .querySelector("td, th")! + .dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); + const handles = editor.getExtension(TableHandlesExtension)!; + const transfer = new DataTransfer(); + let preview: Element | undefined; + transfer.setDragImage = (image) => { + preview = image; + }; + const event = { dataTransfer: transfer, clientX: 0, clientY: 0 }; + if (orientation === "row") { + handles.rowDragStart(event); + } else { + handles.colDragStart(event); + } + if (!(preview instanceof HTMLElement)) { + throw new Error("Expected a table drag image"); + } + return { handles, transfer, preview }; +} + +function dragOver(cell: Element, dataTransfer: DataTransfer) { + const box = cell.getBoundingClientRect(); + cell.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: box.x + box.width / 2, + clientY: box.y + box.height / 2, + }), + ); +} + +for (const orientation of ["row", "col"] as const) { + test(`previews the ${orientation}, updates its drop cursor and cleans up`, async () => { + const editor = createEditor(); + await document.fonts.ready; + const root = editor.prosemirrorView.dom; + const rows = root.querySelectorAll("tr"); + const originals = + orientation === "row" + ? Array.from(rows[0].children) + : Array.from(rows, (row) => row.children[0]); + const source = originals[0]; + const background = getComputedStyle(source).backgroundColor; + source.classList.add("selectedCell"); + const handle = document.createElement("div"); + handle.className = "column-resize-handle"; + source.appendChild(handle); + const { handles, transfer, preview } = startDrag(editor, orientation); + const clones = Array.from(preview.querySelectorAll("td")); + expect(clones.map((cell) => cell.textContent)).toEqual( + originals.map((cell) => cell.textContent), + ); + expect(preview.parentElement).toBe(root.parentElement); + expect( + preview.querySelector( + ".selectedCell, .column-resize-handle, [contenteditable]", + ), + ).toBeNull(); + for (const [index, clone] of clones.entries()) { + const original = originals[index]; + expect(clone.getBoundingClientRect().width).toBeCloseTo( + original.getBoundingClientRect().width, + 0, + ); + expect(clone.scrollWidth).toBeLessThanOrEqual(clone.clientWidth); + expect(getComputedStyle(clone).backgroundColor).toBe( + getComputedStyle(original).backgroundColor, + ); + for (const property of ["whiteSpace", "overflowWrap", "color"] as const) { + expect(getComputedStyle(clone.querySelector("p")!)[property]).toBe( + getComputedStyle(original.querySelector("p")!)[property], + ); + } + } + const sourceSelector = `.bn-table-drag-source-${orientation}`; + expect(root.querySelectorAll(sourceSelector)).toHaveLength(3); + expect(preview.querySelector(sourceSelector)).toBeNull(); + expect(getComputedStyle(source).backgroundColor).toBe(background); + expect(getComputedStyle(source).backgroundImage).toContain( + "linear-gradient", + ); + dragOver( + orientation === "row" ? rows[2].children[0] : rows[0].children[2], + transfer, + ); + expect(root.querySelectorAll(".bn-table-drop-cursor")).toHaveLength(3); + dragOver(source, transfer); + expect(root.querySelector(".bn-table-drop-cursor")).toBeNull(); + expect(root.querySelectorAll(sourceSelector)).toHaveLength(3); + + // Photograph the native capture element on-screen, without the live table + // behind its transparent cells. Cleanup belongs to afterEach if this fails. + preview.style.cssText += ";position:fixed;top:20px;left:20px;z-index:9999"; + root.style.visibility = "hidden"; + await expectElement(preview).toMatchScreenshot( + `table-${orientation}-preview-wrapped-text`, + ); + handles.dragEnd(); + expect(preview.isConnected).toBe(false); + expect(root.querySelector(sourceSelector)).toBeNull(); + expect(getComputedStyle(root.querySelector("td")!).backgroundImage).toBe( + "none", + ); + }); +} + +test("replaces previews per editor and removes them when the table is deleted", () => { + const first = createEditor(); + const second = createEditor(); + const old = startDrag(first, "row").preview; + const replacement = startDrag(first, "row"); + expect(old.isConnected).toBe(false); + const other = startDrag(second, "col"); + other.handles.dragEnd(); + expect(other.preview.isConnected).toBe(false); + expect(replacement.preview.isConnected).toBe(true); + first.replaceBlocks( + ["table"], + [{ type: "paragraph", content: "replacement" }], + ); + expect(replacement.preview.isConnected).toBe(false); + expect(() => replacement.handles.dragEnd()).not.toThrow(); +}); + +test("keeps a merged header's dimensions without its spans", () => { + const editor = createEditor(); + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { + cells: [ + { + type: "tableCell", + props: { colspan: 2 }, + content: ["Merged header"], + }, + { type: "tableCell", content: ["Other header"] }, + ], + }, + { cells: ["one", "two", "three"] }, + ], + }, + }); + const original = editor.prosemirrorView.dom.querySelector("th")!; + const { preview } = startDrag(editor, "row"); + const clone = preview.querySelector("th")!; + expect(clone.textContent).toBe("Merged header"); + expect(clone.getBoundingClientRect().width).toBeCloseTo( + original.getBoundingClientRect().width, + 0, + ); + expect(clone.getBoundingClientRect().height).toBeCloseTo( + original.getBoundingClientRect().height, + 0, + ); + expect(clone.hasAttribute("colspan")).toBe(false); +}); + +test("renders the source tint and drop cursor in both themes", async () => { + const editor = createEditor(); + const root = editor.prosemirrorView.dom; + const themeRoot = root.closest(".bn-root")!; + await document.fonts.ready; + const { handles, transfer } = startDrag(editor, "row"); + dragOver(root.querySelectorAll("tr")[2].children[0], transfer); + for (const theme of ["light", "dark"] as const) { + themeRoot.dataset.colorScheme = theme; + expect(getComputedStyle(root).backgroundColor).toBe( + theme === "dark" ? "rgb(31, 31, 31)" : "rgb(255, 255, 255)", + ); + await expectElement(root.querySelector("table")!).toMatchScreenshot( + `table-row-drag-in-progress-${theme}`, + ); + } + handles.dragEnd(); +}); diff --git a/packages/core/src/extensions/TableHandles/dragPreview.ts b/packages/core/src/extensions/TableHandles/dragPreview.ts new file mode 100644 index 0000000000..e3be256d6d --- /dev/null +++ b/packages/core/src/extensions/TableHandles/dragPreview.ts @@ -0,0 +1,199 @@ +import type { EditorView } from "prosemirror-view"; +import { RelativeCellIndices } from "../../api/blockManipulation/tables/tables.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + DRAG_SOURCE_COL_CLASS, + DRAG_SOURCE_ROW_CLASS, + DROP_CURSOR_CLASS, +} from "./dragDecorations.js"; + +const previews = new WeakMap(); + +export function removeDragPreview(view: EditorView) { + previews.get(view)?.remove(); + previews.delete(view); +} + +export function attachDragPreview(view: EditorView, preview: Element) { + removeDragPreview(view); + const parent = view.dom.parentElement; + const root = view.root; + const previewParent = parent?.isConnected + ? parent + : root instanceof ShadowRoot + ? root + : root.body; + previewParent.appendChild(preview); + previews.set(view, preview); +} + +// 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", + "selectedCell", + ); + clone + .querySelectorAll(".ProseMirror-selectednode, .selectedCell") + .forEach((node) => { + node.classList.remove("ProseMirror-selectednode", "selectedCell"); + }); + clone + // Editing widgets and embeds are not part of the cell's content snapshot. + // As with whole-block previews, an embed can prevent native drag capture. + .querySelectorAll( + `.${DROP_CURSOR_CLASS}, .column-resize-handle, .ProseMirror-gapcursor, iframe, embed, object`, + ) + .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. Like whole-block previews, it keeps the editor's DOM/style context + * rather than approximating its appearance with a second set of table styles. + * + * `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( + editor: BlockNoteEditor, + // The block container element for the table, i.e. `TableHandlesView`'s + // `tableElement`. + blockElement: HTMLElement, + cells: RelativeCellIndices[], + orientation: "row" | "col", +): HTMLElement | undefined { + const sourceTable = blockElement.querySelector("table"); + const tableBody = sourceTable?.querySelector("tbody"); + + if (!sourceTable || !tableBody) { + return undefined; + } + + const table = sourceTable.cloneNode(false) as HTMLTableElement; + // The original width/colgroup describe the whole table, not this subset. + table.style.removeProperty("width"); + table.style.removeProperty("min-width"); + const tableBodyClone = tableBody.cloneNode(false) as 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 rowElement = tableBody.children[row]; + const cell = rowElement?.children[col]; + + if (cell && rowElement) { + // Row previews share one row; column previews keep each cell's own row. + const rowClone = + (orientation === "row" && tableBodyClone.firstElementChild) || + tableBodyClone.appendChild(rowElement.cloneNode(false)); + rowClone.appendChild(cloneCellWithSize(cell)); + } + } + + if (tableBodyClone.childElementCount === 0) { + return undefined; + } + + table.appendChild(tableBodyClone); + + // Copy the actual ancestor chain through the editor. It carries inherited + // block colors/line height and selectors such as `.bn-editor + // [data-content-type="table"]`, including app overrides and custom frames. + // Only layout constraints are reset: this is a snapshot of a subset, not a + // second editor viewport or table scroller. + let content: HTMLElement = table; + const view = editor.prosemirrorView; + let ancestor = sourceTable.parentElement; + while (ancestor && view.dom.contains(ancestor)) { + const clone = ancestor.cloneNode(false) as HTMLElement; + clone.classList.remove("ProseMirror-selectednode"); + clone.removeAttribute("id"); + clone.removeAttribute("contenteditable"); + clone.removeAttribute("tabindex"); + Object.assign(clone.style, { + width: "max-content", + minWidth: "0", + maxWidth: "none", + height: "auto", + minHeight: "0", + maxHeight: "none", + margin: "0", + padding: "0", + overflow: "visible", + }); + clone.appendChild(content); + content = clone; + if (ancestor === view.dom) { + break; + } + ancestor = ancestor.parentElement; + } + + // Same themed outer wrapper and off-screen capture strategy as SideMenu's + // whole-block preview (#3235). Do not hide with opacity: Firefox/Safari also + // apply it to the native drag bitmap. + const container = view.dom.parentElement?.closest(".bn-root"); + const wrapper = + (container?.cloneNode(false) as HTMLElement | undefined) ?? + document.createElement("div"); + wrapper.removeAttribute("id"); + wrapper.classList.add("bn-drag-preview", "bn-table-drag-preview"); + wrapper.setAttribute("aria-hidden", "true"); + Object.assign(wrapper.style, { + background: "transparent", + position: "absolute", + top: "-10000px", + left: "0", + width: "max-content", + maxWidth: "none", + margin: "0", + padding: "10px", + height: "auto", + minHeight: "0", + maxHeight: "75vh", + overflow: "hidden", + pointerEvents: "none", + }); + wrapper.appendChild(content); + + // dataTransfer.setDragImage(element) only works if element is attached to the + // DOM. + // Keep the off-screen image attached until drag end: Firefox/Safari may + // capture after dragstart. Attach beside the editor to inherit its theme. + attachDragPreview(view, wrapper); + + return wrapper; +} diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 6fa9173445..0dd1a19a6f 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -891,6 +891,31 @@ export const examples = { readme: "By default, BlockNote's floating components (formatting toolbar, slash menu, table handles, etc.) mount next to the editor, inside its `bn-container` (or inside whatever you render `BlockNoteViewEditor` into). The `portalElements` prop on `BlockNoteView` lets you change that: globally via `default`, or per component by key. The menus and popovers a floating component opens follow it wherever it mounts.\n\nThis example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default, so the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so the floating components escape the wrapper and render fully.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)\n- [Mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar)", }, + { + 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\n## How It Works\n\nEverything here is CSS - no additional extensions or event handling.\nBlockNote'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. The snapshot lives outside the live editor, but copies its DOM styling\ncontext, so those same table styles apply to it. Its outer wrapper also carries\n`bn-table-drag-preview` for preview-specific overrides. The source tint uses\n`--bn-table-drag-source-color` so it layers over existing cell backgrounds.\n\n`tableStyles.css` does the restyling; `App.tsx` provides an initial table with\n`headerRows: 1` so the header styling is visible right away.\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)", + }, ], }, theming: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f36d87bcc..7ebefcd6af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2307,6 +2307,49 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.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.9.5)(esbuild@0.27.5)(jiti@2.7.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + vite-plus: + specifier: 'catalog:' + version: 1.4.0(@opentelemetry/api@1.10.0)(@types/node@25.9.5)(@vitest/browser-playwright@5.0.5)(esbuild@0.27.5)(jiti@2.7.0)(jsdom@29.0.2(@noble/hashes@2.4.0)(canvas@3.1.0))(msw@2.11.5(@types/node@25.9.5)(typescript@7.0.2))(playwright@1.60.0)(terser@5.51.2)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.9.0) + examples/04-theming/01-theming-dom-attributes: dependencies: '@blocknote/ariakit': diff --git a/tests/src/end-to-end/tables/tables.test.tsx b/tests/src/end-to-end/tables/tables.test.tsx index 354e015c49..8f4d7a5b23 100644 --- a/tests/src/end-to-end/tables/tables.test.tsx +++ b/tests/src/end-to-end/tables/tables.test.tsx @@ -71,6 +71,48 @@ beforeEach(async () => { }); describe("Check Table interactions", () => { + // Cancellation is a native drag lifecycle check; visual coverage lives in + // TableHandles/dragPreview.browser.test.ts alongside the preview implementation. + test.skipIf(browserName === "firefox")( + "Escape cancels a table drag and cleans up its visuals", + async () => { + await focusOnEditor(); + await insertParagraph(); + await executeSlashCommand("table"); + const rows = document.querySelectorAll(`${TABLE_SELECTOR} tr`); + const handle = await getTableHandle( + rows[0].querySelector("td, th")!, + "row", + ); + const box = handle.getBoundingClientRect(); + const target = rows[1].children[0].getBoundingClientRect(); + await mouseSequence([ + { type: "move", x: box.x + box.width / 2, y: box.y + box.height / 2 }, + { type: "down" }, + { + type: "move", + x: target.x + target.width / 2, + y: target.y + target.height / 2, + steps: 10, + }, + ]); + await vi.waitFor(() => + expect( + document.querySelector(".bn-table-drag-source-row"), + ).not.toBeNull(), + ); + await userEvent.keyboard("{Escape}"); + await mouseSequence([{ type: "up" }]); + await vi.waitFor(() => + expect( + document.querySelector( + ".bn-table-drag-source-row, .bn-table-drop-cursor, .bn-table-drag-preview", + ), + ).toBeNull(), + ); + }, + ); + test("Should be able to type in cell", async () => { await focusOnEditor(); await executeSlashCommand("table");