From 917629c7db502ff75cbfcfdc25324db80e5038b3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 14:05:34 +0200 Subject: [PATCH 1/5] fix(core): keep the editor focused after a block drop and after side menu menus A drag with the side menu handle left the editor without focus, so Ctrl-Z and other keys did nothing until the user clicked into the editor again (#3165). The same happened after a change from the drag handle menu, for example a color change. - The drop cursor extension now ends every block drop in the editor that gets it: at `dragend` it blurs the editor (Firefox caret fix, #2128), puts a text cursor at the end of the dropped blocks and focuses the editor. - The side menu ends its own drag at `dragend` on the document, so a custom side menu needs no wiring. `blockDragEnd` is deprecated and does nothing. - Closing a side menu menu gives the focus back to the editor when the focus is in the side menu or lost. - A drop at a block's edge (new column) is now one transaction, so one undo reverts it (#3197), and it leaves the cursor on the dropped blocks. --- .../docs/getting-started/vanilla-js.mdx | 2 +- .../13-custom-ui/src/MUISideMenu.tsx | 1 - .../vanilla-custom-side-menu/src/App.tsx | 1 - .../src/extensions/DropCursor/DropCursor.ts | 46 +++++++++- .../extensions/DropCursor/dropIntoChildren.ts | 4 + .../core/src/extensions/SideMenu/SideMenu.ts | 42 ++++++++-- .../extensions/SideMenu/blockDropHandling.ts | 5 ++ .../core/src/extensions/SideMenu/dragging.ts | 4 + .../DefaultButtons/DragHandleButton.tsx | 2 - .../DropCursor/multiColumnHandleDropPlugin.ts | 83 +++++++++++-------- .../extensions/columnDrop.browser.test.ts | 5 +- .../src/end-to-end/dragdrop/dragdrop.test.tsx | 63 +++++++++++++- .../end-to-end/draghandle/draghandle.test.tsx | 42 ++++++++++ .../multicolumn/multicolumnDrop.test.tsx | 50 ++++++++++- 14 files changed, 296 insertions(+), 54 deletions(-) diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index 149d386bba..ce8ae85eea 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -75,6 +75,6 @@ const unsubscribe = extension.store.subscribe(() => { }); ``` -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: +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: diff --git a/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx b/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx index 90fff854f6..f79165f213 100644 --- a/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx +++ b/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx @@ -114,7 +114,6 @@ function MUIDragHandleButton(props: SideMenuProps) { draggable={"true"} onClick={onClick} onDragStart={(e) => sideMenu.blockDragStart(e, block)} - onDragEnd={sideMenu.blockDragEnd} > sideMenu.blockDragEnd()); dragBtn.draggable = true; element.style.display = "none"; element.appendChild(dragBtn); diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 1d5194b719..7ccdc84a83 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,4 +1,9 @@ -import { Plugin, PluginKey } from "prosemirror-state"; +// Handles drops into one editor. It shows the drop cursor: the line that shows +// where the dragged content will go. It inserts blocks that go into a block, +// e.g. a toggle (ProseMirror inserts all other drops). After a block drop, it +// focuses the editor, with a text cursor at the end of the dropped blocks. + +import { Plugin, PluginKey, Selection } from "prosemirror-state"; import { dropPoint } from "prosemirror-transform"; import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; import { @@ -72,6 +77,8 @@ 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; const config = { width: options.dropCursor?.width ?? 5, @@ -267,8 +274,13 @@ 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); }; const onDragEnd = () => { @@ -276,6 +288,29 @@ 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() { + 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 +379,9 @@ export const DropCursorExtension = createExtension< } else { editor.updateBlock(target, { children: draggedBlocks }); } + // The selection on the dropped blocks, as ProseMirror's drop leaves + // it. `onRootDragEnd` then focuses the editor. + editor.setTextCursorPosition(draggedBlocks.at(-1), "end"); }); return true; }, @@ -359,6 +397,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 }); diff --git a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts index 9407fd6139..11f6e2650c 100644 --- a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts +++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts @@ -1,3 +1,7 @@ +// Finds out if a block drag goes into the children of a block with +// `meta.dropsIntoChildren`, e.g. a toggle. `DropCursor.ts` uses this to show the +// cursor and to drop the blocks. + import type { EditorView } from "prosemirror-view"; import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js"; diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 4d7f1a79c3..86786de07d 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -1,3 +1,9 @@ +// The side menu: the "+" button and the drag handle next to the block under +// the pointer. This file shows, hides and freezes the menu. It is also the +// source of block drags: it starts a drag from the handle and cleans up after +// it. `blockDropHandling.ts` routes the drag between editors, and the +// `DropCursor` extension handles the drop. + import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Block } from "../../blocks/defaultBlocks.js"; @@ -159,8 +165,8 @@ export const SideMenuExtension = createExtension( ) { // While a block is dragged by its handle, the menu must stay as it is: // the handle is the drag source, and if the menu hid or moved, React would - // remove it from the DOM, so its `dragend` (and `blockDragEnd`, which - // removes the drag image) would never fire. The drop's document change + // remove it from the DOM, so its `dragend` (which removes the drag image) + // would never fire. The drop's document change // otherwise re-resolves the block at the pointer's pre-drag position. if (menuFrozen || isDragOrigin) { // Frozen or dragging: keep the menu as it is. @@ -306,6 +312,19 @@ export const SideMenuExtension = createExtension( signal, ); + // Ends a drag of this side menu. The handle's `dragend` reaches the + // document, as the menu keeps the handle mounted while it is dragged + // (see `update`), so a custom side menu needs no wiring. + root.addEventListener( + "dragend", + () => { + if (isDragOrigin) { + endBlockDrag(); + } + }, + { signal, capture: true }, + ); + // Shows or updates the menu whenever the pointer moves, unless frozen. root.addEventListener( // `mousemove`, not `pointermove`: iOS Safari delivers a tap as a hover @@ -413,12 +432,10 @@ export const SideMenuExtension = createExtension( }, /** - * Handles drag & drop events for blocks. + * @deprecated Does nothing: the side menu ends a drag by itself, when + * the handle's `dragend` reaches the document. Remove the call. */ - blockDragEnd() { - endBlockDrag(); - editor.blur(); - }, + blockDragEnd() {}, /** * Whether the side menu is currently frozen (e.g. because the drag handle @@ -450,6 +467,17 @@ export const SideMenuExtension = createExtension( unfreezeMenu() { menuFrozen = false; hideMenu(); + // Closing a menu of the side menu gives the focus back to the editor, + // so the keyboard works on it right away, e.g. to undo a change made + // in the menu (#3165). Only when the focus is in the side menu (its + // menus render in it) or lost, not when it moved elsewhere, e.g. to + // an input that a menu item opened. + const active = document.activeElement; + const focusLost = !active || active === document.body; + const focusInSideMenu = !!menuElement && menuElement.contains(active); + if (focusLost || focusInSideMenu) { + editor.focus(); + } }, /** diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 08b76a19da..f9643579c9 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -1,3 +1,8 @@ +// Routes side menu drags between editors. It finds the editor that a drag event +// is for, sends drag events near an editor into that editor, and removes the +// dragged blocks from their editor after a drop in another editor. It does not +// decide where the blocks go in an editor: the `DropCursor` extension does. + import { DOMParser, Slice } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index a6e8379a0e..832d5a2cef 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -1,3 +1,7 @@ +// The start of a side menu drag. It selects the dragged blocks, makes the drag +// image, and puts the blocks into the drag data. Other files handle the drag +// after `dragstart`. + import { Node } from "prosemirror-model"; import { NodeSelection, Selection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; diff --git a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx index 37aa012db1..549d024685 100644 --- a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx @@ -48,8 +48,6 @@ export const DragHandleButton = ( label={dict.side_menu.drag_handle_label} draggable={true} onDragStart={(e) => sideMenu.blockDragStart(e, block)} - // eslint-disable-next-line @typescript-eslint/unbound-method -- blockDragEnd is a plain object method, not a class method - onDragEnd={sideMenu.blockDragEnd} className={"bn-button"} icon={} /> diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 5c0fc07dd1..a8ead5bba7 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -1,3 +1,7 @@ +// Drops blocks at the left or right edge of a block into a new column. It +// makes the change in one transaction and leaves the selection on the dropped +// blocks. ProseMirror and the `DropCursor` extension handle all other drops. + import type { BlockNoteEditor } from "@blocknote/core"; import { createExtension, @@ -195,22 +199,28 @@ 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, + }); + } + // The selection on the dropped blocks, as ProseMirror's drop + // leaves it. The drop cursor then focuses the editor. + editor.setTextCursorPosition(draggedBlocks.at(-1)!, "end"); + }); } else { // Create new columnList with blocks as columns const block = nodeToBlock(blockInfo.block.node, view.state.doc); @@ -228,27 +238,30 @@ 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(draggedBlocks.at(-1)!, "end"); + }); } return true; // Prevent default ProseMirror drop behavior diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts index 2349f3b5ff..40982f6443 100644 --- a/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts +++ b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts @@ -56,7 +56,10 @@ function mount() { } afterEach(() => { - sideMenu().blockDragEnd(); + // Ends the drag as the browser does, which removes the drag image. + editor.prosemirrorView.dom.parentElement!.dispatchEvent( + new DragEvent("dragend", { bubbles: true }), + ); editor._tiptapEditor.destroy(); root.remove(); }); diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index dc98e87b6b..bff02ca6c1 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, @@ -502,3 +503,63 @@ 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 sleep(100); + expect(ids(editors[0].document)).toEqual(["b", "a"]); + // 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"]); + }); + + 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 sleep(100); + expect(ids(editors[0].document[0].children)).toEqual(["dragged"]); + + await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); + expect(ids(editors[0].document)).toEqual(["toggle", "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 sleep(300); + expect(ids(editors[0].document)).toEqual(["a", "b", "image"]); + expect(editors[0].isFocused()).toBe(true); + await waitForSelectorDetached(".bn-formatting-toolbar"); + + await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); + expect(ids(editors[0].document)).toEqual(["a", "image", "b"]); + }); + }, +); 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. From f2fb4381ffe4b0d67bdd9a5fa48d2e46979bcab8 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 14:40:49 +0200 Subject: [PATCH 2/5] fix(core)!: remove blockDragEnd, hide the side menu after a drop - `blockDragEnd` is removed. The side menu ends its drag by itself, at the handle's `dragend`. Remove calls to it. - The side menu hides at the end of a drag. Before, it stayed at the dragged block's old place until the next pointer move. - The formatting toolbar does not show when whole blocks are selected. A block drag selects the dragged blocks, and undoing the drop selected them again, which showed the toolbar. BREAKING CHANGE: `SideMenuExtension.blockDragEnd` is removed. --- .../FormattingToolbar/FormattingToolbar.ts | 13 ++++++- .../core/src/extensions/SideMenu/SideMenu.ts | 9 ++--- .../src/end-to-end/dragdrop/dragdrop.test.tsx | 34 ++++++++++++++++--- 3 files changed, 45 insertions(+), 11 deletions(-) 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/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 86786de07d..46d2b67bd5 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -320,6 +320,9 @@ export const SideMenuExtension = createExtension( () => { if (isDragOrigin) { endBlockDrag(); + // The menu stayed at the dragged block's old place during the + // drag. The next pointer move shows it at the block there. + hideMenu(); } }, { signal, capture: true }, @@ -431,12 +434,6 @@ export const SideMenuExtension = createExtension( dragStart(event, block, editor); }, - /** - * @deprecated Does nothing: the side menu ends a drag by itself, when - * the handle's `dragend` reaches the document. Remove the call. - */ - blockDragEnd() {}, - /** * Whether the side menu is currently frozen (e.g. because the drag handle * menu is open). diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index bff02ca6c1..d9d26a0576 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -42,6 +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 = { + toHaveFocus(): Promise; not: { toBeVisible(): Promise }; }; type ElementExpect = (element: Element | null) => ElementMatchers; @@ -517,7 +518,7 @@ describe.skipIf(browserName === "firefox")( ]); await dragAndDropBlock(block("a"), block("b"), false); - await sleep(100); + await expectElement(editors[0].prosemirrorView.dom).toHaveFocus(); expect(ids(editors[0].document)).toEqual(["b", "a"]); // A text cursor shows no formatting toolbar. await waitForSelectorDetached(".bn-formatting-toolbar"); @@ -537,13 +538,39 @@ describe.skipIf(browserName === "firefox")( block("toggle").querySelector(".bn-block-content")!, false, ); - await sleep(100); + 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"]); }); + test("shows no formatting toolbar after undoing a drop", 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(); + await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); + expect(ids(editors[0].document)).toEqual(["a", "b"]); + await waitForSelectorDetached(".bn-formatting-toolbar"); + }); + + // The menu stays at the dragged block's old place during the drag. The next + // pointer move shows it again, at the block under the pointer. + test("hides the side menu after a drop", async () => { + await renderEditors([ + { id: "a", type: "paragraph", content: "A" }, + { id: "b", type: "paragraph", content: "B" }, + ]); + + await dragAndDropBlock(block("a"), block("b"), false); + await waitForSelectorDetached(".bn-side-menu"); + expect(ids(editors[0].document)).toEqual(["b", "a"]); + }); + // 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([ @@ -553,9 +580,8 @@ describe.skipIf(browserName === "firefox")( ]); await dragAndDropBlock(block("image"), block("b"), false); - await sleep(300); + await expectElement(editors[0].prosemirrorView.dom).toHaveFocus(); expect(ids(editors[0].document)).toEqual(["a", "b", "image"]); - expect(editors[0].isFocused()).toBe(true); await waitForSelectorDetached(".bn-formatting-toolbar"); await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); From fa152959a2136be2bfef8984338c5959c04524b9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 17:21:18 +0200 Subject: [PATCH 3/5] fix(core): keep focus handling correct for cross-document drops and shadow roots - The drop cursor clears its "blocks dropped" flag at each `dragstart`. A drop from another document ends its drag there, without a `dragend` in this one, so the flag could stay set until an unrelated later drag. - `unfreezeMenu` reads the active element from the editor's root. In a shadow root, the document's active element is the shadow host. - The image drag test checks that the formatting toolbar is not in the document. The query gave `null`, which the element matcher rejects. --- packages/core/src/extensions/DropCursor/DropCursor.ts | 3 +++ packages/core/src/extensions/SideMenu/SideMenu.ts | 4 +++- tests/src/end-to-end/dragdrop/dragdrop.test.tsx | 5 +---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 7ccdc84a83..031c17a51b 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -167,6 +167,9 @@ 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; }; const onDragOver = (event: Event) => { diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 46d2b67bd5..7b44630433 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -469,7 +469,9 @@ export const SideMenuExtension = createExtension( // in the menu (#3165). Only when the focus is in the side menu (its // menus render in it) or lost, not when it moved elsewhere, e.g. to // an input that a menu item opened. - const active = document.activeElement; + // In a shadow root, the document's active element is the shadow host. + const active = + editor.prosemirrorView.root.activeElement ?? document.activeElement; const focusLost = !active || active === document.body; const focusInSideMenu = !!menuElement && menuElement.contains(active); if (focusLost || focusInSideMenu) { diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index d9d26a0576..bad2d3455c 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -43,7 +43,6 @@ import { executeSlashCommand } from "../../utils/slashmenu.js"; // Type the accessor locally. type ElementMatchers = { toHaveFocus(): Promise; - not: { toBeVisible(): Promise }; }; type ElementExpect = (element: Element | null) => ElementMatchers; const expectElement = (expect as unknown as { element: ElementExpect }).element; @@ -144,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"); }, ); From cc4f00a3068e4c12600882f65ddb91fbd9b68337 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Oct 2026 16:54:47 +0100 Subject: [PATCH 4/5] fix(core): preserve visible block selections and drag-end compatibility --- .../docs/getting-started/vanilla-js.mdx | 2 +- .../13-custom-ui/src/MUISideMenu.tsx | 1 + .../vanilla-custom-side-menu/src/App.tsx | 1 + .../DropCursor/DropCursor.browser.test.ts | 50 +++++++ .../src/extensions/DropCursor/DropCursor.ts | 18 ++- .../extensions/DropCursor/dropIntoChildren.ts | 4 - .../SideMenu/MultipleNodeSelection.test.ts | 127 ++++++++++++++++++ .../SideMenu/MultipleNodeSelection.ts | 69 +++++++--- .../SideMenu/SideMenu.browser.test.ts | 111 +++++++++++++++ .../core/src/extensions/SideMenu/SideMenu.ts | 63 ++++----- .../extensions/SideMenu/blockDropHandling.ts | 5 - .../core/src/extensions/SideMenu/dragging.ts | 11 +- .../DefaultButtons/DragHandleButton.tsx | 2 + .../DropCursor/multiColumnHandleDropPlugin.ts | 7 +- .../extensions/columnDrop.browser.test.ts | 5 +- .../src/end-to-end/dragdrop/dragdrop.test.tsx | 67 +++++---- 16 files changed, 435 insertions(+), 108 deletions(-) create mode 100644 packages/core/src/extensions/DropCursor/DropCursor.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/MultipleNodeSelection.test.ts create mode 100644 packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index ce8ae85eea..149d386bba 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -75,6 +75,6 @@ const unsubscribe = extension.store.subscribe(() => { }); ``` -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: +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: diff --git a/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx b/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx index f79165f213..90fff854f6 100644 --- a/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx +++ b/examples/03-ui-components/13-custom-ui/src/MUISideMenu.tsx @@ -114,6 +114,7 @@ function MUIDragHandleButton(props: SideMenuProps) { draggable={"true"} onClick={onClick} onDragStart={(e) => sideMenu.blockDragStart(e, block)} + onDragEnd={sideMenu.blockDragEnd} > sideMenu.blockDragEnd()); dragBtn.draggable = true; element.style.display = "none"; element.appendChild(dragBtn); 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 031c17a51b..15f40ec5ad 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,8 +1,3 @@ -// Handles drops into one editor. It shows the drop cursor: the line that shows -// where the dragged content will go. It inserts blocks that go into a block, -// e.g. a toggle (ProseMirror inserts all other drops). After a block drop, it -// focuses the editor, with a text cursor at the end of the dropped blocks. - import { Plugin, PluginKey, Selection } from "prosemirror-state"; import { dropPoint } from "prosemirror-transform"; import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; @@ -79,6 +74,7 @@ export const DropCursorExtension = createExtension< 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, @@ -170,6 +166,7 @@ export const DropCursorExtension = createExtension< // A drop from another document ends the drag there, without a `dragend` // in this one. blocksDropped = false; + clearTimeout(focusTimeout); }; const onDragOver = (event: Event) => { @@ -284,6 +281,12 @@ export const DropCursorExtension = createExtension< 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 = () => { @@ -297,6 +300,7 @@ export const DropCursorExtension = createExtension< * menu's handle, which is outside the editor. */ function onRootDragEnd() { + clearTimeout(focusTimeout); if (!blocksDropped) { return; } @@ -382,8 +386,7 @@ export const DropCursorExtension = createExtension< } else { editor.updateBlock(target, { children: draggedBlocks }); } - // The selection on the dropped blocks, as ProseMirror's drop leaves - // it. `onRootDragEnd` then focuses the editor. + // Custom drops must also leave the cursor at the dropped blocks. editor.setTextCursorPosition(draggedBlocks.at(-1), "end"); }); return true; @@ -414,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/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts index 11f6e2650c..9407fd6139 100644 --- a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts +++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts @@ -1,7 +1,3 @@ -// Finds out if a block drag goes into the children of a block with -// `meta.dropsIntoChildren`, e.g. a toggle. `DropCursor.ts` uses this to show the -// cursor and to drop the blocks. - import type { EditorView } from "prosemirror-view"; import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js"; 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..7f2fad1805 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { SideMenuExtension } from "./SideMenu.js"; +import { unsetDragImage } from "./dragging.js"; + +const cleanup: (() => void)[] = []; + +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 7b44630433..84c5e6c59c 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -1,9 +1,3 @@ -// The side menu: the "+" button and the drag handle next to the block under -// the pointer. This file shows, hides and freezes the menu. It is also the -// source of block drags: it starts a drag from the handle and cleans up after -// it. `blockDropHandling.ts` routes the drag between editors, and the -// `DropCursor` extension handles the drop. - import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Block } from "../../blocks/defaultBlocks.js"; @@ -165,8 +159,8 @@ export const SideMenuExtension = createExtension( ) { // While a block is dragged by its handle, the menu must stay as it is: // the handle is the drag source, and if the menu hid or moved, React would - // remove it from the DOM, so its `dragend` (which removes the drag image) - // would never fire. The drop's document change + // remove it from the DOM, so its `dragend` (and `blockDragEnd`, which + // removes the drag image) would never fire. The drop's document change // otherwise re-resolves the block at the pointer's pre-drag position. if (menuFrozen || isDragOrigin) { // Frozen or dragging: keep the menu as it is. @@ -312,22 +306,6 @@ export const SideMenuExtension = createExtension( signal, ); - // Ends a drag of this side menu. The handle's `dragend` reaches the - // document, as the menu keeps the handle mounted while it is dragged - // (see `update`), so a custom side menu needs no wiring. - root.addEventListener( - "dragend", - () => { - if (isDragOrigin) { - endBlockDrag(); - // The menu stayed at the dragged block's old place during the - // drag. The next pointer move shows it at the block there. - hideMenu(); - } - }, - { signal, capture: true }, - ); - // Shows or updates the menu whenever the pointer moves, unless frozen. root.addEventListener( // `mousemove`, not `pointermove`: iOS Safari delivers a tap as a hover @@ -434,6 +412,17 @@ export const SideMenuExtension = createExtension( dragStart(event, block, editor); }, + /** + * 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(); + // The menu stayed at the dragged block's old place during the drag. + // The next pointer move shows it at the block there. + hideMenu(); + }, + /** * Whether the side menu is currently frozen (e.g. because the drag handle * menu is open). @@ -464,19 +453,21 @@ export const SideMenuExtension = createExtension( unfreezeMenu() { menuFrozen = false; hideMenu(); - // Closing a menu of the side menu gives the focus back to the editor, - // so the keyboard works on it right away, e.g. to undo a change made - // in the menu (#3165). Only when the focus is in the side menu (its - // menus render in it) or lost, not when it moved elsewhere, e.g. to - // an input that a menu item opened. - // In a shadow root, the document's active element is the shadow host. - const active = - editor.prosemirrorView.root.activeElement ?? document.activeElement; - const focusLost = !active || active === document.body; - const focusInSideMenu = !!menuElement && menuElement.contains(active); - if (focusLost || focusInSideMenu) { - editor.focus(); + // 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/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index f9643579c9..08b76a19da 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -1,8 +1,3 @@ -// Routes side menu drags between editors. It finds the editor that a drag event -// is for, sends drag events near an editor into that editor, and removes the -// dragged blocks from their editor after a drop in another editor. It does not -// decide where the blocks go in an editor: the `DropCursor` extension does. - import { DOMParser, Slice } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 832d5a2cef..b5e14e3165 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -1,9 +1,5 @@ -// The start of a side menu drag. It selects the dragged blocks, makes the drag -// image, and puts the blocks into the drag data. Other files handle the drag -// after `dragstart`. - 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"; @@ -195,8 +191,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/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx index 549d024685..37aa012db1 100644 --- a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx @@ -48,6 +48,8 @@ export const DragHandleButton = ( label={dict.side_menu.drag_handle_label} draggable={true} onDragStart={(e) => sideMenu.blockDragStart(e, block)} + // eslint-disable-next-line @typescript-eslint/unbound-method -- blockDragEnd is a plain object method, not a class method + onDragEnd={sideMenu.blockDragEnd} className={"bn-button"} icon={} /> diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index a8ead5bba7..454ea35c11 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -1,7 +1,3 @@ -// Drops blocks at the left or right edge of a block into a new column. It -// makes the change in one transaction and leaves the selection on the dropped -// blocks. ProseMirror and the `DropCursor` extension handle all other drops. - import type { BlockNoteEditor } from "@blocknote/core"; import { createExtension, @@ -217,8 +213,7 @@ export function createMultiColumnHandleDropPlugin( children: newChildren, }); } - // The selection on the dropped blocks, as ProseMirror's drop - // leaves it. The drop cursor then focuses the editor. + // Keep typing at the dropped blocks, not their old position. editor.setTextCursorPosition(draggedBlocks.at(-1)!, "end"); }); } else { diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts index 40982f6443..2349f3b5ff 100644 --- a/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts +++ b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts @@ -56,10 +56,7 @@ function mount() { } afterEach(() => { - // Ends the drag as the browser does, which removes the drag image. - editor.prosemirrorView.dom.parentElement!.dispatchEvent( - new DragEvent("dragend", { bubbles: true }), - ); + sideMenu().blockDragEnd(); editor._tiptapEditor.destroy(); root.remove(); }); diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index bad2d3455c..aab0581b69 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -337,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. @@ -517,11 +539,15 @@ describe.skipIf(browserName === "firefox")( 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 () => { @@ -540,49 +566,42 @@ describe.skipIf(browserName === "firefox")( await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); expect(ids(editors[0].document)).toEqual(["toggle", "dragged"]); + expectVisibleBlockSelection(["dragged"]); }); - test("shows no formatting toolbar after undoing a drop", async () => { + // 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("a"), block("b"), false); + await dragAndDropBlock(block("image"), block("b"), false); await expectElement(editors[0].prosemirrorView.dom).toHaveFocus(); - await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); - expect(ids(editors[0].document)).toEqual(["a", "b"]); + expect(ids(editors[0].document)).toEqual(["a", "b", "image"]); await waitForSelectorDetached(".bn-formatting-toolbar"); - }); - // The menu stays at the dragged block's old place during the drag. The next - // pointer move shows it again, at the block under the pointer. - test("hides the side menu after a drop", async () => { - await renderEditors([ - { id: "a", type: "paragraph", content: "A" }, - { id: "b", type: "paragraph", content: "B" }, - ]); - - await dragAndDropBlock(block("a"), block("b"), false); - await waitForSelectorDetached(".bn-side-menu"); - expect(ids(editors[0].document)).toEqual(["b", "a"]); + await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); + expect(ids(editors[0].document)).toEqual(["a", "image", "b"]); + expectVisibleBlockSelection(["image"]); }); - // A dropped block without text has no place for a text cursor. - test("undoes a drop of an image with the keyboard", async () => { + test("restores a visible multi-block selection after undoing a drop", async () => { await renderEditors([ { id: "a", type: "paragraph", content: "A" }, - { id: "image", type: "image" }, { id: "b", type: "paragraph", content: "B" }, + { id: "c", type: "paragraph", content: "C" }, ]); + editors[0].setSelection("a", "b"); - await dragAndDropBlock(block("image"), block("b"), false); + await dragAndDropBlock(block("a"), block("c"), false); + expect(ids(editors[0].document)).toEqual(["c", "a", "b"]); 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"]); + expect(ids(editors[0].document)).toEqual(["a", "b", "c"]); + expectVisibleBlockSelection(["a", "b"]); + await waitForSelectorDetached(".bn-formatting-toolbar"); }); }, ); From fa8a46d0d311bb990710433827696b8e4b40bcc2 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Oct 2026 18:09:16 +0100 Subject: [PATCH 5/5] fix(core): address nested drag selection and column drop history --- .../SideMenu/SideMenu.browser.test.ts | 42 +++++++++++++++++++ .../core/src/extensions/SideMenu/dragging.ts | 3 +- packages/xl-multi-column/package.json | 1 + .../DropCursor/multiColumnHandleDropPlugin.ts | 11 ++++- .../src/test/extensions/columnDrop.test.ts | 31 ++++++++++++++ pnpm-lock.yaml | 3 ++ 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts index 7f2fad1805..e987fa4e68 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts @@ -2,10 +2,52 @@ 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) diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index b5e14e3165..40f9e24872 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -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( 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 454ea35c11..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 @@ -214,7 +219,7 @@ export function createMultiColumnHandleDropPlugin( }); } // Keep typing at the dropped blocks, not their old position. - editor.setTextCursorPosition(draggedBlocks.at(-1)!, "end"); + editor.setTextCursorPosition(lastDraggedBlock, "end"); }); } else { // Create new columnList with blocks as columns @@ -255,10 +260,12 @@ export function createMultiColumnHandleDropPlugin( }, ], ); - editor.setTextCursorPosition(draggedBlocks.at(-1)!, "end"); + 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