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.