Skip to content

Commit cc4f00a

Browse files
committed
fix(core): preserve visible block selections and drag-end compatibility
1 parent fa15295 commit cc4f00a

16 files changed

Lines changed: 435 additions & 108 deletions

File tree

‎docs/content/docs/getting-started/vanilla-js.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,6 @@ const unsubscribe = extension.store.subscribe(() => {
7575
});
7676
```
7777

78-
Let's look at how you could add the [Side Menu](/docs/react/components/side-menu) to your editor. The example below mounts a plain editor and builds a custom Side Menu from scratch, using the extension's store to position it and its `blockDragStart` method to handle dragging:
78+
Let's look at how you could add the [Side Menu](/docs/react/components/side-menu) to your editor. The example below mounts a plain editor and builds a custom Side Menu from scratch, using the extension's store to position it and its `blockDragStart`/`blockDragEnd` methods to handle dragging:
7979

8080
<Example name="vanilla-js/vanilla-custom-side-menu" />

‎examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ function MUIDragHandleButton(props: SideMenuProps) {
114114
draggable={"true"}
115115
onClick={onClick}
116116
onDragStart={(e) => sideMenu.blockDragStart(e, block)}
117+
onDragEnd={sideMenu.blockDragEnd}
117118
>
118119
<DragIndicator
119120
sx={{

‎examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function initEditor(rootElement: HTMLElement) {
9797
sideMenu.blockDragStart(evt, block);
9898
}
9999
});
100+
dragBtn.addEventListener("dragend", () => sideMenu.blockDragEnd());
100101
dragBtn.draggable = true;
101102
element.style.display = "none";
102103
element.appendChild(dragBtn);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, describe, expect, it } from "vite-plus/test";
2+
3+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
4+
import "../../editor/editor.css";
5+
6+
const cleanup: (() => void)[] = [];
7+
8+
afterEach(() => {
9+
cleanup
10+
.splice(0)
11+
.reverse()
12+
.forEach((dispose) => dispose());
13+
});
14+
15+
describe("focus after a drop from another root", () => {
16+
it("finishes a drop in a shadow root without a local dragend", async () => {
17+
const host = document.createElement("div");
18+
document.body.appendChild(host);
19+
cleanup.push(() => host.remove());
20+
const root = host.attachShadow({ mode: "open" });
21+
const container = document.createElement("div");
22+
root.appendChild(container);
23+
const editor = BlockNoteEditor.create({
24+
initialContent: [{ type: "paragraph", content: "Target" }],
25+
});
26+
editor.mount(container);
27+
cleanup.push(() => editor._tiptapEditor.destroy());
28+
29+
const view = editor.prosemirrorView;
30+
const rect = view.dom.getBoundingClientRect();
31+
const dataTransfer = new DataTransfer();
32+
dataTransfer.setData("text/html", "<p>Incoming</p>");
33+
dataTransfer.setData("blocknote/html", "<p>Incoming</p>");
34+
// A drag from a different document/root delivers drop here, but its
35+
// dragend fires at the source and never reaches this root.
36+
view.dom.dispatchEvent(
37+
new DragEvent("drop", {
38+
bubbles: true,
39+
cancelable: true,
40+
clientX: rect.left + 5,
41+
clientY: rect.top + rect.height / 2,
42+
dataTransfer,
43+
}),
44+
);
45+
46+
expect(view.state.doc.textContent).toContain("Incoming");
47+
await expect.poll(() => view.state.selection.empty).toBe(true);
48+
expect(root.activeElement).toBe(view.dom);
49+
});
50+
});

‎packages/core/src/extensions/DropCursor/DropCursor.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
// Handles drops into one editor. It shows the drop cursor: the line that shows
2-
// where the dragged content will go. It inserts blocks that go into a block,
3-
// e.g. a toggle (ProseMirror inserts all other drops). After a block drop, it
4-
// focuses the editor, with a text cursor at the end of the dropped blocks.
5-
61
import { Plugin, PluginKey, Selection } from "prosemirror-state";
72
import { dropPoint } from "prosemirror-transform";
83
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
@@ -79,6 +74,7 @@ export const DropCursorExtension = createExtension<
7974
let dragSourceElement: Element | null = null;
8075
// Set when blocks are dropped in this editor, until the drag ends.
8176
let blocksDropped = false;
77+
let focusTimeout: ReturnType<typeof setTimeout> | undefined;
8278

8379
const config = {
8480
width: options.dropCursor?.width ?? 5,
@@ -170,6 +166,7 @@ export const DropCursorExtension = createExtension<
170166
// A drop from another document ends the drag there, without a `dragend`
171167
// in this one.
172168
blocksDropped = false;
169+
clearTimeout(focusTimeout);
173170
};
174171

175172
const onDragOver = (event: Event) => {
@@ -284,6 +281,12 @@ export const DropCursorExtension = createExtension<
284281
editor.prosemirrorView.editable &&
285282
((event as DragEvent).dataTransfer?.types.includes("blocknote/html") ??
286283
false);
284+
// A drop from another document or shadow root has no local dragend.
285+
// Finish it after drop handlers run, unless dragend already did so.
286+
clearTimeout(focusTimeout);
287+
if (blocksDropped) {
288+
focusTimeout = setTimeout(onRootDragEnd, 0);
289+
}
287290
};
288291

289292
const onDragEnd = () => {
@@ -297,6 +300,7 @@ export const DropCursorExtension = createExtension<
297300
* menu's handle, which is outside the editor.
298301
*/
299302
function onRootDragEnd() {
303+
clearTimeout(focusTimeout);
300304
if (!blocksDropped) {
301305
return;
302306
}
@@ -382,8 +386,7 @@ export const DropCursorExtension = createExtension<
382386
} else {
383387
editor.updateBlock(target, { children: draggedBlocks });
384388
}
385-
// The selection on the dropped blocks, as ProseMirror's drop leaves
386-
// it. `onRootDragEnd` then focuses the editor.
389+
// Custom drops must also leave the cursor at the dropped blocks.
387390
editor.setTextCursorPosition(draggedBlocks.at(-1), "end");
388391
});
389392
return true;
@@ -414,6 +417,7 @@ export const DropCursorExtension = createExtension<
414417
// Clean up on unmount
415418
signal.addEventListener("abort", () => {
416419
clearTimeout(timeout);
420+
clearTimeout(focusTimeout);
417421
setCursor(null);
418422
});
419423
},

‎packages/core/src/extensions/DropCursor/dropIntoChildren.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
// Finds out if a block drag goes into the children of a block with
2-
// `meta.dropsIntoChildren`, e.g. a toggle. `DropCursor.ts` uses this to show the
3-
// cursor and to drop the blocks.
4-
51
import type { EditorView } from "prosemirror-view";
62

73
import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// @vitest-environment node
2+
import { history, redo, undo } from "@tiptap/pm/history";
3+
import { Schema } from "prosemirror-model";
4+
import { EditorState, Selection, TextSelection } from "prosemirror-state";
5+
import { describe, expect, it } from "vite-plus/test";
6+
7+
import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
8+
9+
const schema = new Schema({
10+
nodes: {
11+
doc: { content: "paragraph+" },
12+
paragraph: { content: "text*" },
13+
text: {},
14+
},
15+
});
16+
17+
function paragraph(text: string) {
18+
return schema.nodes.paragraph.create(null, schema.text(text));
19+
}
20+
21+
const doc = schema.nodes.doc.create(null, [
22+
paragraph("A"),
23+
paragraph("B"),
24+
paragraph("C"),
25+
]);
26+
27+
describe("MultipleNodeSelection bookmarks", () => {
28+
it("round-trips a block selection through editor-state JSON", () => {
29+
const selection = MultipleNodeSelection.create(doc, 0, 3);
30+
const state = EditorState.create({ doc, selection });
31+
const restored = EditorState.fromJSON({ schema }, state.toJSON());
32+
33+
expect(restored.selection.eq(selection)).toBe(true);
34+
});
35+
36+
it.each([1, 2])("restores a selection of %i blocks", (count) => {
37+
const selection = MultipleNodeSelection.create(doc, 0, count * 3);
38+
const restored = selection.getBookmark().resolve(doc);
39+
40+
expect(restored).toBeInstanceOf(MultipleNodeSelection);
41+
expect(restored.eq(selection)).toBe(true);
42+
});
43+
44+
it("maps a selection past inserted blocks", () => {
45+
const selection = MultipleNodeSelection.create(doc, 3, 6);
46+
const tr = EditorState.create({ doc }).tr.insert(0, paragraph("X"));
47+
const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
48+
49+
expect(restored).toBeInstanceOf(MultipleNodeSelection);
50+
expect(restored.eq(MultipleNodeSelection.create(tr.doc, 6, 9))).toBe(true);
51+
});
52+
53+
it("maps edits inside the selected blocks", () => {
54+
const selection = MultipleNodeSelection.create(doc, 0, 6);
55+
const tr = EditorState.create({ doc }).tr.insertText("more", 2);
56+
const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
57+
58+
expect(restored).toBeInstanceOf(MultipleNodeSelection);
59+
expect(restored.eq(MultipleNodeSelection.create(tr.doc, 0, 10))).toBe(true);
60+
});
61+
62+
it.each([3, 6])("excludes blocks inserted at boundary %i", (pos) => {
63+
const selection = MultipleNodeSelection.create(doc, 3, 6);
64+
const tr = EditorState.create({ doc }).tr.insert(pos, paragraph("X"));
65+
const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
66+
const from = pos === 3 ? 6 : 3;
67+
68+
expect(restored).toBeInstanceOf(MultipleNodeSelection);
69+
expect(
70+
restored.eq(MultipleNodeSelection.create(tr.doc, from, from + 3)),
71+
).toBe(true);
72+
});
73+
74+
it.each([3, 6])("keeps the live selection stable at boundary %i", (pos) => {
75+
const selection = MultipleNodeSelection.create(doc, 3, 6);
76+
const tr = EditorState.create({ doc }).tr.insert(pos, paragraph("X"));
77+
const mapped = selection.map(tr.doc, tr.mapping);
78+
const from = pos === 3 ? 6 : 3;
79+
80+
expect(
81+
mapped.eq(MultipleNodeSelection.create(tr.doc, from, from + 3)),
82+
).toBe(true);
83+
});
84+
85+
it("falls back to a cursor when the selected blocks are deleted", () => {
86+
const selection = MultipleNodeSelection.create(doc, 0, 6);
87+
const tr = EditorState.create({ doc }).tr.delete(0, 6);
88+
const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
89+
90+
expect(restored).toBeInstanceOf(TextSelection);
91+
expect(restored.empty).toBe(true);
92+
expect(restored.eq(Selection.near(tr.doc.resolve(0)))).toBe(true);
93+
});
94+
95+
it("falls back when joining blocks removes a selection boundary", () => {
96+
const selection = MultipleNodeSelection.create(doc, 0, 3);
97+
const tr = EditorState.create({ doc }).tr.join(3);
98+
const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
99+
100+
expect(restored).toBeInstanceOf(TextSelection);
101+
expect(restored.empty).toBe(true);
102+
});
103+
104+
it.each([1, 2])("preserves %i selected blocks through undo/redo", (count) => {
105+
const selection = MultipleNodeSelection.create(doc, 0, count * 3);
106+
let state = EditorState.create({ doc, selection, plugins: [history()] });
107+
const content = selection.content().content;
108+
const tr = state.tr.delete(selection.from, selection.to);
109+
tr.insert(tr.doc.content.size, content);
110+
tr.setSelection(Selection.near(tr.doc.resolve(tr.doc.content.size)));
111+
state = state.apply(tr);
112+
const movedDoc = state.doc;
113+
114+
function dispatch(transaction: typeof tr) {
115+
state = state.apply(transaction);
116+
}
117+
118+
expect(undo(state, dispatch)).toBe(true);
119+
expect(state.doc.eq(doc)).toBe(true);
120+
expect(state.selection).toBeInstanceOf(MultipleNodeSelection);
121+
expect(state.selection.eq(selection)).toBe(true);
122+
expect(redo(state, dispatch)).toBe(true);
123+
expect(state.doc.eq(movedDoc)).toBe(true);
124+
expect(undo(state, dispatch)).toBe(true);
125+
expect(state.selection.eq(selection)).toBe(true);
126+
});
127+
});

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

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Fragment, Node, ResolvedPos, Slice } from "prosemirror-model";
2-
import { Selection } from "prosemirror-state";
2+
import { Selection, SelectionBookmark } from "prosemirror-state";
33
import { Mappable } from "prosemirror-transform";
44

55
/**
6-
* This class represents an editor selection which spans multiple nodes/blocks. It's currently only used to allow users
7-
* to drag multiple blocks at the same time. Expects the selection anchor and head to be between nodes, i.e. just before
6+
* This class represents an editor selection which spans one or more whole nodes/blocks. It's used for block drags
7+
* and their restored history selections. Expects the selection anchor and head to be between nodes, i.e. just before
88
* the first target node and just after the last, and that anchor and head are at the same nesting level.
99
*
1010
* Partially based on ProseMirror's NodeSelection implementation:
@@ -37,6 +37,13 @@ export class MultipleNodeSelection extends Selection {
3737
return new MultipleNodeSelection(doc.resolve(from), doc.resolve(to));
3838
}
3939

40+
static fromJSON(doc: Node, json: { anchor: number; head: number }) {
41+
if (typeof json.anchor !== "number" || typeof json.head !== "number") {
42+
throw new RangeError("Invalid input for MultipleNodeSelection.fromJSON");
43+
}
44+
return MultipleNodeSelection.create(doc, json.anchor, json.head);
45+
}
46+
4047
content(): Slice {
4148
return new Slice(Fragment.from(this.nodes), 0, 0);
4249
}
@@ -64,25 +71,57 @@ export class MultipleNodeSelection extends Selection {
6471
}
6572

6673
map(doc: Node, mapping: Mappable): Selection {
67-
const fromResult = mapping.mapResult(this.from);
68-
const toResult = mapping.mapResult(this.to);
74+
return this.getBookmark().map(mapping).resolve(doc);
75+
}
76+
77+
getBookmark(): SelectionBookmark {
78+
return new MultipleNodeBookmark(this.from, this.to);
79+
}
80+
81+
toJSON() {
82+
return { type: "multiple-node", anchor: this.anchor, head: this.head };
83+
}
84+
}
6985

70-
if (toResult.deleted) {
71-
return Selection.near(doc.resolve(fromResult.pos));
86+
class MultipleNodeBookmark implements SelectionBookmark {
87+
constructor(
88+
private readonly from: number,
89+
private readonly to: number,
90+
) {}
91+
92+
map(mapping: Mappable): SelectionBookmark {
93+
const from = mapping.mapResult(this.from, 1);
94+
// A collapsed bookmark is a fallback cursor, not a range to expand.
95+
if (this.from === this.to) {
96+
return new MultipleNodeBookmark(from.pos, from.pos);
7297
}
98+
const to = mapping.mapResult(this.to, -1);
7399

74-
if (fromResult.deleted) {
75-
return Selection.near(doc.resolve(toResult.pos));
100+
if (to.deleted) {
101+
return new MultipleNodeBookmark(from.pos, from.pos);
102+
}
103+
if (from.deleted) {
104+
return new MultipleNodeBookmark(to.pos, to.pos);
76105
}
77106

78-
return new MultipleNodeSelection(
79-
doc.resolve(fromResult.pos),
80-
doc.resolve(toResult.pos),
81-
);
107+
return new MultipleNodeBookmark(from.pos, to.pos);
82108
}
83109

84-
toJSON(): any {
85-
return { type: "multiple-node", anchor: this.anchor, head: this.head };
110+
resolve(doc: Node): Selection {
111+
const $from = doc.resolve(this.from);
112+
const $to = doc.resolve(this.to);
113+
// Structural edits can leave positions inside blocks or in different parents.
114+
// Only restore the block selection when both ends are still node boundaries.
115+
if (
116+
this.from < this.to &&
117+
$from.sameParent($to) &&
118+
$from.textOffset === 0 &&
119+
$to.textOffset === 0
120+
) {
121+
return new MultipleNodeSelection($from, $to);
122+
}
123+
124+
return Selection.near($from);
86125
}
87126
}
88127

0 commit comments

Comments
 (0)