Skip to content

Commit 139e7bd

Browse files
YousefEDclaude
andcommitted
fix: multi-column UX - trailing blocks in columns, hover borders, drop cursor left edge
Fixes #2820. - Trailing block widgets are now also rendered in columns, filling the empty space below a column's last block. Clicking one appends a block inside that column, instead of blocks silently landing below the multi-column area. - Hovering a column list shows light separators between its columns, and the resize border now actually appears when nearing a column boundary (it was suppressed by a side menu check that was almost always true). The side menu is no longer stretched next to headings (which blocked the resize handle) - it keeps its natural size and is positioned with a floating-ui offset instead. - The drop cursor only treats a drop as a "left edge" drop when the cursor is actually left of the block, instead of within a margin inside the block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3094559 commit 139e7bd

14 files changed

Lines changed: 588 additions & 152 deletions

File tree

packages/core/src/editor/Block.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,46 @@ NESTED BLOCKS
723723
padding: 12px 20px;
724724
/* scroll if we overflow, for example when tables or images are in the column */
725725
overflow-x: auto;
726+
/* Lets the trailing block widget grow to fill the column's leftover height
727+
when a sibling column is taller. */
728+
display: flex;
729+
flex-direction: column;
730+
}
731+
732+
/* Shows separators between a column list's columns while the mouse is over
733+
it. This clarifies where each column (and its clickable empty space) ends.
734+
The class is managed by the multi-column package's column resize plugin. The
735+
transition delay stops the separators from flashing in when the mouse just
736+
passes through the column list. */
737+
.bn-editor[contenteditable="true"]
738+
.bn-column-list-hovered
739+
> .bn-block-column:not(:last-child) {
740+
box-shadow: 4px 0 0 #eee;
741+
transition: box-shadow 0.2s ease 0.2s;
742+
}
743+
744+
/* The border between two columns when the cursor is near their boundary or
745+
they're being resized, rendered on the boundary's left column. The class is
746+
managed by the multi-column package's column resize plugin. Unlike the
747+
lighter separators, it appears instantly, so the delayed transition is
748+
overridden. */
749+
.bn-editor[contenteditable="true"]
750+
.bn-column-list-hovered
751+
> .bn-block-column.bn-column-resize-border {
752+
box-shadow: 4px 0 0 #ccc;
753+
cursor: col-resize;
754+
transition: none;
755+
}
756+
757+
.bn-editor[contenteditable="true"]
758+
.bn-block-column.bn-column-resize-border
759+
+ .bn-block-column {
760+
cursor: col-resize;
761+
}
762+
763+
.bn-block-column > .bn-trailing-block {
764+
height: auto;
765+
flex-grow: 1;
726766
}
727767

