diff --git a/packages/core/src/extensions/DropCursor/DropCursor.browser.test.ts b/packages/core/src/extensions/DropCursor/DropCursor.browser.test.ts
new file mode 100644
index 0000000000..606ec09f5d
--- /dev/null
+++ b/packages/core/src/extensions/DropCursor/DropCursor.browser.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import "../../editor/editor.css";
+
+const cleanup: (() => void)[] = [];
+
+afterEach(() => {
+ cleanup
+ .splice(0)
+ .reverse()
+ .forEach((dispose) => dispose());
+});
+
+describe("focus after a drop from another root", () => {
+ it("finishes a drop in a shadow root without a local dragend", async () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ cleanup.push(() => host.remove());
+ const root = host.attachShadow({ mode: "open" });
+ const container = document.createElement("div");
+ root.appendChild(container);
+ const editor = BlockNoteEditor.create({
+ initialContent: [{ type: "paragraph", content: "Target" }],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+
+ const view = editor.prosemirrorView;
+ const rect = view.dom.getBoundingClientRect();
+ const dataTransfer = new DataTransfer();
+ dataTransfer.setData("text/html", "
Incoming
");
+ dataTransfer.setData("blocknote/html", "Incoming
");
+ // A drag from a different document/root delivers drop here, but its
+ // dragend fires at the source and never reaches this root.
+ view.dom.dispatchEvent(
+ new DragEvent("drop", {
+ bubbles: true,
+ cancelable: true,
+ clientX: rect.left + 5,
+ clientY: rect.top + rect.height / 2,
+ dataTransfer,
+ }),
+ );
+
+ expect(view.state.doc.textContent).toContain("Incoming");
+ await expect.poll(() => view.state.selection.empty).toBe(true);
+ expect(root.activeElement).toBe(view.dom);
+ });
+});
diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts
index 1d5194b719..15f40ec5ad 100644
--- a/packages/core/src/extensions/DropCursor/DropCursor.ts
+++ b/packages/core/src/extensions/DropCursor/DropCursor.ts
@@ -1,4 +1,4 @@
-import { Plugin, PluginKey } from "prosemirror-state";
+import { Plugin, PluginKey, Selection } from "prosemirror-state";
import { dropPoint } from "prosemirror-transform";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import {
@@ -72,6 +72,9 @@ export const DropCursorExtension = createExtension<
let element: HTMLElement | null = null;
let timeout = -1;
let dragSourceElement: Element | null = null;
+ // Set when blocks are dropped in this editor, until the drag ends.
+ let blocksDropped = false;
+ let focusTimeout: ReturnType | undefined;
const config = {
width: options.dropCursor?.width ?? 5,
@@ -160,6 +163,10 @@ export const DropCursorExtension = createExtension<
const onDragStart = (event: Event) => {
const e = event as DragEvent;
dragSourceElement = e.target instanceof Element ? e.target : null;
+ // A drop from another document ends the drag there, without a `dragend`
+ // in this one.
+ blocksDropped = false;
+ clearTimeout(focusTimeout);
};
const onDragOver = (event: Event) => {
@@ -267,8 +274,19 @@ export const DropCursorExtension = createExtension<
}
};
- const onDrop = () => {
+ const onDrop = (event: Event) => {
scheduleRemoval(20);
+ // Only block drags carry `blocknote/html`, not text drags.
+ blocksDropped =
+ editor.prosemirrorView.editable &&
+ ((event as DragEvent).dataTransfer?.types.includes("blocknote/html") ??
+ false);
+ // A drop from another document or shadow root has no local dragend.
+ // Finish it after drop handlers run, unless dragend already did so.
+ clearTimeout(focusTimeout);
+ if (blocksDropped) {
+ focusTimeout = setTimeout(onRootDragEnd, 0);
+ }
};
const onDragEnd = () => {
@@ -276,6 +294,30 @@ export const DropCursorExtension = createExtension<
dragSourceElement = null;
};
+ /**
+ * Leaves the editor ready for the keyboard after blocks were dropped in it.
+ * Listens on the root, as `dragend` fires on the drag source, e.g. the side
+ * menu's handle, which is outside the editor.
+ */
+ function onRootDragEnd() {
+ clearTimeout(focusTimeout);
+ if (!blocksDropped) {
+ return;
+ }
+ blocksDropped = false;
+ const view = editor.prosemirrorView;
+ // Firefox shows no text cursor after a drop until the editor is focused
+ // again (#2128).
+ view.dom.blur();
+ // A text cursor at the end of the dropped blocks, so the keyboard works on
+ // the editor right away, e.g. to undo the drop (#3165). The drop leaves the
+ // blocks selected, which would show the formatting toolbar.
+ view.dispatch(
+ view.state.tr.setSelection(Selection.near(view.state.selection.$to, -1)),
+ );
+ view.focus();
+ }
+
// Drops the blocks where the cursor shows them: as the first children of
// the block it goes into. Other drops are ProseMirror's.
const dropIntoChildrenPlugin = new Plugin({
@@ -344,6 +386,8 @@ export const DropCursorExtension = createExtension<
} else {
editor.updateBlock(target, { children: draggedBlocks });
}
+ // Custom drops must also leave the cursor at the dropped blocks.
+ editor.setTextCursorPosition(draggedBlocks.at(-1), "end");
});
return true;
},
@@ -359,6 +403,10 @@ export const DropCursorExtension = createExtension<
capture: true,
signal,
});
+ root.addEventListener("dragend", onRootDragEnd, {
+ capture: true,
+ signal,
+ });
// Handle drag events on the editor
dom.addEventListener("dragover", onDragOver, { signal });
@@ -369,6 +417,7 @@ export const DropCursorExtension = createExtension<
// Clean up on unmount
signal.addEventListener("abort", () => {
clearTimeout(timeout);
+ clearTimeout(focusTimeout);
setCursor(null);
});
},
diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts
index 18dd451ee8..3d45b3a651 100644
--- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts
+++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts
@@ -1,10 +1,11 @@
-import { Selection, TextSelection } from "prosemirror-state";
+import { NodeSelection, Selection, TextSelection } from "prosemirror-state";
import {
createExtension,
createStore,
} from "../../editor/BlockNoteExtension.js";
import { isTouchDevice } from "../../util/browser.js";
+import { MultipleNodeSelection } from "../SideMenu/MultipleNodeSelection.js";
export const FormattingToolbarExtension = createExtension(({ editor }) => {
const store = createStore(false);
@@ -17,6 +18,16 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => {
return false;
}
+ // Don't show if whole blocks are selected. A block drag selects the
+ // dragged blocks, and undoing the drop selects them again.
+ if (
+ tr.selection instanceof MultipleNodeSelection ||
+ (tr.selection instanceof NodeSelection &&
+ tr.selection.node.type.isInGroup("bnBlock"))
+ ) {
+ return false;
+ }
+
// Don't show if the selection is a text selection but contains no text.
if (
tr.selection instanceof TextSelection &&
diff --git a/packages/core/src/extensions/SideMenu/MultipleNodeSelection.test.ts b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.test.ts
new file mode 100644
index 0000000000..b8c0ed743f
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.test.ts
@@ -0,0 +1,127 @@
+// @vitest-environment node
+import { history, redo, undo } from "@tiptap/pm/history";
+import { Schema } from "prosemirror-model";
+import { EditorState, Selection, TextSelection } from "prosemirror-state";
+import { describe, expect, it } from "vite-plus/test";
+
+import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
+
+const schema = new Schema({
+ nodes: {
+ doc: { content: "paragraph+" },
+ paragraph: { content: "text*" },
+ text: {},
+ },
+});
+
+function paragraph(text: string) {
+ return schema.nodes.paragraph.create(null, schema.text(text));
+}
+
+const doc = schema.nodes.doc.create(null, [
+ paragraph("A"),
+ paragraph("B"),
+ paragraph("C"),
+]);
+
+describe("MultipleNodeSelection bookmarks", () => {
+ it("round-trips a block selection through editor-state JSON", () => {
+ const selection = MultipleNodeSelection.create(doc, 0, 3);
+ const state = EditorState.create({ doc, selection });
+ const restored = EditorState.fromJSON({ schema }, state.toJSON());
+
+ expect(restored.selection.eq(selection)).toBe(true);
+ });
+
+ it.each([1, 2])("restores a selection of %i blocks", (count) => {
+ const selection = MultipleNodeSelection.create(doc, 0, count * 3);
+ const restored = selection.getBookmark().resolve(doc);
+
+ expect(restored).toBeInstanceOf(MultipleNodeSelection);
+ expect(restored.eq(selection)).toBe(true);
+ });
+
+ it("maps a selection past inserted blocks", () => {
+ const selection = MultipleNodeSelection.create(doc, 3, 6);
+ const tr = EditorState.create({ doc }).tr.insert(0, paragraph("X"));
+ const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
+
+ expect(restored).toBeInstanceOf(MultipleNodeSelection);
+ expect(restored.eq(MultipleNodeSelection.create(tr.doc, 6, 9))).toBe(true);
+ });
+
+ it("maps edits inside the selected blocks", () => {
+ const selection = MultipleNodeSelection.create(doc, 0, 6);
+ const tr = EditorState.create({ doc }).tr.insertText("more", 2);
+ const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
+
+ expect(restored).toBeInstanceOf(MultipleNodeSelection);
+ expect(restored.eq(MultipleNodeSelection.create(tr.doc, 0, 10))).toBe(true);
+ });
+
+ it.each([3, 6])("excludes blocks inserted at boundary %i", (pos) => {
+ const selection = MultipleNodeSelection.create(doc, 3, 6);
+ const tr = EditorState.create({ doc }).tr.insert(pos, paragraph("X"));
+ const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
+ const from = pos === 3 ? 6 : 3;
+
+ expect(restored).toBeInstanceOf(MultipleNodeSelection);
+ expect(
+ restored.eq(MultipleNodeSelection.create(tr.doc, from, from + 3)),
+ ).toBe(true);
+ });
+
+ it.each([3, 6])("keeps the live selection stable at boundary %i", (pos) => {
+ const selection = MultipleNodeSelection.create(doc, 3, 6);
+ const tr = EditorState.create({ doc }).tr.insert(pos, paragraph("X"));
+ const mapped = selection.map(tr.doc, tr.mapping);
+ const from = pos === 3 ? 6 : 3;
+
+ expect(
+ mapped.eq(MultipleNodeSelection.create(tr.doc, from, from + 3)),
+ ).toBe(true);
+ });
+
+ it("falls back to a cursor when the selected blocks are deleted", () => {
+ const selection = MultipleNodeSelection.create(doc, 0, 6);
+ const tr = EditorState.create({ doc }).tr.delete(0, 6);
+ const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
+
+ expect(restored).toBeInstanceOf(TextSelection);
+ expect(restored.empty).toBe(true);
+ expect(restored.eq(Selection.near(tr.doc.resolve(0)))).toBe(true);
+ });
+
+ it("falls back when joining blocks removes a selection boundary", () => {
+ const selection = MultipleNodeSelection.create(doc, 0, 3);
+ const tr = EditorState.create({ doc }).tr.join(3);
+ const restored = selection.getBookmark().map(tr.mapping).resolve(tr.doc);
+
+ expect(restored).toBeInstanceOf(TextSelection);
+ expect(restored.empty).toBe(true);
+ });
+
+ it.each([1, 2])("preserves %i selected blocks through undo/redo", (count) => {
+ const selection = MultipleNodeSelection.create(doc, 0, count * 3);
+ let state = EditorState.create({ doc, selection, plugins: [history()] });
+ const content = selection.content().content;
+ const tr = state.tr.delete(selection.from, selection.to);
+ tr.insert(tr.doc.content.size, content);
+ tr.setSelection(Selection.near(tr.doc.resolve(tr.doc.content.size)));
+ state = state.apply(tr);
+ const movedDoc = state.doc;
+
+ function dispatch(transaction: typeof tr) {
+ state = state.apply(transaction);
+ }
+
+ expect(undo(state, dispatch)).toBe(true);
+ expect(state.doc.eq(doc)).toBe(true);
+ expect(state.selection).toBeInstanceOf(MultipleNodeSelection);
+ expect(state.selection.eq(selection)).toBe(true);
+ expect(redo(state, dispatch)).toBe(true);
+ expect(state.doc.eq(movedDoc)).toBe(true);
+ expect(undo(state, dispatch)).toBe(true);
+ expect(state.selection.eq(selection)).toBe(true);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts
index cc241f0c35..c04ddf469e 100644
--- a/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts
+++ b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts
@@ -1,10 +1,10 @@
import { Fragment, Node, ResolvedPos, Slice } from "prosemirror-model";
-import { Selection } from "prosemirror-state";
+import { Selection, SelectionBookmark } from "prosemirror-state";
import { Mappable } from "prosemirror-transform";
/**
- * This class represents an editor selection which spans multiple nodes/blocks. It's currently only used to allow users
- * to drag multiple blocks at the same time. Expects the selection anchor and head to be between nodes, i.e. just before
+ * This class represents an editor selection which spans one or more whole nodes/blocks. It's used for block drags
+ * and their restored history selections. Expects the selection anchor and head to be between nodes, i.e. just before
* the first target node and just after the last, and that anchor and head are at the same nesting level.
*
* Partially based on ProseMirror's NodeSelection implementation:
@@ -37,6 +37,13 @@ export class MultipleNodeSelection extends Selection {
return new MultipleNodeSelection(doc.resolve(from), doc.resolve(to));
}
+ static fromJSON(doc: Node, json: { anchor: number; head: number }) {
+ if (typeof json.anchor !== "number" || typeof json.head !== "number") {
+ throw new RangeError("Invalid input for MultipleNodeSelection.fromJSON");
+ }
+ return MultipleNodeSelection.create(doc, json.anchor, json.head);
+ }
+
content(): Slice {
return new Slice(Fragment.from(this.nodes), 0, 0);
}
@@ -64,25 +71,57 @@ export class MultipleNodeSelection extends Selection {
}
map(doc: Node, mapping: Mappable): Selection {
- const fromResult = mapping.mapResult(this.from);
- const toResult = mapping.mapResult(this.to);
+ return this.getBookmark().map(mapping).resolve(doc);
+ }
+
+ getBookmark(): SelectionBookmark {
+ return new MultipleNodeBookmark(this.from, this.to);
+ }
+
+ toJSON() {
+ return { type: "multiple-node", anchor: this.anchor, head: this.head };
+ }
+}
- if (toResult.deleted) {
- return Selection.near(doc.resolve(fromResult.pos));
+class MultipleNodeBookmark implements SelectionBookmark {
+ constructor(
+ private readonly from: number,
+ private readonly to: number,
+ ) {}
+
+ map(mapping: Mappable): SelectionBookmark {
+ const from = mapping.mapResult(this.from, 1);
+ // A collapsed bookmark is a fallback cursor, not a range to expand.
+ if (this.from === this.to) {
+ return new MultipleNodeBookmark(from.pos, from.pos);
}
+ const to = mapping.mapResult(this.to, -1);
- if (fromResult.deleted) {
- return Selection.near(doc.resolve(toResult.pos));
+ if (to.deleted) {
+ return new MultipleNodeBookmark(from.pos, from.pos);
+ }
+ if (from.deleted) {
+ return new MultipleNodeBookmark(to.pos, to.pos);
}
- return new MultipleNodeSelection(
- doc.resolve(fromResult.pos),
- doc.resolve(toResult.pos),
- );
+ return new MultipleNodeBookmark(from.pos, to.pos);
}
- toJSON(): any {
- return { type: "multiple-node", anchor: this.anchor, head: this.head };
+ resolve(doc: Node): Selection {
+ const $from = doc.resolve(this.from);
+ const $to = doc.resolve(this.to);
+ // Structural edits can leave positions inside blocks or in different parents.
+ // Only restore the block selection when both ends are still node boundaries.
+ if (
+ this.from < this.to &&
+ $from.sameParent($to) &&
+ $from.textOffset === 0 &&
+ $to.textOffset === 0
+ ) {
+ return new MultipleNodeSelection($from, $to);
+ }
+
+ return Selection.near($from);
}
}
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts
new file mode 100644
index 0000000000..e987fa4e68
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { SideMenuExtension } from "./SideMenu.js";
+import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
+import { getNodeById } from "../../api/nodeUtil.js";
+import { unsetDragImage } from "./dragging.js";
+
+const cleanup: (() => void)[] = [];
+
+it("drags only the child when its parent has a single-block selection", () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ cleanup.push(() => container.remove());
+ const editor = BlockNoteEditor.create({
+ initialContent: [
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [{ id: "child", type: "paragraph", content: "Child" }],
+ },
+ ],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+ const view = editor.prosemirrorView;
+ cleanup.push(() => unsetDragImage(view.root));
+ const parent = getNodeById("parent", view.state.doc)!;
+ editor.transact((tr) =>
+ tr.setSelection(
+ MultipleNodeSelection.create(
+ tr.doc,
+ parent.posBeforeNode,
+ parent.posBeforeNode + parent.node.nodeSize,
+ ),
+ ),
+ );
+ editor
+ .getExtension(SideMenuExtension)!
+ .blockDragStart(
+ { dataTransfer: new DataTransfer(), clientY: 0 },
+ editor.getBlock("child")!,
+ );
+ expect(view.state.selection).toBeInstanceOf(MultipleNodeSelection);
+ expect(view.state.selection.content().content.firstChild?.attrs.id).toBe(
+ "child",
+ );
+});
+
+afterEach(() => {
+ cleanup
+ .splice(0)
+ .reverse()
+ .forEach((dispose) => dispose());
+});
+
+it("restores editor focus when a menu closes in another document", () => {
+ const frame = document.createElement("iframe");
+ document.body.appendChild(frame);
+ cleanup.push(() => frame.remove());
+ const ownerDocument = frame.contentDocument!;
+ const container = ownerDocument.createElement("div");
+ ownerDocument.body.appendChild(container);
+ const editor = BlockNoteEditor.create({
+ initialContent: [{ type: "paragraph", content: "Target" }],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+
+ expect(ownerDocument.activeElement).toBe(ownerDocument.body);
+ editor.getExtension(SideMenuExtension)!.unfreezeMenu();
+ expect(ownerDocument.activeElement).toBe(editor.prosemirrorView.dom);
+});
+
+it.each([false, true])(
+ "does not steal focus from another input (editor UI: %s)",
+ (editorUI) => {
+ const wrapper = document.createElement("div");
+ const container = document.createElement("div");
+ const input = document.createElement("input");
+ wrapper.appendChild(container);
+ document.body.append(wrapper, input);
+ cleanup.push(
+ () => wrapper.remove(),
+ () => input.remove(),
+ );
+ const editor = BlockNoteEditor.create({
+ initialContent: [{ type: "paragraph", content: "Target" }],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+ if (editorUI) {
+ editor.registerPortalElement(input);
+ cleanup.push(() => editor.unregisterPortalElement(input));
+ }
+
+ input.focus();
+ expect(editor.isWithinEditor(input)).toBe(editorUI);
+ editor.getExtension(SideMenuExtension)!.unfreezeMenu();
+ expect(document.activeElement).toBe(input);
+ },
+);
+
+it("ends a drag through the public API without blurring the editor", () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ cleanup.push(() => container.remove());
+ const editor = BlockNoteEditor.create({
+ initialContent: [
+ { id: "dragged", type: "paragraph", content: "Dragged" },
+ { id: "other", type: "paragraph", content: "Other" },
+ ],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+ const view = editor.prosemirrorView;
+ cleanup.push(() => unsetDragImage(view.root));
+ const sideMenu = editor.getExtension(SideMenuExtension)!;
+
+ function hover(id: string) {
+ const block = view.dom.querySelector(`[data-id="${id}"]`)!;
+ const rect = block.getBoundingClientRect();
+ block.dispatchEvent(
+ new MouseEvent("mousemove", {
+ bubbles: true,
+ // Do not let the lost-dragend mousemove fallback clean up for the API.
+ buttons: 1,
+ clientX: rect.left + 5,
+ clientY: rect.top + rect.height / 2,
+ }),
+ );
+ }
+
+ editor.focus();
+ hover("dragged");
+ expect(sideMenu.store.state?.show).toBe(true);
+ sideMenu.blockDragStart(
+ { dataTransfer: new DataTransfer(), clientY: 0 },
+ editor.getBlock("dragged")!,
+ );
+ expect(document.querySelector(".bn-drag-preview")).not.toBeNull();
+ sideMenu.blockDragEnd();
+ expect(document.querySelector(".bn-drag-preview")).toBeNull();
+ expect(sideMenu.store.state?.show).toBe(false);
+ expect(view.root.activeElement).toBe(view.dom);
+
+ // Clearing the drag state allows the menu to follow another block again.
+ hover("other");
+ expect(sideMenu.store.state?.block.id).toBe("other");
+ sideMenu.blockDragEnd();
+ expect(view.root.activeElement).toBe(view.dom);
+});
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index 4d7f1a79c3..84c5e6c59c 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -413,11 +413,14 @@ export const SideMenuExtension = createExtension(
},
/**
- * Handles drag & drop events for blocks.
+ * Ends a side menu drag. Call from the drag handle's `dragend` handler.
+ * Destination focus and selection are handled by the drop cursor.
*/
blockDragEnd() {
endBlockDrag();
- editor.blur();
+ // The menu stayed at the dragged block's old place during the drag.
+ // The next pointer move shows it at the block there.
+ hideMenu();
},
/**
@@ -450,6 +453,21 @@ export const SideMenuExtension = createExtension(
unfreezeMenu() {
menuFrozen = false;
hideMenu();
+ // Restore keyboard focus after menu actions, but don't steal it from
+ // another input. In shadow roots, check the focused element, not its host.
+ const view = editor.prosemirrorView;
+ const ownerDocument = view.dom.ownerDocument;
+ const active = view.root.activeElement ?? ownerDocument.activeElement;
+ if (active && active !== ownerDocument.body) {
+ // Editor ownership alone also includes inputs in other panels.
+ if (
+ !editor.isWithinEditor(active) ||
+ !menuElement?.contains(active)
+ ) {
+ return;
+ }
+ }
+ editor.focus();
},
/**
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index a6e8379a0e..40f9e24872 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -1,5 +1,5 @@
import { Node } from "prosemirror-model";
-import { NodeSelection, Selection } from "prosemirror-state";
+import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js";
@@ -183,7 +183,8 @@ export function dragStart<
const draggedBlockInSelection = from <= pos && pos < to;
const multipleBlocksSelected =
selection.$anchor.node() !== selection.$head.node() ||
- selection instanceof MultipleNodeSelection;
+ (selection instanceof MultipleNodeSelection &&
+ selection.nodes.length > 1);
if (draggedBlockInSelection && multipleBlocksSelected) {
view.dispatch(
@@ -191,8 +192,11 @@ export function dragStart<
);
setDragImage(view, from, to);
} else {
+ // Unlike NodeSelection, this restores a visible selection on undo.
view.dispatch(
- view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)),
+ view.state.tr.setSelection(
+ MultipleNodeSelection.create(doc, pos, pos + posInfo.node.nodeSize),
+ ),
);
setDragImage(view, pos);
}
diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json
index 764b2d7ec2..4eedfc06c2 100644
--- a/packages/xl-multi-column/package.json
+++ b/packages/xl-multi-column/package.json
@@ -69,6 +69,7 @@
"@blocknote/core": "workspace:^",
"@blocknote/react": "workspace:^",
"@tiptap/core": "^3.31.3",
+ "@tiptap/pm": "^3.31.3",
"prosemirror-model": "^1.25.11",
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.42.5"
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
index 5c0fc07dd1..9f5a5803fc 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
@@ -7,6 +7,7 @@ import {
nodeToBlock,
} from "@blocknote/core";
import { UniqueID } from "@blocknote/core/extensions";
+import { closeHistory } from "@tiptap/pm/history";
import type { Node } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
@@ -75,6 +76,10 @@ export function createMultiColumnHandleDropPlugin(
return false; // Let ProseMirror handle empty slice drops
}
const draggedBlockIds = new Set(draggedBlocks.map((block) => block.id));
+ let lastDraggedBlock = draggedBlocks.at(-1)!;
+ while (lastDraggedBlock.children.length > 0) {
+ lastDraggedBlock = lastDraggedBlock.children.at(-1)!;
+ }
if (blockInfo.blockNoteType === "column") {
// The user is dropping the target column's entire contents on the
@@ -195,22 +200,27 @@ export function createMultiColumnHandleDropPlugin(
id: UniqueID.options.generateID(),
});
- const blocksToRemove = draggedBlocks.filter(
- (block) =>
- editor.getBlock(block.id) &&
- !blocksAlreadyInColumnList.has(block.id),
- );
- if (blocksToRemove.length > 0) {
- editor.removeBlocks(blocksToRemove);
- }
+ // One transaction, so one undo reverts the drop (#3197).
+ editor.transact(() => {
+ const blocksToRemove = draggedBlocks.filter(
+ (block) =>
+ editor.getBlock(block.id) &&
+ !blocksAlreadyInColumnList.has(block.id),
+ );
+ if (blocksToRemove.length > 0) {
+ editor.removeBlocks(blocksToRemove);
+ }
- if (newChildren.length === 1) {
- editor.replaceBlocks([columnList], draggedBlocks);
- } else {
- editor.updateBlock(columnList, {
- children: newChildren,
- });
- }
+ if (newChildren.length === 1) {
+ editor.replaceBlocks([columnList], draggedBlocks);
+ } else {
+ editor.updateBlock(columnList, {
+ children: newChildren,
+ });
+ }
+ // Keep typing at the dropped blocks, not their old position.
+ editor.setTextCursorPosition(lastDraggedBlock, "end");
+ });
} else {
// Create new columnList with blocks as columns
const block = nodeToBlock(blockInfo.block.node, view.state.doc);
@@ -228,29 +238,34 @@ export function createMultiColumnHandleDropPlugin(
? [draggedBlocks, [block]]
: [[block], draggedBlocks];
- const blocksToRemove = draggedBlocks.filter((draggedBlock) =>
- editor.getBlock(draggedBlock.id),
- );
- if (blocksToRemove.length > 0) {
- editor.removeBlocks(blocksToRemove);
- }
+ editor.transact(() => {
+ const blocksToRemove = draggedBlocks.filter((draggedBlock) =>
+ editor.getBlock(draggedBlock.id),
+ );
+ if (blocksToRemove.length > 0) {
+ editor.removeBlocks(blocksToRemove);
+ }
- editor.replaceBlocks(
- [block],
- [
- {
- type: "columnList",
- children: columns.map((children) => {
- return {
- type: "column",
- children,
- };
- }),
- },
- ],
- );
+ editor.replaceBlocks(
+ [block],
+ [
+ {
+ type: "columnList",
+ children: columns.map((children) => {
+ return {
+ type: "column",
+ children,
+ };
+ }),
+ },
+ ],
+ );
+ editor.setTextCursorPosition(lastDraggedBlock, "end");
+ });
}
+ // Close after the drop: the next keystroke must be a separate undo step.
+ editor.transact((tr) => closeHistory(tr));
return true; // Prevent default ProseMirror drop behavior
},
},
diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts
index 94c696f512..be5c85cd42 100644
--- a/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts
+++ b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts
@@ -1,4 +1,5 @@
import { getNodeById } from "@blocknote/core";
+import { closeHistory } from "@tiptap/pm/history";
import { Fragment, Slice } from "prosemirror-model";
import { describe, expect, it, vi } from "vite-plus/test";
@@ -30,6 +31,36 @@ function dropOnEdge(ids: string[], target: string, position: "left" | "right") {
}
describe("column edge drops", () => {
+ it.each(["column-0", "paragraph-1"])(
+ "places the cursor in the final nested child when dropping beside %s",
+ (target) => {
+ const editor = getEditor();
+ dropOnEdge(["paragraph-0"], target, "right");
+ expect(editor.getTextCursorPosition().block.id).toBe(
+ "nested-paragraph-0",
+ );
+ expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(
+ "Nested Paragraph 0".length,
+ );
+ },
+ );
+
+ it.each(["column-0", "paragraph-0"])(
+ "undoes immediate typing separately from the drop beside %s",
+ (target) => {
+ const editor = getEditor();
+ editor.transact((tr) => closeHistory(tr));
+ const before = editor.document;
+ dropOnEdge(["paragraph-1"], target, "right");
+ const dropped = editor.document;
+ editor.transact((tr) => tr.insertText("typed"));
+ expect(editor.undo()).toBe(true);
+ expect(editor.document).toEqual(dropped);
+ expect(editor.undo()).toBe(true);
+ expect(editor.document).toEqual(before);
+ },
+ );
+
it.each(["left", "right"] as const)(
"moves a mixed selection to an emptied target's %s edge",
(edge) => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1c50b9ed84..4d26043f2b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6269,6 +6269,9 @@ importers:
'@tiptap/core':
specifier: ^3.31.3
version: 3.31.3(@tiptap/pm@3.31.3)
+ '@tiptap/pm':
+ specifier: ^3.31.3
+ version: 3.31.3
prosemirror-model:
specifier: 1.25.12
version: 1.25.12
diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx
index dc98e87b6b..aab0581b69 100644
--- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx
+++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx
@@ -20,13 +20,14 @@ import {
PARAGRAPH_SELECTOR,
PDF_SELECTOR,
} from "../../utils/const.js";
-import { browserName, userEvent } from "../../utils/context.js";
+import { browserName, MOD, userEvent } from "../../utils/context.js";
import { insertHeading, insertParagraph } from "../../utils/copypaste.js";
import {
compareDocToSnapshot,
focusOnEditor,
sleep,
waitForSelector,
+ waitForSelectorDetached,
} from "../../utils/editor.js";
import {
dragAndDropBlock,
@@ -41,7 +42,7 @@ import { executeSlashCommand } from "../../utils/slashmenu.js";
// types `expect` from an internal module, so the augmentation doesn't attach.
// Type the accessor locally.
type ElementMatchers = {
- not: { toBeVisible(): Promise };
+ toHaveFocus(): Promise;
};
type ElementExpect = (element: Element | null) => ElementMatchers;
const expectElement = (expect as unknown as { element: ElementExpect }).element;
@@ -142,9 +143,7 @@ describe("Check Block Dragging Functionality", () => {
await dragAndDropBlock(IMAGE_SELECTOR, H_ONE_BLOCK_SELECTOR, false);
- await expectElement(
- document.querySelector(".bn-formatting-toolbar"),
- ).not.toBeVisible();
+ await waitForSelectorDetached(".bn-formatting-toolbar");
},
);
@@ -338,6 +337,28 @@ function block(id: string) {
return document.querySelector(`[data-id="${id}"]`)!;
}
+function expectVisibleBlockSelection(blockIds: string[]) {
+ const view = editors[0].prosemirrorView;
+ const selection = view.state.selection;
+ expect(selection.toJSON().type).toBe("multiple-node");
+ const selectedIds: string[] = [];
+ selection
+ .content()
+ .content.forEach((node) => selectedIds.push(node.attrs.id));
+ expect(selectedIds).toEqual(blockIds);
+ expect(selection.visible).toBe(true);
+ expect(view.dom.classList.contains("ProseMirror-hideselection")).toBe(false);
+
+ const nativeSelection = view.dom.ownerDocument.getSelection()!;
+ expect(nativeSelection.rangeCount).toBe(1);
+ expect(nativeSelection.isCollapsed).toBe(false);
+ const range = nativeSelection.getRangeAt(0);
+ for (const id of blockIds) {
+ expect(range.intersectsNode(block(id))).toBe(true);
+ }
+ expect(range.getBoundingClientRect().height).toBeGreaterThan(0);
+}
+
/**
* Where the mouse picks the block with the ID: the chrome of a layout block, or
* else the block's element.
@@ -502,3 +523,85 @@ describe.skipIf(browserName === "firefox")("Dropping into a block", () => {
expect(ids(editors[0].document[0].children)).toEqual(["dragged"]);
});
});
+
+// Playwright doesn't correctly simulate drag events in Firefox.
+describe.skipIf(browserName === "firefox")(
+ "Focus after dragging a block",
+ () => {
+ // The drop leaves a text cursor in the editor, so the keyboard works on it
+ // right away, e.g. to undo the drop (#3165).
+ test("undoes a drop with the keyboard", async () => {
+ await renderEditors([
+ { id: "a", type: "paragraph", content: "A" },
+ { id: "b", type: "paragraph", content: "B" },
+ ]);
+
+ await dragAndDropBlock(block("a"), block("b"), false);
+ await expectElement(editors[0].prosemirrorView.dom).toHaveFocus();
+ expect(ids(editors[0].document)).toEqual(["b", "a"]);
+ await waitForSelectorDetached(".bn-side-menu");
+ // A text cursor shows no formatting toolbar.
+ await waitForSelectorDetached(".bn-formatting-toolbar");
+
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(ids(editors[0].document)).toEqual(["a", "b"]);
+ await expectElement(editors[0].prosemirrorView.dom).toHaveFocus();
+ expectVisibleBlockSelection(["a"]);
+ await waitForSelectorDetached(".bn-formatting-toolbar");
+ });
+
+ test("undoes a drop into a toggle with the keyboard", async () => {
+ await renderEditors([
+ { id: "toggle", type: "toggleListItem", content: "Toggle" },
+ { id: "dragged", type: "paragraph", content: "Dragged" },
+ ]);
+
+ await dragAndDropBlock(
+ block("dragged"),
+ block("toggle").querySelector(".bn-block-content")!,
+ false,
+ );
+ await expectElement(editors[0].prosemirrorView.dom).toHaveFocus();
+ expect(ids(editors[0].document[0].children)).toEqual(["dragged"]);
+
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(ids(editors[0].document)).toEqual(["toggle", "dragged"]);
+ expectVisibleBlockSelection(["dragged"]);
+ });
+
+ // A dropped block without text has no place for a text cursor.
+ test("undoes a drop of an image with the keyboard", async () => {
+ await renderEditors([
+ { id: "a", type: "paragraph", content: "A" },
+ { id: "image", type: "image" },
+ { id: "b", type: "paragraph", content: "B" },
+ ]);
+
+ await dragAndDropBlock(block("image"), block("b"), false);
+ await expectElement(editors[0].prosemirrorView.dom).toHaveFocus();
+ expect(ids(editors[0].document)).toEqual(["a", "b", "image"]);
+ await waitForSelectorDetached(".bn-formatting-toolbar");
+
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(ids(editors[0].document)).toEqual(["a", "image", "b"]);
+ expectVisibleBlockSelection(["image"]);
+ });
+
+ test("restores a visible multi-block selection after undoing a drop", async () => {
+ await renderEditors([
+ { id: "a", type: "paragraph", content: "A" },
+ { id: "b", type: "paragraph", content: "B" },
+ { id: "c", type: "paragraph", content: "C" },
+ ]);
+ editors[0].setSelection("a", "b");
+
+ await dragAndDropBlock(block("a"), block("c"), false);
+ expect(ids(editors[0].document)).toEqual(["c", "a", "b"]);
+ await expectElement(editors[0].prosemirrorView.dom).toHaveFocus();
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(ids(editors[0].document)).toEqual(["a", "b", "c"]);
+ expectVisibleBlockSelection(["a", "b"]);
+ await waitForSelectorDetached(".bn-formatting-toolbar");
+ });
+ },
+);
diff --git a/tests/src/end-to-end/draghandle/draghandle.test.tsx b/tests/src/end-to-end/draghandle/draghandle.test.tsx
index d8d6c9c56a..d95f4825b5 100644
--- a/tests/src/end-to-end/draghandle/draghandle.test.tsx
+++ b/tests/src/end-to-end/draghandle/draghandle.test.tsx
@@ -1,3 +1,6 @@
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
import App from "@examples/01-basic/testing/src/App";
import { beforeEach, describe, expect, test } from "vite-plus/test";
import { render } from "vitest-browser-react";
@@ -13,6 +16,7 @@ import {
H_TWO_BLOCK_SELECTOR,
PARAGRAPH_SELECTOR,
SLASH_MENU_SELECTOR,
+ TEXT_COLOR_SELECTOR,
} from "../../utils/const.js";
import { insertHeading } from "../../utils/copypaste.js";
import {
@@ -269,3 +273,41 @@ describe("Check Draghandle functionality", () => {
}
});
});
+
+describe("Focus after the drag handle menu", () => {
+ // Closing the menu gives the focus back to the editor, so the keyboard
+ // works on it right away, e.g. to undo the change (#3165). Nothing is typed
+ // first: the browser would otherwise undo through its own undo stack, even
+ // without focus.
+ test("undoes a block color set in the drag handle menu with the keyboard", async () => {
+ function Editor() {
+ const editor = useCreateBlockNote({
+ initialContent: [{ type: "paragraph", content: "Paragraph" }],
+ });
+ return ;
+ }
+ await render();
+ // The block is drawn again when its color changes.
+ const textColor = () =>
+ document
+ .querySelector(PARAGRAPH_SELECTOR)!
+ .closest(".bn-block")!
+ .getAttribute("data-text-color");
+
+ await moveMouseOverElement(await waitForSelector(PARAGRAPH_SELECTOR));
+ await userEvent.click(await waitForSelector(DRAG_HANDLE_SELECTOR));
+ await waitForSelector(DRAG_HANDLE_MENU_SELECTOR);
+ await moveMouseOverElement(page.getByText("Colors").element());
+ // WebKit needs the submenu's open animation to settle before its
+ // children have non-zero bounding boxes.
+ await waitForSelector(TEXT_COLOR_SELECTOR("red"));
+ await sleep(200);
+ const { x, y } = getRect(TEXT_COLOR_SELECTOR("red"));
+ await clickAt(x + 10, y + 10);
+ await waitForSelectorDetached(DRAG_HANDLE_MENU_SELECTOR);
+ expect(textColor()).toBe("red");
+
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(textColor()).toBeNull();
+ });
+});
diff --git a/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx
index 1c90c86d16..a33d1ac368 100644
--- a/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx
+++ b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx
@@ -7,11 +7,15 @@ import {
multiColumnDropCursor,
withMultiColumn,
} from "@blocknote/xl-multi-column";
-import { beforeEach, describe, test } from "vite-plus/test";
+import { beforeEach, describe, expect, test } from "vite-plus/test";
import { render } from "vitest-browser-react";
import { EDITOR_SELECTOR } from "../../utils/const.js";
-import { browserName, page, userEvent } from "../../utils/context.js";
-import { compareDocToSnapshot, waitForSelector } from "../../utils/editor.js";
+import { browserName, MOD, page, userEvent } from "../../utils/context.js";
+import {
+ compareDocToSnapshot,
+ getDoc,
+ waitForSelector,
+} from "../../utils/editor.js";
import { clickAt, dragAndDropBlock, getRect } from "../../utils/mouse.js";
// A two-column layout where the left column contains a single block, for
@@ -138,6 +142,46 @@ describe("Check Multi-Column Drop Behaviour", () => {
},
);
+ // The drop leaves a text cursor at the end of the dropped block, so typing
+ // continues there (#3165).
+ test.skipIf(browserName === "firefox")(
+ "Check typing after dragging a block to a new column",
+ async () => {
+ await focusOnOutsideParagraph();
+
+ await dragAndDropBlock(
+ page.getByText("Paragraph outside columns").element(),
+ blockContent("Right block 2"),
+ false,
+ );
+ await userEvent.keyboard("X");
+
+ expect(
+ page.getByText("Paragraph outside columnsX").element(),
+ ).toBeDefined();
+ },
+ );
+
+ // The drop used to take several undo steps, the first of which removed the
+ // dropped block (#3197).
+ test.skipIf(browserName === "firefox")(
+ "Check undoing a drag of a block to a new column",
+ async () => {
+ await focusOnOutsideParagraph();
+ const before = getDoc();
+
+ await dragAndDropBlock(
+ page.getByText("Paragraph outside columns").element(),
+ blockContent("Right block 2"),
+ false,
+ );
+ expect(getDoc()).not.toEqual(before);
+
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ expect(getDoc()).toEqual(before);
+ },
+ );
+
// Dragging a multi-block selection to the edge of a column used to only
// move the first block of the selection into the new column, or throw when
// the drag fragment nests the blocks in a `blockGroup` node.