Skip to content

Commit 7093c51

Browse files
committed
fix(core): drop a dragged block into the editor on top where editors overlap
The drop handling selected the editor closest to a drag event. Where two editors overlap, both are at distance 0, and the editor that comes first in the document won, usually the one at the bottom. The side menu of the top editor now shows there, so a block dragged in the top editor was dropped into the bottom editor. If the target of the drag event is in an editor, that editor is now the only candidate. The distance only decides when the pointer is outside every editor.
1 parent 6081901 commit 7093c51

2 files changed

Lines changed: 133 additions & 7 deletions

File tree

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ function isSynthetic(event: Event): boolean {
1010
}
1111

1212
/**
13-
* Finds the closest editor visually to the given coordinates.
13+
* Finds the editor that a drag event is about, and the distance from the
14+
* event to the blocks of that editor. If the event target is in an editor,
15+
* that editor is the only candidate: where editors overlap, it is the editor
16+
* on top. Otherwise, it is the editor closest to the event.
1417
*/
15-
function findClosestEditorElement(
16-
view: EditorView,
17-
coords: { clientX: number; clientY: number },
18-
) {
19-
const editors = Array.from(view.root.querySelectorAll(".bn-editor"));
18+
function findDropEditor(view: EditorView, event: DragEvent) {
19+
const target =
20+
event.target instanceof Element ? event.target.closest(".bn-editor") : null;
21+
const editors = target
22+
? [target]
23+
: Array.from(view.root.querySelectorAll(".bn-editor"));
24+
const coords = event;
2025

2126
if (editors.length === 0) {
2227
return null;
@@ -135,7 +140,7 @@ export function registerBlockDropHandlers(
135140
const isOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged;
136141

137142
// Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close)
138-
const closestEditor = findClosestEditorElement(view, event);
143+
const closestEditor = findDropEditor(view, event);
139144

140145
// We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point
141146
if (!closestEditor || closestEditor.distance > dropMargin) {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { BlockNoteEditor, type PartialBlock } from "@blocknote/core";
2+
import { SideMenuExtension } from "@blocknote/core/extensions";
3+
import "@blocknote/core/fonts/inter.css";
4+
import { BlockNoteView } from "@blocknote/mantine";
5+
import "@blocknote/mantine/style.css";
6+
import { useCreateBlockNote } from "@blocknote/react";
7+
import { afterEach, describe, expect, test } from "vite-plus/test";
8+
import { cleanup, render } from "vitest-browser-react";
9+
10+
import { DRAG_HANDLE_SELECTOR } from "../../utils/const.js";
11+
import { sleep, waitForSelector } from "../../utils/editor.js";
12+
import { getRect, mouseSequence } from "../../utils/mouse.js";
13+
14+
// Two editors stacked on the same grid cell (#2559), e.g. an editor in a
15+
// dialog over another one. The top editor is editable; the bottom one is
16+
// read-only or editable. The side menu and drops must go to the top editor.
17+
18+
const editors: Record<string, BlockNoteEditor<any, any, any>> = {};
19+
20+
function Editor(props: { name: string; ids: string[]; editable: boolean }) {
21+
const editor = useCreateBlockNote({
22+
initialContent: props.ids.map(
23+
(id) =>
24+
({
25+
id,
26+
type: "paragraph",
27+
content: `${props.name} ${id}`,
28+
}) as PartialBlock,
29+
),
30+
});
31+
editors[props.name] = editor;
32+
return <BlockNoteView editor={editor} editable={props.editable} />;
33+
}
34+
35+
function Overlapping(props: { bottomEditable: boolean }) {
36+
return (
37+
<div style={{ display: "grid", width: 600 }}>
38+
<div style={{ gridArea: "1 / 1" }} data-editor-name="bottom">
39+
<Editor
40+
name="bottom"
41+
ids={["u1", "u2", "u3"]}
42+
editable={props.bottomEditable}
43+
/>
44+
</div>
45+
<div
46+
style={{ gridArea: "1 / 1", zIndex: 1, background: "white" }}
47+
data-editor-name="top"
48+
>
49+
<Editor name="top" ids={["t1", "t2", "t3"]} editable={true} />
50+
</div>
51+
</div>
52+
);
53+
}
54+
55+
afterEach(async () => {
56+
await mouseSequence([{ type: "move", x: 1, y: 1 }]);
57+
cleanup();
58+
});
59+
60+
function menuBlock(name: string): string | undefined {
61+
const state = editors[name].getExtension(SideMenuExtension)?.store.state;
62+
return state?.show ? state.block.id : undefined;
63+
}
64+
65+
function blockRect(name: string, id: string) {
66+
return document
67+
.querySelector(
68+
`[data-editor-name="${name}"] .bn-block[data-id="${id}"] .bn-block-content`,
69+
)!
70+
.getBoundingClientRect();
71+
}
72+
73+
for (const bottomEditable of [false, true]) {
74+
describe(`overlapping editors, bottom editor ${bottomEditable ? "editable" : "read-only"}`, () => {
75+
test("hover: the top editor shows its menu", async () => {
76+
await render(<Overlapping bottomEditable={bottomEditable} />);
77+
await waitForSelector(".bn-editor");
78+
const r = blockRect("top", "t2");
79+
await mouseSequence([
80+
{ type: "move", x: r.x + 30, y: r.y + r.height / 2, steps: 8 },
81+
]);
82+
await sleep(150);
83+
expect(menuBlock("top")).toBe("t2");
84+
expect(menuBlock("bottom")).toBeUndefined();
85+
});
86+
87+
test("drag and drop: a block of the top editor moves within it", async () => {
88+
await render(<Overlapping bottomEditable={bottomEditable} />);
89+
await waitForSelector(".bn-editor");
90+
const r3 = blockRect("top", "t3");
91+
await mouseSequence([
92+
{ type: "move", x: r3.x + 30, y: r3.y + r3.height / 2, steps: 8 },
93+
]);
94+
await sleep(150);
95+
const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR));
96+
const t1 = blockRect("top", "t1");
97+
await mouseSequence([
98+
{
99+
type: "move",
100+
x: handle.x + handle.width / 2,
101+
y: handle.y + handle.height / 2,
102+
steps: 5,
103+
},
104+
]);
105+
await mouseSequence([{ type: "down" }]);
106+
await sleep(100);
107+
await mouseSequence([
108+
{ type: "move", x: t1.x + 30, y: t1.y + 2, steps: 5 },
109+
]);
110+
await sleep(100);
111+
await mouseSequence([{ type: "up" }]);
112+
await sleep(150);
113+
expect(editors.top.document.map((b) => b.id)).toEqual(["t3", "t1", "t2"]);
114+
expect(editors.bottom.document.map((b) => b.id)).toEqual([
115+
"u1",
116+
"u2",
117+
"u3",
118+
]);
119+
});
120+
});
121+
}

0 commit comments

Comments
 (0)