-
-
Notifications
You must be signed in to change notification settings - Fork 758
Add table reordering visualization example (drag source highlight, drop-cursor color, floating drag image) #2920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mustafa-yilmaz
wants to merge
4
commits into
TypeCellOS:main
Choose a base branch
from
mustafa-yilmaz:table-reordering-visualization
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,150
−2
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5ea838c
feat(examples): add table reordering visualization example
mustafa-yilmaz ceedb6b
test(tables): cover the table-reordering-visualization example
mustafa-yilmaz 7af99b6
fix(examples): address CodeRabbit review findings on #2920
mustafa-yilmaz a283737
fix(examples): remap tablePos through tr.mapping in the drag decoration
mustafa-yilmaz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
12 changes: 12 additions & 0 deletions
12
examples/03-ui-components/21-table-reordering-visualization/.bnexample.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "playground": true, | ||
| "docs": false, | ||
| "author": "must", | ||
| "tags": [ | ||
| "Intermediate", | ||
| "UI Components", | ||
| "Tables", | ||
| "Drag & Drop", | ||
| "Appearance & Styling" | ||
| ] | ||
| } |
107 changes: 107 additions & 0 deletions
107
examples/03-ui-components/21-table-reordering-visualization/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| # Table Reordering Visualization | ||
|
|
||
| This example gives dragging a table row/column much clearer visual feedback | ||
| than BlockNote's default, matching the feel of tools like Microsoft Loop: | ||
|
|
||
| - **Restyled tables**: rounded card look, muted header row, hairline | ||
| borders, and a row-hover highlight instead of a harsh black grid. | ||
| - **Drag source highlight**: the row/column actually being dragged is | ||
| tinted and outlined so it's obvious what's moving. | ||
| - **Colored drop indicator**: the drop-position line uses a solid brand | ||
| color instead of the default pale blue. | ||
| - **Floating drag image**: a real snapshot of the row/column follows the | ||
| cursor while dragging, instead of BlockNote's default (invisible) native | ||
| drag image. | ||
| - **Header row by default**: the `/table` command starts new tables with | ||
| a header row already enabled, so the header styling is visible right away. | ||
|
|
||
| ## Interaction Model | ||
|
|
||
| Nothing here changes _what_ a row/column drag does - BlockNote's own | ||
| `TableHandlesExtension` still owns the drag lifecycle (`dragstart` / | ||
| `dragover` / `drop`) and the actual reorder (`moveRow` / `moveColumn` + | ||
| `editor.updateBlock`). This example only adds feedback layered on top of | ||
| that existing lifecycle: | ||
|
|
||
| 1. **Drag start** - `TableDragSourceExtension` reads the same | ||
| `tableHandlesPluginKey` transaction metadata BlockNote's own drop-cursor | ||
| decoration reads, and paints a ProseMirror node decoration on the | ||
| row/column being dragged. `useTableDragImage` builds a cloned snapshot of | ||
| that same row/column and swaps it in as the native drag image via | ||
| `DataTransfer.setDragImage`. | ||
| 2. **Drag over** - BlockNote's existing drop-cursor decoration renders as | ||
| normal (just recolored via CSS); the source decoration stays as the drag | ||
| continues, since it's keyed off the drag's _original_ index, not the | ||
| current hover target. | ||
| 3. **Drop / dragend** - BlockNote clears its `draggingState` and dispatches | ||
| the move as a normal transaction either way. Because the source | ||
| decoration is derived from that same state, it disappears the instant | ||
| `draggingState` is cleared - on a successful drop **and** on a cancelled | ||
| drag (e.g. `Escape`), since both go through `dragEnd()` set to `undefined`/`null`. | ||
|
|
||
| ## Known Limitations | ||
|
|
||
| - **Keyboard and touch**: BlockNote's table drag handles are | ||
| `draggable` + `onDragStart` only today (see `TableHandle.tsx`) - there's no | ||
| keyboard-operable reorder path, and native HTML5 drag-and-drop isn't | ||
| supported on touch browsers at all. Both are pre-existing gaps in | ||
| BlockNote's table-drag feature as a whole, not something this example | ||
| introduces or fixes - building either would be a separate, larger feature | ||
| for BlockNote's core drag system. | ||
| - **Accessibility**: for the same reason, there's no keyboard focus | ||
| restoration to verify after a reorder - the interaction can't be reached | ||
| by keyboard in the first place yet. | ||
| - **Merged cells**: the source-highlight decoration resolves cells by plain | ||
| row/column index, which doesn't account for `colspan`/`rowspan` shifting | ||
| indices. In practice BlockNote's own `canRowBeDraggedInto` / | ||
| `canColumnBeDraggedInto` guards already block most drags across a merged | ||
| cell, so this mainly affects highlighting fidelity in edge cases, not | ||
| document correctness - see the "merged (rowspan) cell" test. | ||
| - **Concurrent edits mid-drag**: confirmed via manual repro, this is worse | ||
| than it first looked. BlockNote's `TableHandlesView` stores `tablePos` | ||
| (and the table content snapshot used by `dropHandler`) once per | ||
| `mousemove`, and never remaps them through `tr.mapping`. `mousemove` | ||
| doesn't fire on the dragged-over element during a native drag, so any | ||
| transaction that changes the document elsewhere while a drag is in | ||
| progress - a concurrent local or collaborative edit - leaves both stale. | ||
| The _next_ `dragover` recomputes BlockNote's own drop-cursor decoration | ||
| from that stale `tablePos` and throws (`RangeError`, confirmed), not just | ||
| "drops silently overwrite a concurrent change" as previously stated here. | ||
| `tableDragSourceExtension.ts`'s own plugin state now remaps `tablePos` | ||
| through `tr.mapping` so it doesn't share this specific failure mode, but | ||
| there's no way to verify that in an end-to-end test while BlockNote's own | ||
| decoration throws first in the same view update. This is pre-existing | ||
| BlockNote core behavior this example doesn't introduce - see the PR | ||
| discussion for the upstream report. | ||
|
|
||
| ## Tests | ||
|
|
||
| `tests/src/end-to-end/tables/tableReorderingVisualization.test.tsx` covers | ||
| the parts this example actually adds: source-highlight + drop-cursor | ||
| appearance during a drag, per-cell tinting for column drags, cleanup on a | ||
| cancelled drag, dragging a row with rich (bold) inline content, dragging a | ||
| column across a merged cell, and the `/table` header-row default. It | ||
| doesn't re-test BlockNote's own move/reorder logic, which is already | ||
| covered by `tables.test.tsx`, and it doesn't cover the concurrent-edit | ||
| scenario above - see that section for why. | ||
|
|
||
| ## How It Works | ||
|
|
||
| - `tableDragSourceExtension.ts` adds a small ProseMirror plugin that reads | ||
| the same transaction metadata BlockNote's own `TableHandlesExtension` | ||
| uses for its drop-cursor, and applies a node decoration to the row/column | ||
| being dragged _from_. Using a decoration (not a direct DOM class mutation) | ||
| matters: ProseMirror's table view can redraw independently of React, and | ||
| a plain DOM mutation gets silently discarded on the next redraw. | ||
| - `useTableDragImage.ts` swaps BlockNote's hidden 1x1 native drag image for | ||
| a cloned snapshot of the row/column, styled like a lifted card, via the | ||
| standard `DataTransfer.setDragImage` API. | ||
| - `tableStyles.css` restyles the table itself and the drop-cursor color. | ||
| - `App.tsx` overrides the default `/table` slash-menu item so new tables | ||
| start with `headerRows: 1`. | ||
|
|
||
| **Relevant Docs:** | ||
|
|
||
| - [Tables](/docs/features/blocks/tables) | ||
| - [Editor Setup](/docs/getting-started/editor-setup) | ||
| - [Slash Menu](/docs/react/components/suggestion-menus) |
13 changes: 13 additions & 0 deletions
13
examples/03-ui-components/21-table-reordering-visualization/index.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Table Reordering Visualization</title> | ||
| <!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY --> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="./main.tsx"></script> | ||
| </body> | ||
| </html> | ||
11 changes: 11 additions & 0 deletions
11
examples/03-ui-components/21-table-reordering-visualization/main.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <React.StrictMode> | ||
| <App /> | ||
| </React.StrictMode>, | ||
| ); |
32 changes: 32 additions & 0 deletions
32
examples/03-ui-components/21-table-reordering-visualization/package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "name": "@blocknote/example-ui-components-table-reordering-visualization", | ||
| "description": "Enhanced visual feedback for table row/column drag-and-drop reordering.", | ||
| "type": "module", | ||
| "private": true, | ||
| "version": "0.12.4", | ||
| "scripts": { | ||
| "start": "vp dev", | ||
| "dev": "vp dev", | ||
| "build:prod": "tsc && vp build", | ||
| "preview": "vp preview" | ||
| }, | ||
| "dependencies": { | ||
| "@blocknote/ariakit": "latest", | ||
| "@blocknote/core": "latest", | ||
| "@blocknote/mantine": "latest", | ||
| "@blocknote/react": "latest", | ||
| "@blocknote/shadcn": "latest", | ||
| "@mantine/core": "^9.0.2", | ||
| "@mantine/hooks": "^9.0.2", | ||
| "prosemirror-state": "^1.4.4", | ||
| "prosemirror-view": "^1.41.4", | ||
| "react": "^19.2.3", | ||
| "react-dom": "^19.2.3" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/react": "^19.2.3", | ||
| "@types/react-dom": "^19.2.3", | ||
| "@vitejs/plugin-react": "^6.0.1", | ||
| "vite-plus": "catalog:" | ||
| } | ||
| } |
91 changes: 91 additions & 0 deletions
91
examples/03-ui-components/21-table-reordering-visualization/src/App.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { BlockNoteEditor } from "@blocknote/core"; | ||
| import { | ||
| filterSuggestionItems, | ||
| insertOrUpdateBlockForSlashMenu, | ||
| } from "@blocknote/core/extensions"; | ||
| import "@blocknote/core/fonts/inter.css"; | ||
| import { BlockNoteView } from "@blocknote/mantine"; | ||
| import "@blocknote/mantine/style.css"; | ||
| import { | ||
| DefaultReactSuggestionItem, | ||
| getDefaultReactSlashMenuItems, | ||
| SuggestionMenuController, | ||
| useCreateBlockNote, | ||
| } from "@blocknote/react"; | ||
|
|
||
| import { TableDragSourceExtension } from "./tableDragSourceExtension"; | ||
| import "./tableStyles.css"; | ||
| import { useTableDragImage } from "./useTableDragImage"; | ||
|
|
||
| // BlockNote's stock "/table" item inserts a table with no header row, so it | ||
| // never picks up the header styling until someone manually toggles it on. | ||
| // This swaps in a version that starts with `headerRows: 1` instead. | ||
| const getCustomSlashMenuItems = ( | ||
| editor: BlockNoteEditor<any, any, any>, | ||
| ): DefaultReactSuggestionItem[] => | ||
| getDefaultReactSlashMenuItems(editor).map((item) => { | ||
| // `key` is typed away on the React item (it's reserved for JSX), but the | ||
| // underlying object - built from the same items core uses - still has it. | ||
| const key = (item as unknown as { key?: string }).key; | ||
| if (key !== "table") { | ||
| return item; | ||
| } | ||
| return { | ||
| ...item, | ||
| onItemClick: () => | ||
| insertOrUpdateBlockForSlashMenu(editor, { | ||
| type: "table", | ||
| content: { | ||
| type: "tableContent", | ||
| headerRows: 1, | ||
| rows: [{ cells: ["", "", ""] }, { cells: ["", "", ""] }], | ||
| } as any, | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| export default function App() { | ||
| const editor = useCreateBlockNote({ | ||
| tables: { | ||
| splitCells: true, | ||
| cellBackgroundColor: true, | ||
| cellTextColor: true, | ||
| headers: true, | ||
| }, | ||
| extensions: [TableDragSourceExtension()], | ||
| initialContent: [ | ||
| { | ||
| type: "heading", | ||
| props: { level: 2 }, | ||
| content: "Enriched Reordering Visualization for BlockNote.js Tables", | ||
| }, | ||
| { | ||
| type: "table", | ||
| content: { | ||
| type: "tableContent", | ||
| columnWidths: [180, 140, 140, 220], | ||
| headerRows: 1, | ||
| rows: [ | ||
| { cells: ["Column A", "Column B", "Column C", "Column D"] }, | ||
| { cells: ["1a", "1b", "1c", "1d"] }, | ||
| { cells: ["2a", "2b", "2c", "2d"] }, | ||
| { cells: ["3a", "3b", "3c", "3d"] }, | ||
| ], | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| useTableDragImage(editor); | ||
|
|
||
| return ( | ||
| <BlockNoteView editor={editor} slashMenu={false}> | ||
| <SuggestionMenuController | ||
| triggerCharacter="/" | ||
| getItems={async (query) => | ||
| filterSuggestionItems(getCustomSlashMenuItems(editor), query) | ||
| } | ||
| /> | ||
| </BlockNoteView> | ||
| ); | ||
| } |
123 changes: 123 additions & 0 deletions
123
examples/03-ui-components/21-table-reordering-visualization/src/tableDragSourceExtension.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { createExtension } from "@blocknote/core"; | ||
| import { tableHandlesPluginKey } from "@blocknote/core/extensions"; | ||
| import { Plugin, PluginKey } from "prosemirror-state"; | ||
| import { Decoration, DecorationSet } from "prosemirror-view"; | ||
|
|
||
| const SOURCE_ROW_CLASS = "bn-table-drag-source-row"; | ||
| const SOURCE_COL_CLASS = "bn-table-drag-source-col"; | ||
|
|
||
| type DragSourceMeta = { | ||
| draggedCellOrientation: "row" | "col"; | ||
| originalIndex: number; | ||
| tablePos: number; | ||
| }; | ||
|
|
||
| const pluginKey = new PluginKey<DragSourceMeta | null>( | ||
| "tableDragSourceHighlight", | ||
| ); | ||
|
|
||
| /** | ||
| * BlockNote's TableHandlesExtension decorates the drop *target* while | ||
| * dragging a row/column (the `bn-table-drop-cursor` widget), but has no | ||
| * equivalent for the row/column being dragged *from*, which makes it hard to | ||
| * tell what's actually moving. This mirrors that mechanism for the source | ||
| * side: it reads the same `tableHandlesPluginKey` transaction meta | ||
| * (`{draggedCellOrientation, originalIndex, tablePos}` on drag start, `null` | ||
| * on drag end, a bare `true` "redraw the decorations" ping while hovering) | ||
| * and applies a node decoration - not a direct DOM class mutation, which | ||
| * ProseMirror's table NodeView can silently discard on redraw - so the | ||
| * highlight survives every dragover-triggered decoration recompute. | ||
| */ | ||
| export const TableDragSourceExtension = createExtension(() => ({ | ||
| key: "tableDragSourceHighlight", | ||
| prosemirrorPlugins: [ | ||
| new Plugin<DragSourceMeta | null>({ | ||
| key: pluginKey, | ||
| state: { | ||
| init: () => null, | ||
| apply(tr, prev) { | ||
| const meta = tr.getMeta(tableHandlesPluginKey); | ||
| if (meta === null) { | ||
| return null; | ||
| } | ||
| if ( | ||
| meta && | ||
| typeof meta === "object" && | ||
| typeof (meta as DragSourceMeta).tablePos === "number" && | ||
| typeof (meta as DragSourceMeta).originalIndex === "number" | ||
| ) { | ||
| return meta as DragSourceMeta; | ||
| } | ||
| if (!prev || !tr.docChanged) { | ||
| return prev; | ||
| } | ||
| // A transaction changed the document without setting our meta - | ||
| // e.g. a concurrent local or collaborative edit elsewhere in the | ||
| // doc while a drag is in progress. Remap the stored position | ||
| // through it instead of letting it go stale, so the highlight | ||
| // keeps tracking the table rather than just disappearing on the | ||
| // next `decorations()` call. | ||
| return { ...prev, tablePos: tr.mapping.map(prev.tablePos) }; | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| props: { | ||
| decorations(state) { | ||
| const dragState = pluginKey.getState(state); | ||
| if (!dragState) { | ||
| return null; | ||
| } | ||
|
|
||
| const { draggedCellOrientation, originalIndex, tablePos } = dragState; | ||
|
|
||
| // `tablePos` is captured once at drag-start and isn't remapped | ||
| // against later transactions (matching BlockNote's own drop-cursor | ||
| // decoration, which has the same limitation). If a concurrent edit | ||
| // - locally or from another collaborator - shifts or removes the | ||
| // table while a drag is in progress, this resolve() would throw | ||
| // instead of just skipping the decoration; bail out safely instead. | ||
| let tableResolvedPos; | ||
| try { | ||
| tableResolvedPos = state.doc.resolve(tablePos + 1); | ||
| } catch { | ||
| return null; | ||
| } | ||
| const tableNode = tableResolvedPos.node(); | ||
| if (tableNode.type.name !== "table") { | ||
| return null; | ||
| } | ||
| const decorations: Decoration[] = []; | ||
|
|
||
| if (draggedCellOrientation === "row") { | ||
| const rowNode = tableNode.maybeChild(originalIndex); | ||
| if (rowNode) { | ||
| const rowStart = tableResolvedPos.posAtIndex(originalIndex); | ||
| decorations.push( | ||
| Decoration.node(rowStart, rowStart + rowNode.nodeSize, { | ||
| class: SOURCE_ROW_CLASS, | ||
| }), | ||
| ); | ||
| } | ||
| } else { | ||
| for (let row = 0; row < tableNode.childCount; row++) { | ||
| const rowNode = tableNode.child(row); | ||
| const cellNode = rowNode.maybeChild(originalIndex); | ||
| if (!cellNode) { | ||
| continue; | ||
| } | ||
| const rowStart = tableResolvedPos.posAtIndex(row); | ||
| const rowResolvedPos = state.doc.resolve(rowStart + 1); | ||
| const cellStart = rowResolvedPos.posAtIndex(originalIndex); | ||
| decorations.push( | ||
| Decoration.node(cellStart, cellStart + cellNode.nodeSize, { | ||
| class: SOURCE_COL_CLASS, | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return DecorationSet.create(state.doc, decorations); | ||
| }, | ||
| }, | ||
| }), | ||
| ], | ||
| })); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.