728768
.bn-block-column:first-child {

packages/core/src/extensions/SideMenu/SideMenu.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,14 @@ export const SideMenuExtension = createExtension(({ editor }) => {
756756
editor.blur();
757757
},
758758

759+
/**
760+
* Whether the side menu is currently frozen (e.g. because the drag handle
761+
* menu is open).
762+
*/
763+
get menuFrozen() {
764+
return view!.menuFrozen;
765+
},
766+
759767
/**
760768
* Freezes the side menu. When frozen, the side menu will stay
761769
* attached to the same block regardless of which block is hovered by the

packages/core/src/extensions/TrailingNode/TrailingNode.ts

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,10 @@ import {
1313

1414
const PLUGIN_KEY = new PluginKey<DecorationSet>("trailingNode");
1515

16-
// Skip the widget when the editor isn't editable, or when the document already
17-
// ends with an empty paragraph block (since the user can just type into it).
18-
function shouldShowTrailingWidget(doc: PMNode, isEditable: boolean): boolean {
19-
if (!isEditable) {
20-
return false;
21-
}
22-
23-
const rootGroup = doc.lastChild;
24-
const lastBlock = rootGroup?.lastChild;
16+
// Skip the widget when the container already ends with an empty paragraph
17+
// block (since the user can just type into it).
18+
function containerNeedsTrailingWidget(container: PMNode): boolean {
19+
const lastBlock = container.lastChild;
2520
const lastContent = lastBlock?.firstChild;
2621

2722
return !(
@@ -31,12 +26,48 @@ function shouldShowTrailingWidget(doc: PMNode, isEditable: boolean): boolean {
3126
);
3227
}
3328

29+
// Returns the position at the end of each container that should render a
30+
// trailing widget: the root blockGroup, and columns from the multi-column
31+
// package. Nested blockGroups (a block's children) are excluded, as they have
32+
// no empty space below them for a widget to occupy.
33+
function getTrailingWidgetPositions(doc: PMNode): number[] {
34+
// When the schema has no columns, the root blockGroup is the only possible
35+
// container, so traversing the doc to find others can be skipped.
36+
if (!doc.type.schema.nodes["column"]) {
37+
const rootGroup = doc.lastChild;
38+
return rootGroup && containerNeedsTrailingWidget(rootGroup)
39+
? [doc.content.size - 1]
40+
: [];
41+
}
42+
43+
const positions: number[] = [];
44+
45+
doc.descendants((node, pos, parent) => {
46+
if (node.isTextblock) {
47+
return false;
48+
}
49+
50+
const isContainer =
51+
node.type.name === "column" ||
52+
(node.type.name === "blockGroup" && parent?.type.name === "doc");
53+
54+
if (isContainer && containerNeedsTrailingWidget(node)) {
55+
positions.push(pos + node.nodeSize - 1);
56+
}
57+
58+
return true;
59+
});
60+
61+
return positions;
62+
}
63+
3464
/**
3565
* Renders a fake trailing block as a widget decoration after the last block of
36-
* the document. Clicking it inserts a real trailing block and moves the
37-
* selection into it. This way the trailing block is not part of the document
38-
* content, so it doesn't appear when the editor is read-only or when the
39-
* content is exported.
66+
* the document, as well as of any other container that blocks can be appended
67+
* to (e.g. columns). Clicking it inserts a real trailing block in the
68+
* container and moves the selection into it. This way the trailing block is
69+
* not part of the document content, so it doesn't appear when the editor is
70+
* read-only or when the content is exported.
4071
*/
4172
export const TrailingNodeExtension = createExtension(
4273
({ editor }: ExtensionOptions) => {
@@ -52,47 +83,78 @@ export const TrailingNodeExtension = createExtension(
5283
// based on this click.
5384
event.preventDefault();
5485

86+
const view = editor.prosemirrorView;
87+
if (!view) {
88+
return;
89+
}
90+
91+
// The widget may have been remapped since it was created, so its
92+
// container is resolved from its current DOM position instead of
93+
// captured up front.
94+
const container = view.state.doc.resolve(
95+
view.posAtDOM(el, 0),
96+
).parent;
97+
const lastBlockId = container.lastChild?.attrs["id"];
98+
if (!lastBlockId) {
99+
return;
100+
}
101+
55102
editor.transact((tr) => {
56103
const [insertedBlock] = editor.insertBlocks(
57104
[{ type: "paragraph" }],
58-
editor.document[editor.document.length - 1],
105+
lastBlockId,
59106
"after",
60107
);
61108
editor.setTextCursorPosition(insertedBlock, "start");
62109
tr.scrollIntoView();
63110
});
64111

65-
editor.prosemirrorView?.focus();
112+
view.focus();
66113
});
67114
return el;
68115
},
69116
{ side: 1 },
70117
);
71118
}
72119

