From 492bbd20bef87a329bf6ea98e10ba019297ecf7f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 6 Nov 2026 12:31:19 +0100 Subject: [PATCH 1/4] feat: support touch-and-hold block dragging on mobile --- packages/core/src/editor/editor.css | 102 +++- .../src/extensions/DropCursor/DropCursor.ts | 66 ++- .../FormattingToolbar/FormattingToolbar.ts | 9 +- .../core/src/extensions/SideMenu/SideMenu.ts | 47 +- .../extensions/SideMenu/blockDropHandling.ts | 11 +- .../extensions/SideMenu/dragSelection.test.ts | 225 ++++++++ .../src/extensions/SideMenu/dragSelection.ts | 86 +++ .../core/src/extensions/SideMenu/dragging.ts | 71 ++- .../touchBlockDragging.browser.test.ts | 495 ++++++++++++++++ .../extensions/SideMenu/touchBlockDragging.ts | 537 ++++++++++++++++++ .../SideMenu/touchBlockDraggingState.test.ts | 47 ++ .../SideMenu/touchBlockDraggingState.ts | 50 ++ packages/core/src/index.ts | 2 + .../core/src/util/applyBlockNoteRootTheme.ts | 29 + .../core/src/util/virtualKeyboard.test.ts | 56 ++ packages/core/src/util/virtualKeyboard.ts | 26 + .../FormattingToolbar/useVirtualKeyboard.ts | 26 +- packages/react/src/editor/BlockNoteView.tsx | 15 +- packages/react/src/editor/styles.css | 7 + .../src/end-to-end/dragdrop/dragdrop.test.tsx | 176 +++++- .../end-to-end/mobile/mobileToolbar.test.tsx | 23 + 21 files changed, 2021 insertions(+), 85 deletions(-) create mode 100644 packages/core/src/extensions/SideMenu/dragSelection.test.ts create mode 100644 packages/core/src/extensions/SideMenu/dragSelection.ts create mode 100644 packages/core/src/extensions/SideMenu/touchBlockDragging.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/touchBlockDragging.ts create mode 100644 packages/core/src/extensions/SideMenu/touchBlockDraggingState.test.ts create mode 100644 packages/core/src/extensions/SideMenu/touchBlockDraggingState.ts create mode 100644 packages/core/src/util/applyBlockNoteRootTheme.ts create mode 100644 packages/core/src/util/virtualKeyboard.test.ts create mode 100644 packages/core/src/util/virtualKeyboard.ts diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index be9ed67002..4418c4c114 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -40,25 +40,97 @@ font-size: 16px; font-weight: normal; font-family: - "Inter", - "Inter fallback", - "SF Pro Display", - -apple-system, - BlinkMacSystemFont, - "Open Sans", - "Segoe UI", - "Roboto", - "Oxygen", - "Ubuntu", - "Cantarell", - "Fira Sans", - "Droid Sans", - "Helvetica Neue", - sans-serif; + "Inter", "Inter fallback", "SF Pro Display", -apple-system, + BlinkMacSystemFont, "Open Sans", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +/* Outside text editing, a hold lifts the block instead of selecting its text. + Scrolling and pinch zoom remain native; touch handlers cancel only after lift. */ +.bn-editor.bn-touch-browse, +.bn-editor.bn-touch-browse * { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +/* Native setDragImage needs an attached, painted, offscreen snapshot. */ +.bn-drag-preview { + position: absolute; + top: -10000px; + left: 0; + margin: 0; + padding: 10px; + height: auto; + min-height: 0; + max-height: 75vh; + overflow: hidden; + background: transparent; +} + +.bn-drag-preview > .bn-editor { + background: transparent; +} + +.bn-touch-drag-source { + opacity: 0.35; +} + +.bn-drag-preview.bn-touch-drag-preview { + /* Unitless fit limits can be overridden by applications. object-fit cannot + scale an arbitrary DOM subtree; a uniform transform preserves its layout. */ + --bn-touch-drag-scale: min( + var(--bn-touch-drag-max-scale, 1.1), + calc(var(--bn-touch-drag-max-width, 242) / var(--bn-touch-drag-width)), + calc(var(--bn-touch-drag-max-height, 110) / var(--bn-touch-drag-height)) + ); + position: fixed; + top: 0; + left: 0; + max-height: none; + pointer-events: none; + transform-origin: top left; + z-index: 2147483647; + background: var(--bn-colors-editor-background); + box-shadow: + 0 0 0 2px var(--bn-colors-border), + 0 8px 24px #0004; + border-radius: 6px; + opacity: 0.9; + transform: translate( + calc( + var(--bn-touch-drag-x) - var(--bn-touch-drag-grab-x) * + var(--bn-touch-drag-scale) + ), + calc( + var(--bn-touch-drag-y) - var(--bn-touch-drag-grab-y) * + var(--bn-touch-drag-scale) + ) + ) + scale(var(--bn-touch-drag-scale)); +} + +/* Animate the contents, not the positioned preview, around the grabbed point. */ +@media (prefers-reduced-motion: no-preference) { + .bn-touch-drag-preview > .bn-editor { + animation: bn-touch-block-pickup 180ms ease-out; + transform-origin: var(--bn-touch-drag-grab-x) var(--bn-touch-drag-grab-y); + } +} + +@keyframes bn-touch-block-pickup { + from { + opacity: 0.4; + transform: scale(0.94); + } + to { + opacity: 1; + transform: none; + } +} + .bn-table-drop-cursor { position: absolute; z-index: 20; diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 15f40ec5ad..47ae716b42 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,4 +1,4 @@ -import { Plugin, PluginKey, Selection } from "prosemirror-state"; +import { Plugin, PluginKey } from "prosemirror-state"; import { dropPoint } from "prosemirror-transform"; import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; import { @@ -12,6 +12,9 @@ import { import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; import { getDropIntoChildren } from "./dropIntoChildren.js"; +import { MultipleNodeSelection } from "../SideMenu/MultipleNodeSelection.js"; +import { getNodeById } from "../../api/nodeUtil.js"; +import { readDragSelection } from "../SideMenu/dragSelection.js"; export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude"; @@ -73,7 +76,9 @@ export const DropCursorExtension = createExtension< let timeout = -1; let dragSourceElement: Element | null = null; // Set when blocks are dropped in this editor, until the drag ends. - let blocksDropped = false; + let pendingDrop: + | { selection: ReturnType; touch: boolean } + | undefined; let focusTimeout: ReturnType | undefined; const config = { @@ -165,7 +170,7 @@ export const DropCursorExtension = createExtension< 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; + pendingDrop = undefined; clearTimeout(focusTimeout); }; @@ -277,14 +282,19 @@ export const DropCursorExtension = createExtension< const onDrop = (event: Event) => { scheduleRemoval(20); // Only block drags carry `blocknote/html`, not text drags. - blocksDropped = + const transfer = (event as DragEvent).dataTransfer; + pendingDrop = editor.prosemirrorView.editable && - ((event as DragEvent).dataTransfer?.types.includes("blocknote/html") ?? - false); + transfer?.types.includes("blocknote/html") + ? { + selection: readDragSelection(transfer), + touch: transfer.getData("blocknote/touch-drag") === "true", + } + : undefined; // 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) { + if (pendingDrop) { focusTimeout = setTimeout(onRootDragEnd, 0); } }; @@ -295,27 +305,36 @@ export const DropCursorExtension = createExtension< }; /** - * Leaves the editor ready for the keyboard after blocks were dropped in it. + * Restores the selection category and appropriate focus after a block drop. * 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() { + // Sources outside the editable DOM (handles and touch lifts) do not reach + // its dragend listener. Cancellation must clear feedback even without drop. + onDragEnd(); clearTimeout(focusTimeout); - if (!blocksDropped) { + const dropped = pendingDrop; + pendingDrop = undefined; + if (!dropped) { 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. + // Focus is needed for undo (#3165), not a reason to change selection type. + const selection = view.state.selection; view.dispatch( - view.state.tr.setSelection(Selection.near(view.state.selection.$to, -1)), + view.state.tr.setSelection( + dropped.selection.restore(view.state.doc, selection), + ), ); - view.focus(); + // Focusing contenteditable opens the software keyboard even for a block + // selection. A touch block drop is manipulation, not entry into text editing. + if (dropped.selection.type !== "block" || !dropped.touch) { + view.focus(); + } } // Drops the blocks where the cursor shows them: as the first children of @@ -386,8 +405,20 @@ 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"); + // Mark the inserted block range after the content changes, in the + // same undo operation. Drag completion restores the bookmark once; + // restoring here too would lose descendant selections on completion. + editor.transact((tr) => { + const first = getNodeById(draggedBlocks[0].id, tr.doc)!; + const last = getNodeById(draggedBlocks.at(-1).id, tr.doc)!; + tr.setSelection( + MultipleNodeSelection.create( + tr.doc, + first.posBeforeNode, + last.posBeforeNode + last.node.nodeSize, + ), + ); + }); }); return true; }, @@ -412,7 +443,6 @@ export const DropCursorExtension = createExtension< dom.addEventListener("dragover", onDragOver, { signal }); dom.addEventListener("dragleave", onDragLeave, { signal }); dom.addEventListener("drop", onDrop, { signal }); - dom.addEventListener("dragend", onDragEnd, { signal }); // Clean up on unmount signal.addEventListener("abort", () => { diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts index 3d45b3a651..dffd5bcd9a 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts @@ -95,7 +95,7 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { (_editor, { focused }) => { if (!focused) { store.setState(false); - // On touch, leaving the editor also ends the selection. A phone + // On touch, leaving the editor also ends a text selection. A phone // can hand focus back without the keyboard (see the window blur // listener below): the old selection then showed the desktop // toolbar, and without the keyboard no tap cleared it. A tablet @@ -103,7 +103,12 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { // too, unlike a desktop. if (isTouchDevice()) { editor.transact((tr) => { - if (!tr.selection.empty) { + // Block selections survive manipulation without opening the + // software keyboard, including a touch drag-and-drop. + if ( + tr.selection.toJSON().type === "text" && + !tr.selection.empty + ) { tr.setSelection(Selection.near(tr.selection.$head)); } }); diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 84c5e6c59c..197a71ffb6 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -14,6 +14,10 @@ import { } from "../../schema/index.js"; import { registerBlockDropHandlers } from "./blockDropHandling.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + createTouchBlockDraggingPlugin, + registerTouchBlockDragging, +} from "./touchBlockDragging.js"; import { HoveredBlock, isHeadingToMenu, @@ -41,6 +45,8 @@ export type SideMenuState< * padding, where the menu sits) always counts. */ export type SideMenuOptions = { + /** Allow holding a block's surface to lift it outside text editing. @default true */ + touchBlockDragging?: boolean; /** * How far left or right of the editor element the pointer still shows the * side menu, for the block at the pointer's height. Above or below the @@ -108,6 +114,7 @@ export const SideMenuExtension = createExtension( let menuElement: HTMLElement | undefined; // The last pointer position over this document. let pointer: Point | undefined; + let touchInput = false; // The last pointer position over the active block (or the menu). let lastOnActive: Point | undefined; // Set while the pointer is heading from the active block to the menu. @@ -228,8 +235,8 @@ export const SideMenuExtension = createExtension( } } - function endBlockDrag() { - unsetDragImage(editor.prosemirrorView.root); + function endBlockDrag(root = editor.prosemirrorView.root) { + unsetDragImage(root); isDragOrigin = false; } @@ -278,6 +285,7 @@ export const SideMenuExtension = createExtension( key: "sideMenu", store, prosemirrorPlugins: [ + createTouchBlockDraggingPlugin(() => editor.isEditable), new Plugin({ key: sideMenuPluginKey, appendTransaction: (transactions, _oldState, newState) => { @@ -305,6 +313,40 @@ export const SideMenuExtension = createExtension( options?.sideMenu?.dropMargin ?? 250, signal, ); + if (options?.sideMenu?.touchBlockDragging !== false) { + registerTouchBlockDragging({ + view: editor.prosemirrorView, + isEditable: () => editor.isEditable, + canDrag: (id) => { + const block = editor.getBlock(id); + return !!block && isDraggable(block.type); + }, + startDrag: (event, id) => { + const block = editor.getBlock(id); + if (!block) { + return; + } + isDragOrigin = true; + dragStart(event, block, editor); + }, + endDrag: () => { + // The mount signal can abort after TipTap has removed its view. + // Use the captured root, not editor.prosemirrorView, for cleanup. + endBlockDrag(root); + hideMenu(); + }, + signal, + }); + } + // Native drag events do not expose their pointer type. Remember the + // initiating input only to avoid opening the keyboard on a touch drop. + root.addEventListener( + "pointerdown", + (event) => { + touchInput = (event as PointerEvent).pointerType === "touch"; + }, + { signal, capture: true, passive: true }, + ); // Shows or updates the menu whenever the pointer moves, unless frozen. root.addEventListener( @@ -410,6 +452,7 @@ export const SideMenuExtension = createExtension( ) { isDragOrigin = true; dragStart(event, block, editor); + event.dataTransfer?.setData("blocknote/touch-drag", String(touchInput)); }, /** diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 08b76a19da..c08f66cf40 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -36,6 +36,8 @@ function findDropEditor( ? [target] : Array.from(view.root.querySelectorAll(".bn-editor")).filter( (editor) => + // The floating preview clones editor markup, but owns no EditorView. + !editor.closest(".bn-drag-preview") && distanceToRect(point, editor.getBoundingClientRect()) <= dropMargin, ); const coords = event; @@ -121,14 +123,19 @@ export function registerBlockDropHandlers( const dropPointBoundingBox = ( view.dom.firstChild as HTMLElement ).getBoundingClientRect(); + // Firefox can resolve the exact right/bottom edge to the document's end. + // Preserve the left/top boundary: it distinguishes dropping beside a layout + // from dropping inside its first column. + const insetX = Math.min(1, dropPointBoundingBox.width / 2); + const insetY = Math.min(1, dropPointBoundingBox.height / 2); const evt = Object.assign(new Event(event.type, event), { clientX: Math.min( Math.max(event.clientX, dropPointBoundingBox.left), - dropPointBoundingBox.left + dropPointBoundingBox.width, + dropPointBoundingBox.right - insetX, ), clientY: Math.min( Math.max(event.clientY, dropPointBoundingBox.top), - dropPointBoundingBox.top + dropPointBoundingBox.height, + dropPointBoundingBox.bottom - insetY, ), dataTransfer: event.dataTransfer, preventDefault: () => event.preventDefault(), diff --git a/packages/core/src/extensions/SideMenu/dragSelection.test.ts b/packages/core/src/extensions/SideMenu/dragSelection.test.ts new file mode 100644 index 0000000000..1fa5c1c763 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/dragSelection.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment node +import type { Node } from "prosemirror-model"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import { getNodeById } from "../../api/nodeUtil.js"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { MultipleNodeSelection } from "./MultipleNodeSelection.js"; +import { captureDragSelection, readDragSelection } from "./dragSelection.js"; + +const editors: BlockNoteEditor[] = []; + +function block(id: string, text = "abcdef") { + return { id, type: "paragraph" as const, content: text }; +} + +function document(...blocks: PartialBlock[]) { + const editor = BlockNoteEditor.create({ initialContent: blocks }); + editors.push(editor); + return editor.prosemirrorState.doc; +} + +function transfer() { + const data = new Map(); + return { + setData(type: string, value: string) { + data.set(type, value); + }, + getData(type: string) { + return data.get(type) ?? ""; + }, + }; +} + +let source: Node; +let a: Node; +let b: Node; +let dragged: MultipleNodeSelection; + +beforeEach(() => { + source = document(block("a"), block("b")); + a = getNodeById("a", source)!.node; + b = getNodeById("b", source)!.node; + dragged = MultipleNodeSelection.create(source, 1, 1 + a.nodeSize); +}); + +afterEach(() => { + for (const editor of editors) { + editor._tiptapEditor.destroy(); + } + editors.length = 0; + vi.unstubAllGlobals(); +}); + +describe("drag selection bookmarks", () => { + it("captures and restores on HTTP LAN origins without crypto.randomUUID", () => { + // getRandomValues is available in insecure contexts; randomUUID is not. + vi.stubGlobal("crypto", { + getRandomValues: crypto.getRandomValues.bind(crypto), + }); + const data = transfer(); + const selection = TextSelection.create(source, 4, 6); + captureDragSelection(data, selection, dragged); + expect(readDragSelection(data).restore(source, dragged).toJSON()).toEqual( + selection.toJSON(), + ); + }); + + it("maps a backwards multi-block text selection when moved earlier", () => { + const original = document(block("c"), block("a"), block("b")); + const c = getNodeById("c", original)!.node; + const selection = TextSelection.create( + original, + c.nodeSize + a.nodeSize + 6, + c.nodeSize + 4, + ); + const wholeBlocks = MultipleNodeSelection.create( + original, + 1 + c.nodeSize, + 1 + c.nodeSize + a.nodeSize + b.nodeSize, + ); + const data = transfer(); + captureDragSelection(data, selection, wholeBlocks); + const target = document(block("a"), block("b"), block("c")); + const fallback = MultipleNodeSelection.create( + target, + 1, + 1 + a.nodeSize + b.nodeSize, + ); + expect(readDragSelection(data).restore(target, fallback).toJSON()).toEqual({ + type: "text", + anchor: a.nodeSize + 6, + head: 4, + }); + }); + + it.each([ + [4, 4], + [4, 7], + [7, 4], + ])("preserves text anchor %i and head %i when moved", (anchor, head) => { + const data = transfer(); + captureDragSelection( + data, + TextSelection.create(source, anchor, head), + dragged, + ); + const target = document(block("b"), block("a")); + const fallback = MultipleNodeSelection.create( + target, + 1 + b.nodeSize, + 1 + b.nodeSize + a.nodeSize, + ); + const restored = readDragSelection(data).restore(target, fallback); + expect(restored.toJSON()).toEqual({ + type: "text", + anchor: anchor + b.nodeSize, + head: head + b.nodeSize, + }); + }); + + it("restores the original node selection rather than converting its type", () => { + const data = transfer(); + captureDragSelection(data, NodeSelection.create(source, 1), dragged); + const target = document(block("b"), block("a")); + const restored = readDragSelection(data).restore( + target, + TextSelection.create(target, 3 + b.nodeSize), + ); + expect(restored.toJSON()).toEqual({ type: "node", anchor: 1 + b.nodeSize }); + }); + + it("restores a whole-block bookmark after nesting the block", () => { + const data = transfer(); + captureDragSelection(data, dragged, dragged); + const target = document({ + id: "parent", + type: "paragraph", + content: "Parent", + children: [block("a")], + }); + const start = getNodeById("a", target)!.posBeforeNode; + expect( + readDragSelection(data) + .restore(target, TextSelection.create(target, start + 2)) + .toJSON(), + ).toEqual({ + type: "multiple-node", + anchor: start, + head: start + a.nodeSize, + }); + }); + + it("does not relocate a caret outside the dragged block", () => { + const data = transfer(); + captureDragSelection( + data, + TextSelection.create(source, 3 + a.nodeSize), + dragged, + ); + expect(data.getData("blocknote/selection-bookmark")).toBe(""); + expect(readDragSelection(data).type).toBe("text"); + }); + + it("falls back to the transferred category without a matching local bookmark", () => { + const data = transfer(); + data.setData("blocknote/selection-type", "block"); + data.setData("blocknote/selection-bookmark", "another-realm"); + expect(readDragSelection(data).restore(source, dragged).toJSON()).toEqual( + dragged.toJSON(), + ); + }); + + it("falls back safely when schema conversion changes the block span", () => { + const data = transfer(); + captureDragSelection(data, TextSelection.create(source, 7, 4), dragged); + const target = document(block("a", "x")); + const fallback = MultipleNodeSelection.create( + target, + 1, + target.content.size - 1, + ); + expect( + readDragSelection(data) + .restore(target, fallback) + .eq(TextSelection.near(fallback.$to, -1)), + ).toBe(true); + }); + + it("expands a multi-block text fallback to whole blocks", () => { + const data = transfer(); + data.setData("blocknote/selection-type", "block"); + const fallback = TextSelection.create(source, 3, 3 + a.nodeSize); + const restored = readDragSelection(data).restore(source, fallback); + expect(restored.toJSON()).toEqual({ + type: "multiple-node", + anchor: 1, + head: 1 + a.nodeSize + b.nodeSize, + }); + expect(restored.content().content.child(0).type.name).toBe( + "blockContainer", + ); + }); + + it("restores into the inserted range, not existing matching IDs", () => { + const data = transfer(); + captureDragSelection(data, TextSelection.create(source, 4), dragged); + const target = document(block("a"), block("new-a")); + const inserted = getNodeById("new-a", target)!; + const fallback = TextSelection.create(target, inserted.posBeforeNode + 2); + expect(readDragSelection(data).restore(target, fallback).toJSON()).toEqual({ + type: "text", + anchor: inserted.posBeforeNode + 3, + head: inserted.posBeforeNode + 3, + }); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/dragSelection.ts b/packages/core/src/extensions/SideMenu/dragSelection.ts new file mode 100644 index 0000000000..406c6d4bdf --- /dev/null +++ b/packages/core/src/extensions/SideMenu/dragSelection.ts @@ -0,0 +1,86 @@ +import type { Node } from "prosemirror-model"; +import { Selection, type SelectionBookmark } from "prosemirror-state"; +import { StepMap } from "prosemirror-transform"; + +import { CHILD_CONTAINER_GROUP } from "../../schema/blocks/children.js"; +import { UniqueID } from "../tiptap-extensions/UniqueID/UniqueID.js"; +import { MultipleNodeSelection } from "./MultipleNodeSelection.js"; + +type DragBookmark = { + token: string; + bookmark: SelectionBookmark; + from: number; + size: number; +}; + +// Bookmarks contain methods, so they cannot travel in DataTransfer. Retain only +// the latest drag in this realm and use a transfer token to identify it. Drops +// into another document/realm still carry the serializable category fallback. +let dragBookmark: DragBookmark | undefined; + +export function captureDragSelection( + dataTransfer: Pick, + selection: Selection, + draggedSelection: Selection, +) { + dataTransfer.setData( + "blocknote/selection-type", + selection.toJSON().type === "text" ? "text" : "block", + ); + dragBookmark = undefined; + // A caret/selection in an unrelated block must not be transplanted into the + // dragged content. In that case retain the previous end-of-drop fallback. + if ( + draggedSelection.empty || + selection.from < draggedSelection.from || + selection.to > draggedSelection.to + ) { + return; + } + const token = UniqueID.options.generateID(); + dragBookmark = { + token, + bookmark: selection.getBookmark(), + from: draggedSelection.from, + size: draggedSelection.to - draggedSelection.from, + }; + dataTransfer.setData("blocknote/selection-bookmark", token); +} + +export function readDragSelection( + dataTransfer: Pick | null, +) { + const type: "text" | "block" = + dataTransfer?.getData("blocknote/selection-type") === "block" + ? "block" + : "text"; + const saved = + dragBookmark?.token === + dataTransfer?.getData("blocknote/selection-bookmark") + ? dragBookmark + : undefined; + + return { type, restore }; + + function restore(doc: Node, fallback: Selection): Selection { + // ProseMirror may select text inside the dropped blocks. Expand to their + // outer boundaries, including within layout blocks and nested block groups. + // Use the inserted range, not IDs: cross-editor drops can regenerate IDs or + // collide with IDs already present in the destination document. + const range = fallback.$from.blockRange(fallback.$to, (node) => + node.type.isInGroup(CHILD_CONTAINER_GROUP), + ); + // A schema conversion may reshape transferred content. Only translate + // offsets when its span is unchanged; otherwise use a valid fallback. + if (saved && range && range.end - range.start === saved.size) { + // Mapping through the deletion would collapse positions inside the + // source. Translate the bookmark directly to the relocated block span. + return saved.bookmark + .map(StepMap.offset(range.start - saved.from)) + .resolve(doc); + } + return type === "block" && range + ? MultipleNodeSelection.create(doc, range.start, range.end) + : Selection.near(fallback.$to, -1); + } +} diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 40f9e24872..1d1498f5ee 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -8,14 +8,16 @@ import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js" import { getNodeById } from "../../api/nodeUtil.js"; import { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { applyBlockNoteRootTheme } from "../../util/applyBlockNoteRootTheme.js"; import { BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; import { MultipleNodeSelection } from "./MultipleNodeSelection.js"; +import { captureDragSelection } from "./dragSelection.js"; -let dragImageElement: Element | undefined; +let dragImageElement: HTMLElement | undefined; function blockPositionsFromSelection(selection: Selection, doc: Node) { // Absolute positions just before the first block spanned by the selection, and just after the last block. Having the @@ -92,6 +94,16 @@ function setDragImage(view: EditorView, from: number, to = from) { const preview = (container?.cloneNode(false) as HTMLElement | undefined) ?? document.createElement("div"); + // Keep skin-specific attributes from the clone, but apply only its theme, + // not application layout. React's portal roots use the same theme contract. + preview.removeAttribute("style"); + if (container instanceof HTMLElement) { + applyBlockNoteRootTheme(preview, { + className: container.className, + colorScheme: container.getAttribute("data-color-scheme"), + style: container.style, + }); + } preview.removeAttribute("id"); preview.classList.add("bn-drag-preview"); const editor = document.createElement("div"); @@ -99,27 +111,11 @@ function setDragImage(view: EditorView, from: number, to = from) { editor.appendChild(blocks); preview.appendChild(editor); - // The layout is inline, so that app CSS on the copied classes (e.g. a height - // on `.bn-editor`) does not change it. The preview is off-screen, as the - // browser only needs it for its snapshot: `opacity` would also hide it in - // the snapshot in Firefox and Safari. Taller than the cap, it would reach - // into the page from above. - Object.assign(preview.style, { - background: "transparent", - position: "absolute", - top: "-10000px", - left: "0", - margin: "0", - padding: "10px", - height: "auto", - minHeight: "0", - maxHeight: "75vh", - overflow: "hidden", - }); + // Isolate the snapshot's content box from application editor layout rules, + // regardless of stylesheet order. Appearance and native/touch positioning stay + // in CSS so applications can theme the preview without changing its geometry. Object.assign(editor.style, { - // Keep the editor's background out of the empty space around the blocks. - background: "transparent", - width: `${(view.nodeDOM(from) as HTMLElement).offsetWidth}px`, + width: "var(--bn-drag-source-width)", height: "auto", minHeight: "0", maxHeight: "none", @@ -127,6 +123,10 @@ function setDragImage(view: EditorView, from: number, to = from) { padding: "0", overflow: "visible", }); + preview.style.setProperty( + "--bn-drag-source-width", + `${(view.nodeDOM(from) as HTMLElement).offsetWidth}px`, + ); // dataTransfer.setDragImage(element) only works if element is attached to the DOM. unsetDragImage(view.root); @@ -150,6 +150,34 @@ export function unsetDragImage(rootEl: Document | ShadowRoot) { } } +/** Reuse the themed native preview as a small, non-interactive touch thumbnail. */ +export function moveTouchDragImage(x: number, y: number, source: HTMLElement) { + const preview = dragImageElement; + if (!preview) { + return; + } + if (!preview.classList.contains("bn-touch-drag-preview")) { + preview.classList.add("bn-touch-drag-preview"); + // Keep the grabbed point under the finger even when a large block is scaled + // down. Cache it before scrolling changes the source's position. + const rect = source.getBoundingClientRect(); + const offsetX = ((x - rect.left) / rect.width) * preview.offsetWidth; + const offsetY = ((y - rect.top) / rect.height) * preview.offsetHeight; + preview.style.setProperty( + "--bn-touch-drag-width", + String(preview.offsetWidth), + ); + preview.style.setProperty( + "--bn-touch-drag-height", + String(preview.offsetHeight), + ); + preview.style.setProperty("--bn-touch-drag-grab-x", `${offsetX}px`); + preview.style.setProperty("--bn-touch-drag-grab-y", `${offsetY}px`); + } + preview.style.setProperty("--bn-touch-drag-x", `${x}px`); + preview.style.setProperty("--bn-touch-drag-y", `${y}px`); +} + export function dragStart< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -215,6 +243,7 @@ export function dragStart< const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); + captureDragSelection(e.dataTransfer, selection, view.state.selection); e.dataTransfer.setData("blocknote/html", clipboardHTML); // The type of the dragged blocks' parent. The editors parse the blocks // into it (see `blockDropHandling.ts`), so that a block that only this diff --git a/packages/core/src/extensions/SideMenu/touchBlockDragging.browser.test.ts b/packages/core/src/extensions/SideMenu/touchBlockDragging.browser.test.ts new file mode 100644 index 0000000000..7174c75f1a --- /dev/null +++ b/packages/core/src/extensions/SideMenu/touchBlockDragging.browser.test.ts @@ -0,0 +1,495 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import "../../style.css"; +import "../../../../react/src/editor/styles.css"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; + +// Synthetic touch events exercise our recognizer and real editor/drop pipeline; +// viewport changes below are mocked inputs to our keyboard heuristic. These +// tests do not establish native scrolling, iOS selection-handle suppression, or +// software-keyboard behavior. Those require simulator/physical-device input. +let editor: BlockNoteEditor; +let root: HTMLDivElement; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + root = document.createElement("div"); + root.className = "bn-root"; + root.style.cssText = "width:400px;height:300px;overflow:auto"; + document.body.append(root); + editor = BlockNoteEditor.create({ + initialContent: [ + { + id: "a", + type: "heading", + props: { level: 1 }, + content: "A large heading", + }, + { id: "b", type: "paragraph", content: "Second paragraph" }, + { id: "c", type: "paragraph", content: "Third paragraph" }, + ], + }); + editor.mount(root); +}); + +afterEach(() => { + editor._tiptapEditor.destroy(); + root.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function content(id = "a") { + return root.querySelector( + `[data-id="${id}"] .bn-block-content`, + )!; +} + +function point(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + return { + clientX: rect.left + 30, + clientY: rect.top + rect.height / 2, + identifier: 1, + }; +} + +function touch( + type: string, + target: HTMLElement, + location = point(target), + count = 1, +) { + const event = Object.assign( + new Event(type, { bubbles: true, cancelable: true }), + { + touches: + type === "touchend" || type === "touchcancel" + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...location, + identifier: i + 1, + })), + changedTouches: [location], + }, + ); + target.dispatchEvent(event); + return event; +} + +it("lifts a heading after a hold with a bounded thumbnail, preserving edit permission", async () => { + const target = content(); + touch("touchstart", target); + expect(editor.isEditable).toBe(true); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + await vi.advanceTimersByTimeAsync(449); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + await vi.advanceTimersByTimeAsync(2); + const preview = document.querySelector(".bn-drag-preview")!; + expect(preview).not.toBeNull(); + expect(getComputedStyle(preview).pointerEvents).toBe("none"); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + const rect = preview.getBoundingClientRect(); + expect(rect.width).toBeGreaterThan(0); + expect(rect.width).toBeLessThanOrEqual(242.1); + expect(rect.height).toBeLessThanOrEqual(110.1); + const source = target.closest(".bn-block-outer")!; + const sourceRect = source.getBoundingClientRect(); + const pickup = point(target); + expect( + rect.left + + ((pickup.clientX - sourceRect.left) / sourceRect.width) * rect.width, + ).toBeCloseTo(pickup.clientX, 1); + expect( + rect.top + + ((pickup.clientY - sourceRect.top) / sourceRect.height) * rect.height, + ).toBeCloseTo(pickup.clientY, 1); + const beforeMove = preview.getBoundingClientRect(); + touch("touchmove", target, { + ...pickup, + clientX: pickup.clientX + 20, + clientY: pickup.clientY + 10, + }); + const afterMove = preview.getBoundingClientRect(); + expect(afterMove.left - beforeMove.left).toBeCloseTo(20, 1); + expect(afterMove.top - beforeMove.top).toBeCloseTo(10, 1); + touch("touchcancel", target); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + expect(editor.prosemirrorView.dragging).toBeNull(); + expect(editor.prosemirrorView.hasFocus()).toBe(false); +}); + +it("prevents selectstart during a lift but leaves it unclaimed while editing", async () => { + const target = content(); + touch("touchstart", target); + await vi.advanceTimersByTimeAsync(451); + const selectionStart = new Event("selectstart", { + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(selectionStart); + expect(selectionStart.defaultPrevented).toBe(true); + touch("touchcancel", target); + touch("touchstart", target); + touch("touchend", target); + const editingSelectionStart = new Event("selectstart", { + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(editingSelectionStart); + expect(editingSelectionStart.defaultPrevented).toBe(false); + expect(editor.prosemirrorView.dom.contentEditable).toBe("true"); +}); + +it("disables DOM editing before contact and after blur on coarse pointers", () => { + editor._tiptapEditor.destroy(); + const matchMedia = window.matchMedia.bind(window); + vi.spyOn(window, "matchMedia").mockImplementation((query) => { + const result = matchMedia(query); + if (query === "(pointer: coarse)") { + Object.defineProperty(result, "matches", { value: true }); + } + return result; + }); + editor = BlockNoteEditor.create({ + initialContent: [{ id: "a", type: "paragraph", content: "Touch browsing" }], + }); + editor.mount(root); + const dom = editor.prosemirrorView.dom; + expect(dom.contentEditable).toBe("false"); + expect(editor.isEditable).toBe(true); + touch("touchstart", content()); + touch("touchend", content()); + expect(dom.contentEditable).toBe("true"); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + dom.blur(); + expect(dom.contentEditable).toBe("false"); +}); + +it("leaves pre-hold movement unprevented and cancels pickup", async () => { + const target = content(); + touch("touchstart", target); + const event = touch("touchmove", target, { + ...point(target), + clientY: point(target).clientY + 20, + }); + expect(event.defaultPrevented).toBe(false); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + expect(editor.document.map((block) => block.id)).toEqual(["a", "b", "c"]); +}); + +it("a tap enters text editing and a subsequent hold does not start block dragging", async () => { + const target = content("b"); + touch("touchstart", target); + touch("touchend", target); + expect(editor.prosemirrorView.dom.contentEditable).toBe("true"); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + expect(editor.prosemirrorView.state.selection.toJSON().type).toBe("text"); + touch("touchstart", target); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); +}); + +it("moves the actual held block without regenerating IDs or opening text editing", async () => { + const before = editor.document; + const source = content("c"); + touch("touchstart", source); + await vi.advanceTimersByTimeAsync(451); + const destination = { + ...point(content("a")), + clientY: content("a").getBoundingClientRect().top + 1, + }; + expect(touch("touchmove", source, destination).defaultPrevented).toBe(true); + touch("touchend", source, destination); + expect(editor.document).toEqual([before[2], before[0], before[1]]); + expect(editor.prosemirrorView.hasFocus()).toBe(false); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + expect(editor.prosemirrorView.state.selection.toJSON().type).toBe("node"); +}); + +it("a second finger cancels the hold without preventing the touch event", async () => { + const target = content(); + touch("touchstart", target); + expect(touch("touchstart", target, point(target), 2).defaultPrevented).toBe( + false, + ); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); +}); + +it("a second finger outside the editor cancels an active lift", async () => { + touch("touchstart", content()); + await vi.advanceTimersByTimeAsync(451); + expect(document.querySelector(".bn-drag-preview")).not.toBeNull(); + expect(touch("touchstart", root, point(root), 2).defaultPrevented).toBe( + false, + ); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + expect(editor.prosemirrorView.dragging).toBeNull(); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); +}); + +it.each(["a", "button", "input", "div"])( + "does not lift an interactive %s element", + async (tag) => { + const control = document.createElement(tag); + if (tag === "div") { + control.contentEditable = "true"; + } + content().append(control); + touch("touchstart", control); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + touch("touchend", control); + expect(editor.prosemirrorView.hasFocus()).toBe(false); + }, +); + +it("does not lift read-only content", async () => { + editor.isEditable = false; + touch("touchstart", content()); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); +}); + +it("allows tapping non-draggable blocks into editing without lifting them", async () => { + editor._tiptapEditor.destroy(); + const matchMedia = window.matchMedia.bind(window); + vi.spyOn(window, "matchMedia").mockImplementation((query) => { + const result = matchMedia(query); + if (query === "(pointer: coarse)") { + Object.defineProperty(result, "matches", { value: true }); + } + return result; + }); + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + paragraph: { + ...defaultBlockSpecs.paragraph, + implementation: { + ...defaultBlockSpecs.paragraph.implementation, + meta: { draggable: false }, + }, + }, + }, + }), + initialContent: [ + { id: "a", type: "paragraph", content: "Editable, not draggable" }, + ], + }); + editor.mount(root); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + touch("touchstart", content()); + await vi.advanceTimersByTimeAsync(451); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + touch("touchend", content()); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + expect(editor.prosemirrorView.dom.contentEditable).toBe("true"); +}); + +it("routes a touch drop inside dropMargin to the real editor, not its preview", async () => { + const before = editor.document; + const source = content("c"); + touch("touchstart", source); + await vi.advanceTimersByTimeAsync(451); + const destination = { + ...point(source), + clientX: root.getBoundingClientRect().right + 20, + // Aim below the heading, so all engines resolve the same insertion side + // rather than choosing between its start/end at the far right of its line. + clientY: content("a").getBoundingClientRect().bottom - 1, + }; + touch("touchmove", source, destination); + touch("touchend", source, destination); + expect(editor.document).toEqual([before[0], before[2], before[1]]); +}); + +it("keeps browsing across document updates without overriding application read-only permission", () => { + touch("touchstart", content()); + touch("touchcancel", content()); + editor.insertBlocks( + [{ type: "paragraph", content: "Remote update" }], + "c", + "after", + ); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + expect(editor.isEditable).toBe(true); + editor.isEditable = false; + content().dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + expect(editor.isEditable).toBe(false); +}); + +it("keeps mouse editing focused after a desktop-sized viewport shrink and recovery", () => { + const viewport = window.visualViewport!; + const height = viewport.height; + const mockHeight = vi + .spyOn(viewport, "height", "get") + .mockReturnValue(height); + content().dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + editor.prosemirrorView.focus(); + mockHeight.mockReturnValue(height - 300); + viewport.dispatchEvent(new Event("resize")); + mockHeight.mockReturnValue(height); + viewport.dispatchEvent(new Event("resize")); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + expect(editor.prosemirrorView.dom.contentEditable).toBe("true"); +}); + +it("isolates preview sizing from later application editor layout rules", async () => { + // Supply the outer themed root normally provided by UI integrations. + const themedRoot = document.createElement("div"); + themedRoot.className = "bn-root"; + root.before(themedRoot); + themedRoot.append(root); + const style = document.createElement("style"); + style.textContent = + ".bn-root .bn-editor { min-height: 500px; padding: 40px; }"; + try { + touch("touchstart", content()); + await vi.advanceTimersByTimeAsync(451); + document.head.append(style); + const previewEditor = document.querySelector( + ".bn-drag-preview > .bn-editor", + )!; + expect(previewEditor.matches(".bn-root .bn-editor")).toBe(true); + expect(getComputedStyle(previewEditor).minHeight).toBe("0px"); + expect(getComputedStyle(previewEditor).padding).toBe("0px"); + } finally { + touch("touchcancel", content()); + style.remove(); + themedRoot.replaceWith(root); + } +}); + +it.each([false, true])( + "clears drop feedback when a touch lift over a toggle (%s) is canceled", + async (toggle) => { + if (toggle) { + editor.updateBlock("b", { type: "toggleListItem" }); + } + const source = content("c"); + touch("touchstart", source); + await vi.advanceTimersByTimeAsync(451); + const destination = point(content("b")); + touch("touchmove", source, { + ...destination, + clientY: toggle + ? destination.clientY + : content("b").getBoundingClientRect().bottom - 1, + }); + const feedback = '[data-drop-target], [class*="prosemirror-dropcursor-"]'; + expect(document.querySelector(feedback)).not.toBeNull(); + touch("touchcancel", source); + await vi.advanceTimersByTimeAsync(21); + expect(document.querySelector(feedback)).toBeNull(); + }, +); + +it("disables DOM editing after a mocked viewport shrink and recovery", () => { + const viewport = window.visualViewport!; + const height = viewport.height; + const mockHeight = vi + .spyOn(viewport, "height", "get") + .mockReturnValue(height); + const target = content("b"); + touch("touchstart", target); + touch("touchend", target); + mockHeight.mockReturnValue(height - 300); + viewport.dispatchEvent(new Event("resize")); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + mockHeight.mockReturnValue(height); + viewport.dispatchEvent(new Event("resize")); + expect(editor.prosemirrorView.hasFocus()).toBe(false); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + expect(editor.isEditable).toBe(true); +}); + +it("keeps DOM editing enabled for small or zoomed mocked viewport resizes", () => { + const viewport = window.visualViewport!; + const height = viewport.height; + const mockHeight = vi + .spyOn(viewport, "height", "get") + .mockReturnValue(height); + const mockScale = vi.spyOn(viewport, "scale", "get").mockReturnValue(1); + const target = content("b"); + touch("touchstart", target); + touch("touchend", target); + mockHeight.mockReturnValue(height - 50); + viewport.dispatchEvent(new Event("resize")); + mockHeight.mockReturnValue(height); + viewport.dispatchEvent(new Event("resize")); + expect(editor.prosemirrorView.hasFocus()).toBe(true); + mockScale.mockReturnValue(2); + mockHeight.mockReturnValue(height / 2); + viewport.dispatchEvent(new Event("resize")); + mockScale.mockReturnValue(1); + mockHeight.mockReturnValue(height); + viewport.dispatchEvent(new Event("resize")); + expect(editor.prosemirrorView.hasFocus()).toBe(true); +}); + +it("restores DOM editing for mouse input after touch browsing", () => { + touch("touchstart", content()); + touch("touchcancel", content()); + expect(editor.prosemirrorView.dom.contentEditable).toBe("false"); + content().dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + expect(editor.prosemirrorView.dom.contentEditable).toBe("true"); +}); + +it("scrolls at the visible edge while lifted, then stops on cancellation", async () => { + editor.insertBlocks( + Array.from({ length: 20 }, (_, i) => ({ + type: "paragraph" as const, + content: `Block ${i}`, + })), + "c", + "after", + ); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + const cancel = vi.spyOn(window, "cancelAnimationFrame"); + const target = content(); + touch("touchstart", target); + await vi.advanceTimersByTimeAsync(451); + touch("touchmove", target, { + ...point(target), + clientY: root.getBoundingClientRect().bottom - 1, + }); + frames.shift()!(0); + expect(root.scrollTop).toBeGreaterThan(0); + touch("touchcancel", target); + expect(cancel).toHaveBeenCalled(); + const scrollTop = root.scrollTop; + frames.shift()!(16); + expect(root.scrollTop).toBe(scrollTop); +}); + +it.each([100, 451])( + "cleans up on unmount after %i ms without a delayed lift", + async (delay) => { + touch("touchstart", content()); + await vi.advanceTimersByTimeAsync(delay); + expect(document.querySelector(".bn-drag-preview") !== null).toBe( + delay >= 450, + ); + editor.unmount(); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + await vi.advanceTimersByTimeAsync(500); + expect(document.querySelector(".bn-drag-preview")).toBeNull(); + }, +); diff --git a/packages/core/src/extensions/SideMenu/touchBlockDragging.ts b/packages/core/src/extensions/SideMenu/touchBlockDragging.ts new file mode 100644 index 0000000000..82923b0700 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/touchBlockDragging.ts @@ -0,0 +1,537 @@ +import { + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { getNodeById } from "../../api/nodeUtil.js"; +import { moveTouchDragImage } from "./dragging.js"; +import { + edgeScrollSpeed, + keyboardAfterResize, + type Keyboard, +} from "./touchBlockDraggingState.js"; + +const browseKey = new PluginKey("touchBlockBrowsing"); +const holdDelay = 450; +const holdMovementTolerance = 8; + +/** Browsing changes native touch editing, not the application's edit permission. */ +export function createTouchBlockDraggingPlugin(isEditable: () => boolean) { + function syncDOM(view: EditorView) { + view.dom.contentEditable = String( + isEditable() && !browseKey.getState(view.state), + ); + } + return new Plugin({ + key: browseKey, + state: { + init: () => false, + apply: (tr, browsing) => { + const value: unknown = tr.getMeta(browseKey); + return typeof value === "boolean" ? value : browsing; + }, + }, + props: { + attributes: (state): Record => + browseKey.getState(state) + ? { class: "bn-touch-browse", tabindex: "0" } + : { class: "" }, + }, + // This plugin is necessary: PM rewrites contenteditable on state/prop + // updates. A one-off DOM toggle is lost after typing or remote edits. + // props.editable would instead disable application commands/UI as well. + // Keep application permission authoritative; suspend only native editing. + view: (view) => { + syncDOM(view); + return { update: syncDOM }; + }, + }); +} + +type Point = { x: number; y: number }; +type TouchSource = { + controller: AbortController; + identifier: number; + id: string; + block: HTMLElement; + point: Point; +}; +type Gesture = TouchSource & + ( + | { type: "pending" } + | { + type: "dragging"; + transfer: DataTransfer; + scroller: Element; + } + ); + +/** One ancestor walk checks ownership, embedded controls, and the nearest block. */ +function touchBlock(target: EventTarget | null, editor: HTMLElement) { + let block: HTMLElement | undefined; + for ( + let element = target instanceof Element ? target : null; + element && element !== editor; + element = element.parentElement + ) { + // Leave embedded editors and interactive controls to their own gestures: + // lifting their containing block would steal link activation, button taps, + // form input, or native selection inside a custom editable node view. + if ( + element.matches( + '.bn-editor,a,button,input,textarea,select,[role="button"],[contenteditable="true"]', + ) + ) { + return undefined; + } + if ( + !block && + element instanceof HTMLElement && + element.classList.contains("bn-block-outer") && + element.dataset.id + ) { + block = element; + } + if (element.parentElement === editor) { + return block; + } + } + return undefined; +} + +/** + * A stationary touch lifts a block only outside text editing. Scrolling wins + * until the hold completes; editing keeps native selection and long-press UI. + * Native Safari gesture arbitration/keyboard behavior also needs device checks. + */ +export function registerTouchBlockDragging({ + view, + isEditable, + canDrag, + startDrag, + endDrag, + signal, +}: { + view: EditorView; + isEditable: () => boolean; + canDrag: (id: string) => boolean; + startDrag: (event: DragEvent, id: string) => void; + endDrag: () => void; + signal: AbortSignal; +}) { + if (signal.aborted) { + return; + } + const document = view.dom.ownerDocument; + const root = view.root; + const window = document.defaultView!; + const viewport = window.visualViewport; + let touchInput = window.matchMedia("(pointer: coarse)").matches; + let gesture: Gesture | undefined; + let keyboard: Keyboard | undefined; + + function browsing() { + return browseKey.getState(view.state) === true; + } + + function setBrowsing(value: boolean) { + if (value !== browsing() && !view.isDestroyed) { + view.dispatch(view.state.tr.setMeta(browseKey, value)); + } + } + + function browse() { + view.dom.blur(); + setBrowsing(true); + keyboard = undefined; + } + + // Safari decides whether text can be selected when contact starts. Switching + // contentEditable inside touchstart is too late on physical iPhones. Establish + // browsing before contact, without changing desktop or application permission. + if (touchInput && isEditable() && !view.hasFocus()) { + setBrowsing(true); + } + + function watchKeyboard() { + if (viewport) { + keyboard = { + type: "unseen", + height: viewport.height, + width: document.documentElement.clientWidth, + }; + } + } + + function edit(point: Point) { + watchKeyboard(); + setBrowsing(false); + const hit = view.posAtCoords({ left: point.x, top: point.y }); + if (hit) { + view.dispatch( + view.state.tr.setSelection( + TextSelection.near(view.state.doc.resolve(hit.pos)), + ), + ); + } + view.focus(); + } + + function event(type: string, point: Point, transfer: DataTransfer) { + return new DragEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + clientX: point.x, + clientY: point.y, + dataTransfer: transfer, + }); + } + + function over(current: Extract) { + const { x, y } = current.point; + moveTouchDragImage(x, y, current.block); + view.root + .elementFromPoint(x, y) + ?.dispatchEvent(event("dragover", current.point, current.transfer)); + } + + function startScrolling(current: Extract) { + const { signal } = current.controller; + let previous: number | undefined; + let frame = window.requestAnimationFrame(scroll); + signal.addEventListener("abort", () => window.cancelAnimationFrame(frame), { + once: true, + }); + + function scroll(time: number) { + if (signal.aborted) { + return; + } + // Clamp to the visible viewport: a scroll container may extend under the + // software keyboard. Cap elapsed time to avoid jumps after a paused tab. + const rect = current.scroller.getBoundingClientRect(); + const top = Math.max(rect.top, viewport?.offsetTop ?? 0); + const bottom = Math.min( + rect.bottom, + (viewport?.offsetTop ?? 0) + (viewport?.height ?? window.innerHeight), + ); + const elapsed = + previous === undefined + ? 1 / 60 + : Math.min((time - previous) / 1000, 0.05); + previous = time; + const before = current.scroller.scrollTop; + current.scroller.scrollTop += + edgeScrollSpeed(current.point.y, top, bottom) * elapsed; + if (current.scroller.scrollTop !== before) { + over(current); + } + frame = window.requestAnimationFrame(scroll); + } + } + + function finish(outcome: "drop" | "cancel") { + const current = gesture; + gesture = undefined; + if (!current) { + return; + } + // The gesture scope owns its hold timer, animation frame and source styling. + // Every completion path (drop, extra finger, blur, unmount) cancels them here. + current.controller.abort(); + if (current.type === "pending") { + return; + } + if (outcome === "drop") { + view.root + .elementFromPoint(current.point.x, current.point.y) + ?.dispatchEvent(event("drop", current.point, current.transfer)); + } + (view.dom.parentElement ?? root).dispatchEvent( + event("dragend", current.point, current.transfer), + ); + endDrag(); + browse(); + } + + function lift() { + const source = gesture; + if (source?.type !== "pending") { + return; + } + const info = getNodeById(source.id, view.state.doc); + if ( + !isEditable() || + !info || + !source.block.isConnected || + !canDrag(source.id) + ) { + finish("cancel"); + return; + } + // Keep native text editing disabled throughout the lift. Editor permission + // remains enabled for the drop pipeline, but Safari must not start a text + // selection when the finger moves over the source during a block drag. + setBrowsing(true); + view.dom.blur(); + // A whole-surface lift is a block operation, not a text-caret drag. Keep + // an existing block range if the lifted block belongs to it. + const selection = view.state.selection; + if ( + selection.toJSON().type === "text" || + info.posBeforeNode < selection.from || + info.posBeforeNode >= selection.to + ) { + view.dispatch( + view.state.tr.setSelection( + NodeSelection.create(view.state.doc, info.posBeforeNode), + ), + ); + } + const transfer = new DataTransfer(); + const start = event("dragstart", source.point, transfer); + startDrag(start, source.id); + transfer.setData("blocknote/touch-drag", "true"); + // The browser cannot start native DnD after our hold timer. Reuse the + // existing block drop pipeline instead. The editor parent identifies the + // source to UniqueID, but bypasses PM's native text-dragstart handler. + view.dom.parentElement!.dispatchEvent(start); + let scroller = source.block.parentElement; + while ( + scroller && + !( + scroller.scrollHeight > scroller.clientHeight && + /auto|scroll/.test(window.getComputedStyle(scroller).overflowY) + ) + ) { + scroller = scroller.parentElement; + } + gesture = { + ...source, + type: "dragging", + transfer, + scroller: scroller ?? document.scrollingElement!, + }; + source.block.classList.add("bn-touch-drag-source"); + source.controller.signal.addEventListener( + "abort", + () => source.block.classList.remove("bn-touch-drag-source"), + { once: true }, + ); + over(gesture); + startScrolling(gesture); + } + + // Track taps outside text editing/embedded controls; only draggable blocks lift. + view.dom.addEventListener( + "touchstart", + (e) => { + if (e.touches.length !== 1) { + finish("cancel"); + return; + } + if (!isEditable() || (view.hasFocus() && !browsing())) { + return; + } + const block = touchBlock(e.target, view.dom); + if (!block || !block.dataset.id) { + return; + } + finish("cancel"); + setBrowsing(true); + const touch = e.touches[0]; + const point = { x: touch.clientX, y: touch.clientY }; + const pending: Extract = { + type: "pending", + controller: new AbortController(), + identifier: touch.identifier, + id: block.dataset.id, + block, + point, + }; + gesture = pending; + if (canDrag(pending.id)) { + const timer = setTimeout(lift, holdDelay); + pending.controller.signal.addEventListener( + "abort", + () => clearTimeout(timer), + { once: true }, + ); + } + }, + { signal, capture: true, passive: true }, + ); + + // Before pickup, movement yields to scrolling; afterward it drives the preview/drop target. + view.dom.addEventListener( + "touchmove", + (e) => { + const current = gesture; + if (!current) { + return; + } + const touch = Array.from(e.touches).find( + (t) => t.identifier === current.identifier, + ); + if (!touch || e.touches.length !== 1) { + finish("cancel"); + return; + } + const point = { x: touch.clientX, y: touch.clientY }; + if (current.type === "pending") { + // Do not preventDefault: native scrolling/pinch wins before lift. + if ( + Math.hypot(point.x - current.point.x, point.y - current.point.y) > + holdMovementTolerance + ) { + finish("cancel"); + } + return; + } + e.preventDefault(); + current.point = point; + over(current); + }, + { signal, passive: false }, + ); + + // Release commits a lifted block or turns a short, stationary contact into editing. + view.dom.addEventListener( + "touchend", + (e) => { + const current = gesture; + if (!current) { + return; + } + const touch = Array.from(e.changedTouches).find( + (t) => t.identifier === current.identifier, + ); + if (!touch) { + return; + } + e.preventDefault(); + if (current.type === "dragging") { + current.point = { x: touch.clientX, y: touch.clientY }; + finish("drop"); + } else { + finish("cancel"); + edit({ x: touch.clientX, y: touch.clientY }); + } + }, + { signal, passive: false }, + ); + // OS gesture interruption must never commit a drop. + view.dom.addEventListener("touchcancel", () => finish("cancel"), { signal }); + // Suppress native text selection only while our recognizer owns the contact. + view.dom.addEventListener( + "selectstart", + (e) => { + if (gesture) { + e.preventDefault(); + } + }, + { signal }, + ); + // A second finger may land outside the editor while the first holds a block. + view.root.addEventListener( + "touchstart", + (e) => { + if ((e as TouchEvent).touches.length > 1) { + finish("cancel"); + } + }, + { signal, passive: true }, + ); + // Browsing substitutes block pickup for Safari's text/context-menu long press. + view.dom.addEventListener( + "contextmenu", + (e) => { + if (browsing() || gesture?.type === "dragging") { + e.preventDefault(); + } + }, + { signal }, + ); + // Do not let native DnD compete with our synthetic block pipeline. + view.dom.addEventListener( + "dragstart", + (e) => { + if (e.isTrusted && (browsing() || gesture)) { + e.preventDefault(); + } + }, + { signal, capture: true }, + ); + // Keyboard/programmatic focus hands control back to native editing. + view.dom.addEventListener( + "focus", + () => { + if (gesture?.type === "dragging") { + return; + } + // A desktop window resize is not a software-keyboard dismissal. + if (touchInput && !keyboard) { + watchKeyboard(); + } + setBrowsing(false); + }, + { signal }, + ); + // Leaving touch editing arms the next hold before contact begins. + view.dom.addEventListener( + "blur", + () => { + if ((touchInput || keyboard) && !gesture) { + browse(); + } + }, + { signal }, + ); + // Hybrid devices can switch back to mouse/pen editing without a remount. + view.root.addEventListener( + "pointerdown", + (e) => { + if ((e as PointerEvent).pointerType === "touch") { + touchInput = true; + return; + } + touchInput = false; + finish("cancel"); + keyboard = undefined; + setBrowsing(false); + }, + { signal, capture: true }, + ); + // iOS can dismiss its software keyboard without blurring the editor. + viewport?.addEventListener( + "resize", + () => { + if (!keyboard || browsing() || gesture) { + return; + } + keyboard = keyboardAfterResize(keyboard, { + height: viewport.height, + scale: viewport.scale, + layoutWidth: document.documentElement.clientWidth, + }); + if (!keyboard) { + browse(); + } + }, + { signal }, + ); + // Backgrounding cancels a gesture; remount/teardown also restores native DOM editing. + window.addEventListener("blur", () => finish("cancel"), { signal }); + signal.addEventListener( + "abort", + () => { + finish("cancel"); + setBrowsing(false); + }, + { once: true }, + ); +} diff --git a/packages/core/src/extensions/SideMenu/touchBlockDraggingState.test.ts b/packages/core/src/extensions/SideMenu/touchBlockDraggingState.test.ts new file mode 100644 index 0000000000..de0a61ff01 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/touchBlockDraggingState.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + edgeScrollSpeed, + keyboardAfterResize, +} from "./touchBlockDraggingState.js"; + +describe("touch drag policy", () => { + it("ramps scroll speed at either edge and leaves the middle neutral", () => { + expect(edgeScrollSpeed(100, 100, 400)).toBe(-840); + expect(edgeScrollSpeed(130, 100, 400)).toBe(-420); + expect(edgeScrollSpeed(250, 100, 400)).toBe(0); + expect(edgeScrollSpeed(370, 100, 400)).toBe(420); + expect(edgeScrollSpeed(400, 100, 400)).toBe(840); + expect(edgeScrollSpeed(500, 100, 400)).toBe(840); + }); + + it("does not overlap activation bands or scroll a hidden container", () => { + expect(edgeScrollSpeed(115, 100, 130)).toBe(0); + expect(edgeScrollSpeed(100, 100, 100)).toBe(0); + expect(edgeScrollSpeed(100, 200, 100)).toBe(0); + }); + + it("requires an observed keyboard shrink before treating recovery as dismissal", () => { + const baseline = { type: "unseen" as const, height: 700, width: 400 }; + const viewport = { height: 650, layoutWidth: 400, scale: 1 }; + expect(keyboardAfterResize(baseline, viewport)).toEqual(baseline); + const visible = keyboardAfterResize(baseline, { + ...viewport, + height: 400, + })!; + expect(visible.type).toBe("visible"); + expect( + keyboardAfterResize(visible, { ...viewport, height: 700 }), + ).toBeUndefined(); + }); + + it("ignores zoom and resets observation on orientation changes without ending editing", () => { + const visible = { type: "visible" as const, height: 700, width: 400 }; + expect( + keyboardAfterResize(visible, { height: 350, layoutWidth: 400, scale: 2 }), + ).toBe(visible); + expect( + keyboardAfterResize(visible, { height: 400, layoutWidth: 700, scale: 1 }), + ).toEqual({ type: "unseen", height: 400, width: 700 }); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/touchBlockDraggingState.ts b/packages/core/src/extensions/SideMenu/touchBlockDraggingState.ts new file mode 100644 index 0000000000..def72ea635 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/touchBlockDraggingState.ts @@ -0,0 +1,50 @@ +/** Pure policy: geometry and keyboard observations, independent of DOM/events. */ +export type Keyboard = { + type: "unseen" | "visible"; + height: number; + width: number; +}; + +// A 60px activation band leaves the middle of short containers neutral. +// Ramp to 840 CSS px/second so speed is independent of display refresh rate. +const scrollEdge = 60; +const scrollSpeed = 840; + +export function edgeScrollSpeed(y: number, top: number, bottom: number) { + const edge = Math.min(scrollEdge, Math.max(0, bottom - top) / 3); + if (!edge) { + return 0; + } + const distance = + y < top + edge ? y - top - edge : y > bottom - edge ? y - bottom + edge : 0; + return scrollSpeed * Math.max(-1, Math.min(1, distance / edge)); +} + +/** Undefined means the observed keyboard closed; unrelated resizes keep state. */ +export function keyboardAfterResize( + keyboard: Keyboard, + viewport: { height: number; layoutWidth: number; scale: number }, +): Keyboard | undefined { + // Use the toolbar's viewport policy, but only end editing after this editing + // session has observed an open keyboard. Never blur in response to pinch zoom. + if (Math.abs(viewport.scale - 1) > 0.01) { + return keyboard; + } + const next = observeVirtualKeyboard( + { ...keyboard, open: keyboard.type === "visible" }, + viewport, + ); + // Rotation starts a fresh observation, not a keyboard-dismissal action. + if (next.width !== keyboard.width) { + return { type: "unseen", height: next.height, width: next.width }; + } + if (keyboard.type === "visible" && !next.open) { + return undefined; + } + return { + type: next.open ? "visible" : keyboard.type, + height: next.height, + width: next.width, + }; +} +import { observeVirtualKeyboard } from "../../util/virtualKeyboard.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cffeff63fa..3db17595fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,8 @@ export * from "./i18n/dictionary.js"; export * from "./schema/index.js"; export * from "./user/index.js"; export * from "./util/browser.js"; +export * from "./util/applyBlockNoteRootTheme.js"; +export * from "./util/virtualKeyboard.js"; export * from "./util/combineByGroup.js"; export * from "./util/expandToWords.js"; export * from "./util/Store.js"; diff --git a/packages/core/src/util/applyBlockNoteRootTheme.ts b/packages/core/src/util/applyBlockNoteRootTheme.ts new file mode 100644 index 0000000000..d923c6406a --- /dev/null +++ b/packages/core/src/util/applyBlockNoteRootTheme.ts @@ -0,0 +1,29 @@ +/** Shared root theming for React portals and core-created drag previews. */ +export function applyBlockNoteRootTheme( + element: HTMLElement, + theme: { + className: string; + colorScheme: string | null; + style?: CSSStyleDeclaration; + }, +) { + element.className = theme.className; + if (theme.colorScheme !== null) { + element.setAttribute("data-color-scheme", theme.colorScheme); + } else { + element.removeAttribute("data-color-scheme"); + } + if (theme.style) { + // A root's layout belongs to the application, not its floating UI. Only + // custom properties and color-scheme are part of the inline theme contract. + for (const property of Array.from(theme.style)) { + if (property.startsWith("--") || property === "color-scheme") { + element.style.setProperty( + property, + theme.style.getPropertyValue(property), + theme.style.getPropertyPriority(property), + ); + } + } + } +} diff --git a/packages/core/src/util/virtualKeyboard.test.ts b/packages/core/src/util/virtualKeyboard.test.ts new file mode 100644 index 0000000000..9b57c26213 --- /dev/null +++ b/packages/core/src/util/virtualKeyboard.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from "vite-plus/test"; + +import { observeVirtualKeyboard } from "./virtualKeyboard.js"; + +it("uses the tallest viewport and distinguishes keyboard-sized shrink from browser chrome", () => { + const baseline = observeVirtualKeyboard(undefined, { + height: 700, + layoutWidth: 400, + scale: 1, + }); + expect( + observeVirtualKeyboard(baseline, { + height: 620, + layoutWidth: 400, + scale: 1, + }).open, + ).toBe(false); + const open = observeVirtualKeyboard(baseline, { + height: 400, + layoutWidth: 400, + scale: 1, + }); + expect(open.open).toBe(true); + expect( + observeVirtualKeyboard(open, { + height: 700, + layoutWidth: 400, + scale: 1, + }).open, + ).toBe(false); +}); + +it("normalizes pinch zoom and resets the baseline only on a large layout-width change", () => { + const baseline = { height: 700, width: 400, open: false }; + expect( + observeVirtualKeyboard(baseline, { + height: 350, + layoutWidth: 400, + scale: 2, + }).open, + ).toBe(false); + expect( + observeVirtualKeyboard(baseline, { + height: 400, + layoutWidth: 402, + scale: 1, + }), + ).toEqual({ height: 700, width: 400, open: true }); + expect( + observeVirtualKeyboard(baseline, { + height: 400, + layoutWidth: 700, + scale: 1, + }), + ).toEqual({ height: 400, width: 700, open: false }); +}); diff --git a/packages/core/src/util/virtualKeyboard.ts b/packages/core/src/util/virtualKeyboard.ts new file mode 100644 index 0000000000..526327eff2 --- /dev/null +++ b/packages/core/src/util/virtualKeyboard.ts @@ -0,0 +1,26 @@ +export type VirtualKeyboardState = { + height: number; + width: number; + open: boolean; +}; + +/** + * Shared viewport heuristic, not an OS keyboard signal. Scale-normalized height + * excludes pinch zoom; a large layout-width change resets the orientation's + * baseline. 150px separates browser chrome (~60–100px) from a keyboard (~250px+). + */ +export function observeVirtualKeyboard( + previous: VirtualKeyboardState | undefined, + viewport: { height: number; scale: number; layoutWidth: number }, +): VirtualKeyboardState { + const layoutHeight = viewport.height * viewport.scale; + const sameOrientation = + previous && + Math.abs(viewport.layoutWidth - previous.width) <= previous.width * 0.2; + const height = Math.max(sameOrientation ? previous.height : 0, layoutHeight); + return { + height, + width: sameOrientation ? previous.width : viewport.layoutWidth, + open: height - layoutHeight > 150, + }; +} diff --git a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts index d4100b900b..88f2c01778 100644 --- a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts +++ b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts @@ -1,11 +1,11 @@ +import { observeVirtualKeyboard, VirtualKeyboardState } from "@blocknote/core"; import { useLayoutEffect, useState } from "react"; // The tallest layout-equivalent viewport height seen so far — our stand-in for // "keyboard closed" — and the layout width it was measured at. Module scope so // they survive re-renders; the height only ever grows within a given width, so // refreshing it from a render pass is safe. -let maxLayoutViewportHeight = 0; -let baselineLayoutWidth = 0; +let keyboardViewport: VirtualKeyboardState | undefined; /** * Whether the on-screen keyboard is open, from the current visual viewport. We @@ -35,19 +35,12 @@ function isVirtualKeyboardOpen(): boolean { } const vp = window.visualViewport; - const scale = vp?.scale ?? 1; - const layoutHeight = (vp?.height ?? window.innerHeight) * scale; - const layoutWidth = document.documentElement.clientWidth; - - // Orientation changed: the tallest height seen so far belongs to the other - // orientation, start measuring afresh. - if (Math.abs(layoutWidth - baselineLayoutWidth) > baselineLayoutWidth * 0.2) { - baselineLayoutWidth = layoutWidth; - maxLayoutViewportHeight = 0; - } - - maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); - return maxLayoutViewportHeight - layoutHeight > 150; + keyboardViewport = observeVirtualKeyboard(keyboardViewport, { + height: vp?.height ?? window.innerHeight, + scale: vp?.scale ?? 1, + layoutWidth: document.documentElement.clientWidth, + }); + return keyboardViewport.open; } /** @@ -162,8 +155,7 @@ export function useVirtualKeyboard(): boolean { } // The keyboard baseline goes with them: a later editor starts from // what it measures itself, not from a maximum seen on another page. - maxLayoutViewportHeight = 0; - baselineLayoutWidth = 0; + keyboardViewport = undefined; } }; }, []); diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 89cc465424..dcd33aa314 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -3,6 +3,7 @@ import { BlockSchema, InlineContentSchema, StyleSchema, + applyBlockNoteRootTheme, mergeCSSClasses, } from "@blocknote/core"; import React, { @@ -211,12 +212,14 @@ function BlockNoteViewComponent< // `PortalElementOverride` creates outside React's tree. const applyThemedRoot = useCallback( (element: HTMLElement) => { - element.className = mergeCSSClasses( - "bn-root", - editorColorScheme, - className || "", - ); - element.setAttribute("data-color-scheme", editorColorScheme); + applyBlockNoteRootTheme(element, { + className: mergeCSSClasses( + "bn-root", + editorColorScheme, + className || "", + ), + colorScheme: editorColorScheme, + }); applyLibraryTheme?.(element); }, [editorColorScheme, className, applyLibraryTheme], diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 08d2013404..44195addde 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -304,6 +304,13 @@ SideMenuController offsets its position to keep it centered on the line. */ height: 30px; } +@media (pointer: coarse) { + /* Avoid accidental double-tap zoom without changing the menu's layout. */ + .bn-root .bn-side-menu .bn-button { + touch-action: manipulation; + } +} + /* Suggestion menus fill their floating element, which keeps the widest width the menu has had since it opened (`suggestionMenuMiddleware.ts`). Without this, a menu would shrink and grow as the user filters its items. */ diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index aab0581b69..47dabe1614 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -9,6 +9,8 @@ import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react"; import TestingApp from "@examples/01-basic/testing/src/App"; import PdfFileApp from "@examples/06-custom-schema/04-pdf-file-block/src/App"; import { createRef } from "react"; +import { TextSelection } from "@tiptap/pm/state"; +import { getNodeById } from "../../../../packages/core/src/api/nodeUtil.js"; import { describe, expect, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { @@ -34,9 +36,11 @@ import { dropDraggedBlock, getRect, mouseSequence, + moveMouseOverElement, startBlockDrag, } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +import { MultipleNodeSelection } from "../../../../packages/core/src/extensions/SideMenu/MultipleNodeSelection.js"; // `expect.element` is augmented against the bare `vitest` module, but vite-plus // types `expect` from an internal module, so the augmentation doesn't attach. @@ -528,8 +532,176 @@ describe.skipIf(browserName === "firefox")("Dropping into a block", () => { 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("a touch block-selection drop does not refocus the editable element", async () => { + await renderEditors([ + { id: "a", type: "paragraph", content: "A" }, + { id: "b", type: "paragraph", content: "B" }, + ]); + const view = editors[0].prosemirrorView; + view.dispatch( + view.state.tr.setSelection( + MultipleNodeSelection.create( + view.state.doc, + 1, + 1 + view.state.doc.firstChild!.firstChild!.nodeSize, + ), + ), + ); + // The browser test drives a mouse drag, so fake only the initiating + // pointer type before the handle's dragstart handler. Native iOS checks + // long-press initiation and the software keyboard separately. + function markTouch(event: DragEvent) { + event.target!.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerType: "touch", + }), + ); + } + document.addEventListener("dragstart", markTouch, { + once: true, + capture: true, + }); + try { + await dragAndDropBlock(block("a"), block("b"), false); + } finally { + document.removeEventListener("dragstart", markTouch, true); + } + expect(view.state.selection).toBeInstanceOf(MultipleNodeSelection); + expect(view.hasFocus()).toBe(false); + }); + + for (const intoToggle of [false, true]) { + test(`preserves block selection after dropping ${intoToggle ? "into a toggle" : "between blocks"}`, async () => { + await renderEditors([ + { id: "a", type: "paragraph", content: "A" }, + { + id: "b", + type: intoToggle ? "toggleListItem" : "paragraph", + content: "B", + }, + ]); + const view = editors[0].prosemirrorView; + view.dispatch( + view.state.tr.setSelection( + MultipleNodeSelection.create( + view.state.doc, + 1, + 1 + view.state.doc.firstChild!.firstChild!.nodeSize, + ), + ), + ); + await dragAndDropBlock( + block("a"), + intoToggle + ? block("b").querySelector(".bn-block-content")! + : block("b"), + false, + ); + const selection = view.state.selection; + expect(selection).toBeInstanceOf(MultipleNodeSelection); + if (!(selection instanceof MultipleNodeSelection)) { + throw new Error("Expected the dropped block to remain selected"); + } + expect(selection.nodes.map((node) => node.attrs.id)).toEqual(["a"]); + await expectElement(view.dom).toHaveFocus(); + await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`); + expect(ids(editors[0].document)).toEqual(["a", "b"]); + }); + + test(`preserves text selection after dropping ${intoToggle ? "into a toggle" : "between blocks"}`, async () => { + await renderEditors([ + { id: "a", type: "paragraph", content: "A" }, + { + id: "b", + type: intoToggle ? "toggleListItem" : "paragraph", + content: "B", + }, + ]); + editors[0].setTextCursorPosition("a", "end"); + await dragAndDropBlock( + block("a"), + intoToggle + ? block("b").querySelector(".bn-block-content")! + : block("b"), + false, + ); + expect(editors[0].prosemirrorView.state.selection).toBeInstanceOf( + TextSelection, + ); + expect(editors[0].getTextCursorPosition().block.id).toBe("a"); + }); + } + + test("preserves a descendant text range when its parent drops into a toggle", async () => { + await renderEditors([ + { id: "toggle", type: "toggleListItem", content: "Toggle" }, + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "abcdef" }], + }, + ]); + const view = editors[0].prosemirrorView; + const childPosition = getNodeById("child", view.state.doc)!.posBeforeNode; + // Reveal and approach the parent's handle before selecting the child: + // the child's formatting toolbar can cover the parent's hover target. + await moveMouseOverElement( + block("parent").querySelector(".bn-block-content")!, + ); + const handle = getRect(await waitForSelector('[data-test="dragHandle"]')); + const handleX = handle.x + handle.width / 2; + const handleY = handle.y + handle.height / 2; + await mouseSequence([{ type: "move", x: handleX, y: handleY, steps: 5 }]); + view.dispatch( + view.state.tr.setSelection( + TextSelection.create( + view.state.doc, + childPosition + 3, + childPosition + 5, + ), + ), + ); + await mouseSequence([ + { type: "down" }, + { type: "move", x: handleX + 10, y: handleY + 10, steps: 5 }, + ]); + const target = getRect( + block("toggle").querySelector(".bn-block-content")!, + ); + await mouseSequence([ + { + type: "move", + x: target.x + 50, + y: target.y + target.height / 2, + steps: 5, + }, + // Native dragover may require two moves over the target (Playwright). + { type: "move", x: target.x + 50, y: target.y + target.height / 2 }, + ]); + try { + await waitForSelector("[data-drop-target]"); + } finally { + await mouseSequence([{ type: "up" }]); + } + await expect + .poll(() => ids(editors[0].getBlock("toggle")!.children)) + .toEqual(["parent"]); + const movedChildPosition = getNodeById( + "child", + view.state.doc, + )!.posBeforeNode; + await expect + .poll(() => view.state.selection.toJSON()) + .toEqual({ + type: "text", + anchor: movedChildPosition + 3, + head: movedChildPosition + 5, + }); + }); + + // Keyboard focus, independently of selection type, allows undo (#3165). test("undoes a drop with the keyboard", async () => { await renderEditors([ { id: "a", type: "paragraph", content: "A" }, diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx index 6f8adc041b..309875ae90 100644 --- a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -1,4 +1,5 @@ import App from "@examples/01-basic/testing/src/App"; +import type { EditorView } from "@tiptap/pm/view"; import { afterEach, beforeEach, @@ -12,6 +13,7 @@ import { cleanup, render } from "vitest-browser-react"; import { page, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; +import { MultipleNodeSelection } from "../../../../packages/core/src/extensions/SideMenu/MultipleNodeSelection.js"; import { getRect, mouseSequence, @@ -72,6 +74,27 @@ afterEach(async () => { }); describe("Mobile formatting toolbar", () => { + test("blurring preserves a block selection without showing a toolbar", async () => { + await focusOnEditor(); + await userEvent.keyboard("Selected block"); + const view = document.querySelector< + HTMLElement & { editor: { view: EditorView } } + >(EDITOR_SELECTOR)!.editor.view; + const block = view.state.doc.firstChild!.firstChild!; + view.dispatch( + view.state.tr.setSelection( + MultipleNodeSelection.create(view.state.doc, 1, 1 + block.nodeSize), + ), + ); + + view.dom.blur(); + await settleFrames(); + + expect(view.state.selection).toBeInstanceOf(MultipleNodeSelection); + expect(view.hasFocus()).toBe(false); + expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); + }); + // Without the publisher count in `useVirtualKeyboard`, the `--bn-vv-*` // properties stay on `` after the last editor unmounts, pinning a // `bn-scroll-container` to the keyboard-open height on the next page From cf7f8de1af44b0ee16f1631b65cd5cee522d5880 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 6 Nov 2026 12:46:01 +0100 Subject: [PATCH 2/4] fix: preserve selection across editor UI focus changes --- .../src/extensions/SideMenu/touchBlockDragging.ts | 11 +++++++++-- .../__snapshots__/dragMultipleBlocksToNewColumn.json | 4 ++-- .../end-to-end/multicolumn/multicolumnDrop.test.tsx | 7 ++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extensions/SideMenu/touchBlockDragging.ts b/packages/core/src/extensions/SideMenu/touchBlockDragging.ts index 82923b0700..a1f24dfbed 100644 --- a/packages/core/src/extensions/SideMenu/touchBlockDragging.ts +++ b/packages/core/src/extensions/SideMenu/touchBlockDragging.ts @@ -21,9 +21,14 @@ const holdMovementTolerance = 8; /** Browsing changes native touch editing, not the application's edit permission. */ export function createTouchBlockDraggingPlugin(isEditable: () => boolean) { function syncDOM(view: EditorView) { - view.dom.contentEditable = String( + const contentEditable = String( isEditable() && !browseKey.getState(view.state), ); + // Even assigning the same attribute produces a mutation. Avoid waking PM's + // observer while focus moves to editor UI (for example the AI input). + if (view.dom.contentEditable !== contentEditable) { + view.dom.contentEditable = contentEditable; + } } return new Plugin({ key: browseKey, @@ -129,7 +134,9 @@ export function registerTouchBlockDragging({ const root = view.root; const window = document.defaultView!; const viewport = window.visualViewport; - let touchInput = window.matchMedia("(pointer: coarse)").matches; + let touchInput = + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches; let gesture: Gesture | undefined; let keyboard: Keyboard | undefined; diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json index cb3898851a..8786123a66 100644 --- a/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json @@ -66,7 +66,7 @@ { "type": "column", "attrs": { - "id": "8", + "id": "9", "width": 1 }, "content": [ @@ -152,4 +152,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx index a33d1ac368..677210d667 100644 --- a/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx +++ b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx @@ -142,12 +142,13 @@ describe("Check Multi-Column Drop Behaviour", () => { }, ); - // The drop leaves a text cursor at the end of the dropped block, so typing - // continues there (#3165). + // The drop retains focus (#3165) and the cursor's position within the block, + // so typing continues at the original offset rather than jumping to its end. test.skipIf(browserName === "firefox")( "Check typing after dragging a block to a new column", async () => { await focusOnOutsideParagraph(); + await userEvent.keyboard("{Home}{ArrowRight}{ArrowRight}{ArrowRight}"); await dragAndDropBlock( page.getByText("Paragraph outside columns").element(), @@ -157,7 +158,7 @@ describe("Check Multi-Column Drop Behaviour", () => { await userEvent.keyboard("X"); expect( - page.getByText("Paragraph outside columnsX").element(), + page.getByText("ParXagraph outside columns").element(), ).toBeDefined(); }, ); From cc28a4c607f02f79e864e99c3c94f7f9fb106ac1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 6 Nov 2026 12:50:39 +0100 Subject: [PATCH 3/4] test: keep generated document snapshot formatting --- .../__snapshots__/dragMultipleBlocksToNewColumn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json index 8786123a66..209b22a0dc 100644 --- a/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json @@ -152,4 +152,4 @@ ] } ] -} +} \ No newline at end of file From eb47f07069dac102c8de6b26810212a610a0be2d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 6 Nov 2026 13:01:34 +0100 Subject: [PATCH 4/4] test: make column boundary deletion setup deterministic --- .../multicolumn/multicolumn.test.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index e647555520..1ca654a328 100644 --- a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx +++ b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx @@ -1,4 +1,6 @@ import App from "@examples/01-basic/03-multi-column/src/App"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; import { beforeEach, describe, expect, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { MOD, page, userEvent } from "../../utils/context.js"; @@ -33,8 +35,21 @@ describe("Check Multi-Column Behaviour", () => { const target = page .getByText("You can have multiple blocks in a column too") .element(); - const targetRect = getRect(target); - await clickAt(targetRect.x + 1, targetRect.y + 1); + // This tests deletion across a column boundary, not coordinate-to-caret + // hit testing. A click at the wrapped paragraph's top edge can land in the + // neighboring heading in Firefox, deleting text instead of merging columns. + const view = document.querySelector< + HTMLElement & { editor: { view: EditorView } } + >(EDITOR_SELECTOR)!.editor.view; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, view.posAtDOM(target, 0)), + ), + ); + expect(view.state.selection.$from.parentOffset).toBe(0); + expect(view.state.selection.$from.parent.textContent).toBe( + "You can have multiple blocks in a column too", + ); await userEvent.keyboard("{ArrowLeft}"); await userEvent.keyboard("{Delete}");