73-
// Maps the existing DecorationSet through the transaction, then
74-
// incrementally adds or removes the widget only if the show/hide state
75-
// crossed over. The underlying Decoration (and its rendered DOM) stays
76-
// reference-stable across transactions.
120+
// Maps the existing DecorationSet through the transaction, then diffs it
121+
// against the containers that should currently show a widget, only adding
122+
// and removing where the two differ. Decorations (and their rendered DOM)
123+
// stay reference-stable across transactions for unchanged containers.
77124
function nextDecorationSet(
78125
tr: Transaction,
79126
oldSet: DecorationSet,
80127
isEditable: boolean,
81128
): DecorationSet {
82129
const mapped = oldSet.map(tr.mapping, tr.doc);
83-
const existing = mapped.find();
84-
const wasShowing = existing.length > 0;
85-
const shouldShow = shouldShowTrailingWidget(tr.doc, isEditable);
130+
const desiredPositions = new Set(
131+
isEditable ? getTrailingWidgetPositions(tr.doc) : [],
132+
);
133+
134+
const keptPositions = new Set<number>();
135+
const stale: Decoration[] = [];
136+
for (const decoration of mapped.find()) {
137+
if (
138+
desiredPositions.has(decoration.from) &&
139+
!keptPositions.has(decoration.from)
140+
) {
141+
keptPositions.add(decoration.from);
142+
} else {
143+
stale.push(decoration);
144+
}
145+
}
146+
const missing = [...desiredPositions].filter(
147+
(pos) => !keptPositions.has(pos),
148+
);
86149

87-
if (wasShowing === shouldShow) {
88-
return mapped;
150+
let next = mapped;
151+
if (stale.length > 0) {
152+
next = next.remove(stale);
89153
}
90-
if (wasShowing) {
91-
return mapped.remove(existing);
154+
if (missing.length > 0) {
155+
next = next.add(tr.doc, missing.map(createTrailingWidget));
92156
}
93-
return mapped.add(tr.doc, [
94-
createTrailingWidget(tr.doc.content.size - 1),
95-
]);
157+
return next;
96158
}
97159

98160
return {
@@ -133,8 +195,9 @@ export const TrailingNodeExtension = createExtension(
133195
props: {
134196
decorations: (state) => PLUGIN_KEY.getState(state),
135197
// Prevents ProseMirror from trying to move the selection into the
136-
// trailing block, which causes the text caret to flicker in it
137-
// before returning to its previous position.
198+
// trailing block at the end of the document, which causes the text
199+
// caret to flicker in it before returning to its previous
200+
// position.
138201
handleKeyDown: (view, event) => {
139202
if (event.key !== "ArrowRight" && event.key !== "ArrowDown") {
140203
return false;
@@ -150,8 +213,11 @@ export const TrailingNodeExtension = createExtension(
150213
return false;
151214
}
152215

216+
const rootGroup = view.state.doc.lastChild;
153217
if (
154-
!shouldShowTrailingWidget(view.state.doc, editor.isEditable)
218+
!editor.isEditable ||
219+
!rootGroup ||
220+
!containerNeedsTrailingWidget(rootGroup)
155221
) {
156222
return false;
157223
}

packages/react/src/components/SideMenu/SideMenu.tsx

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import { SideMenuExtension } from "@blocknote/core/extensions";
2-
import { ReactNode, useMemo } from "react";
1+
import { ReactNode } from "react";
32

43
import { useComponentsContext } from "../../editor/ComponentsContext.js";
5-
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
6-
import { useExtensionState } from "../../hooks/useExtension.js";
74
import { AddBlockButton } from "./DefaultButtons/AddBlockButton.js";
85
import { DragHandleButton } from "./DefaultButtons/DragHandleButton.js";
96
import { SideMenuProps } from "./SideMenuProps.js";
@@ -20,41 +17,8 @@ import { SideMenuProps } from "./SideMenuProps.js";
2017
export const SideMenu = (props: SideMenuProps & { children?: ReactNode }) => {
2118
const Components = useComponentsContext()!;
2219

23-
const editor = useBlockNoteEditor<any, any, any>();
24-
25-
const block = useExtensionState(SideMenuExtension, {
26-
editor,
27-
selector: (state) => state?.block,
28-
});
29-
30-
const dataAttributes = useMemo(() => {
31-
if (block === undefined) {
32-
return {};
33-
}
34-
35-
const attrs: Record<string, string> = {
36-
"data-block-type": block.type,
37-
};
38-
39-
if (block.type === "heading") {
40-
attrs["data-level"] = (block.props as any).level.toString();
41-
}
42-
43-
if (
44-
editor.schema.blockSpecs[block.type].implementation.meta?.fileBlockAccept
45-
) {
46-
if (block.props.url) {
47-
attrs["data-url"] = "true";
48-
} else {
49-
attrs["data-url"] = "false";
50-
}
51-
}
52-
53-
return attrs;
54-
}, [block, editor.schema.blockSpecs]);
55-
5620
return (
57-
<Components.SideMenu.Root className={"bn-side-menu"} {...dataAttributes}>
21+
<Components.SideMenu.Root className={"bn-side-menu"}>
5822
{props.children || (
5923
<>
6024
<AddBlockButton />

packages/react/src/components/SideMenu/SideMenuController.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { Block, BlockNoteEditor } from "@blocknote/core";
12
import { SideMenuExtension } from "@blocknote/core/extensions";
2-
import { autoUpdate, ReferenceElement } from "@floating-ui/react";
3+
import { autoUpdate, offset, ReferenceElement } from "@floating-ui/react";
34
import { FC, useCallback, useMemo } from "react";
45

56
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -9,6 +10,50 @@ import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
910
import { SideMenu } from "./SideMenu.js";
1011
import { SideMenuProps } from "./SideMenuProps.js";
1112

13+
// Returns the vertical offset of the side menu for the given block. Blocks
14+
// whose first line is taller than the side menu need the menu shifted down to
15+
// stay vertically centered on that line. This is done with a position offset
16+
// instead of stretching the menu element to the first line's height, as the
17+
// taller (invisible) element would block mouse interactions with content
18+
// rendered next to the block, e.g. the column resize borders.
19+
function getBlockOffset(
20+
editor: BlockNoteEditor<any, any, any>,
21+
block: Block<any, any, any>,
22+
): number {
23+
if (block.type === "heading") {
24+
switch (block.props.level) {
25+
case 1:
26+
return 39;
27+
case 2:
28+
return 27;
29+
case 3:
30+
return 18.5;
31+
default:
32+
return 0;
33+
}
34+
}
35+
36+
// File blocks without a URL all render the same "Add file" button,
37+
// regardless of their type.
38+
if (
39+
editor.schema.blockSpecs[block.type]?.implementation.meta
40+
?.fileBlockAccept &&
41+
!block.props.url
42+
) {
43+
return 12;
44+
}
45+
46+
if (block.type === "file") {
47+
return 4;
48+
}
49+
50+
if (block.type === "audio" || block.type === "table") {
51+
return 15;
52+
}
53+
54+
return 0;
55+
}
56+
1257
export const SideMenuController = (props: {
1358
sideMenu?: FC<SideMenuProps>;
1459
floatingUIOptions?: Partial<FloatingUIOptions>;
@@ -71,6 +116,13 @@ export const SideMenuController = (props: {
71116
useFloatingOptions: {
72117
open: show,
73118
placement: "left-start",
119+
// Vertically centers the menu on the block's first line. On the
120+
// "left-start" placement, the cross axis is the vertical one.
121+
middleware: [
122+
offset({
123+
crossAxis: block ? getBlockOffset(editor, block) : 0,
124+
}),
125+
],
74126
whileElementsMounted,
75127
...props.floatingUIOptions?.useFloatingOptions,
76128
},
@@ -89,7 +141,7 @@ export const SideMenuController = (props: {
89141
...props.floatingUIOptions?.elementProps,
90142
},
91143
}),
92-
[props.floatingUIOptions, show, whileElementsMounted],
144+
[props.floatingUIOptions, show, block, editor, whileElementsMounted],
93145
);
94146

95147
const Component = props.sideMenu || SideMenu;

0 commit comments

Comments
 (0)