diff --git a/.github/workflows/device-tests.yml b/.github/workflows/device-tests.yml new file mode 100644 index 0000000000..312f0052e3 --- /dev/null +++ b/.github/workflows/device-tests.yml @@ -0,0 +1,56 @@ +name: Device tests + +# Real-device tests on BrowserStack: nightly, and on demand. Requires the +# BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY repository secrets; see +# tests/device/README.md. +on: + schedule: + - cron: "30 3 * * *" + workflow_dispatch: + inputs: + device_filter: + description: "Substring of a device id from tests/device/devices.ts" + required: false + default: "" + +jobs: + device-tests: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Start playground dev server + run: | + pnpm run dev & + for i in $(seq 1 120); do + if curl -sf http://127.0.0.1:5173/ > /dev/null; then exit 0; fi + sleep 2 + done + echo "playground dev server never came up" >&2 + exit 1 + + - name: Run device tests + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + DEVICE_FILTER: ${{ inputs.device_filter }} + run: pnpm run test:device + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: device-test-screenshots + path: tests/device/.artifacts/ + if-no-files-found: ignore diff --git a/package.json b/package.json index 0323f0ce02..e721165941 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "prestart": "vp run build", "start": "vp run --filter @blocknote/example-editor preview", "test": "vp run --filter \"@blocknote/*\" --filter \"docs\" test", + "test:device": "pnpm --dir tests exec vitest run --config device/vitest.config.mts", "format": "vp fmt", "prepare": "vp config" }, diff --git a/packages/ariakit/src/input/Form.tsx b/packages/ariakit/src/input/Form.tsx index bf964aee66..c0139b7c39 100644 --- a/packages/ariakit/src/input/Form.tsx +++ b/packages/ariakit/src/input/Form.tsx @@ -4,9 +4,20 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => { - const { children, ...rest } = props; + const { children, onSubmit, ...rest } = props; assertEmpty(rest); - return {children}; + return ( + +
{ + event.preventDefault(); + onSubmit?.(); + }} + > + {children} +
+
+ ); }; diff --git a/packages/ariakit/src/input/TextInput.tsx b/packages/ariakit/src/input/TextInput.tsx index 555961faf0..2d1587f3ca 100644 --- a/packages/ariakit/src/input/TextInput.tsx +++ b/packages/ariakit/src/input/TextInput.tsx @@ -5,7 +5,7 @@ import { import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; export const TextInput = forwardRef< HTMLInputElement, @@ -32,6 +32,29 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + return ( <> {props.label && {label}} @@ -43,10 +66,16 @@ export const TextInput = forwardRef< className || "", variant === "large" ? "bn-ak-input-large" : "", )} - ref={ref} + // Belt-and-braces alongside the
in Form.Root. The form is what + // should make the browser treat Enter as a submit; this states it + // outright, so the behaviour doesn't rest on how Chromium scopes its + // "is there a next field to jump to" lookup. Removable once that's + // confirmed on a device — the tell is the keyboard's action key: an + // arrow means it still wants to advance focus. + enterKeyHint="done" + ref={setRefs} name={name} value={value} - autoFocus={autoFocus} placeholder={placeholder} disabled={disabled} onKeyDown={onKeyDown} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..83624785fd 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -797,6 +797,15 @@ export class BlockNoteEditor< * Checks whether a DOM element belongs to this editor — either inside the * editor's DOM tree or inside its portal container (used for floating UI * elements like menus and toolbars). + * + * The first check starts at the content area's *parent*, so that UI the + * host app renders as `BlockNoteView` children counts too — React places + * those beside the content (see the "Static Formatting Toolbar" example). + * + * So the boundary is whatever the element passed to `editor.mount()` has as + * its parent. `BlockNoteView` always provides a wrapper; mounting bare into + * ``, as the vanilla-JS docs do, makes the whole page count as within + * the editor. */ public isWithinEditor = (element: Element): boolean => { return !!( @@ -805,11 +814,25 @@ export class BlockNoteEditor< ); }; - public isFocused() { + public isFocused(options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) also counts as + * focused, answering "is the user still interacting with this editor?". + * The default reports content-area focus only. + */ + includeEditorUI?: boolean; + }) { if (this.headless) { return false; } - return this.prosemirrorView?.hasFocus() || false; + const contentFocused = this.prosemirrorView?.hasFocus() || false; + if (!options?.includeEditorUI) { + return contentFocused; + } + const active = + typeof document !== "undefined" ? document.activeElement : null; + return contentFocused || (!!active && this.isWithinEditor(active)); } public headless = true; @@ -1365,6 +1388,36 @@ export class BlockNoteEditor< ); } + /** + * A callback function that runs whenever the editor's content area gains or + * loses DOM focus. + * + * Note that `focused: false` only means the content area itself blurred — + * focus may have moved into the editor's own UI (e.g. a toolbar + * popover's input). + * + * @param callback The callback to execute. + * @returns A function to remove the callback. + */ + public onFocusChange( + callback: ( + editor: BlockNoteEditor, + context: { focused: boolean; event: FocusEvent }, + ) => void, + options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) counts as focused, + * and the callback fires only when that combined focus state actually + * changes, after focus movement has settled. The default reports raw + * content-area focus/blur events. + */ + includeEditorUI?: boolean; + }, + ) { + return this._eventManager.onFocusChange(callback, options); + } + /** * A callback function that runs when the editor has been mounted. * diff --git a/packages/core/src/editor/managers/EventManager.browser.test.ts b/packages/core/src/editor/managers/EventManager.browser.test.ts new file mode 100644 index 0000000000..684c19745c --- /dev/null +++ b/packages/core/src/editor/managers/EventManager.browser.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +// Focus tracking is almost entirely DOM semantics — event ordering, what +// `document.activeElement` reads as at each step, and how focus behaves when +// it moves into UI that is portalled outside the editor. None of that is +// reproducible in jsdom, so these run in the browser suite across all three +// engines. + +/** Resolves once the deferred focus settle has run (see attachUIFocusTracker). */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe("Focus events", () => { + let editor: BlockNoteEditor; + let container: HTMLElement; + let outside: HTMLInputElement; + + /** + * Mounts an editor inside its own container, mirroring how `BlockNoteView` + * renders it. The nesting matters: `isWithinEditor` — which `isFocused` + * and the focus events are built on — treats the mount element's *parent* + * as the editor's boundary, so that it also covers UI rendered as a + * sibling of the content area. Mounting straight into `` would make + * that boundary the whole page. + */ + function mountEditor() { + const editorContainer = document.createElement("div"); + const mountPoint = document.createElement("div"); + editorContainer.append(mountPoint); + document.body.append(editorContainer); + const instance = BlockNoteEditor.create(); + instance.mount(mountPoint); + return { editor: instance, container: editorContainer }; + } + + beforeEach(() => { + outside = document.createElement("input"); + outside.id = "outside"; + document.body.append(outside); + + ({ editor, container } = mountEditor()); + }); + + afterEach(() => { + editor.unmount(); + container.remove(); + outside.remove(); + }); + + /** + * The DOM contract the tracker is built on + * (https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent#order_of_events). + * If a future engine changes this ordering, the tracker's "settle + * immediately on focusin, defer on focusout" split stops being valid — so + * it's asserted rather than assumed. + */ + it("follows the documented focus event order", async () => { + const a = document.createElement("input"); + const b = document.createElement("input"); + document.body.append(a, b); + const order: string[] = []; + for (const [name, element] of [ + ["a", a], + ["b", b], + ] as const) { + for (const type of ["blur", "focusout", "focus", "focusin"]) { + element.addEventListener(type, () => order.push(`${type}:${name}`)); + } + } + + a.focus(); + order.length = 0; + b.focus(); + + expect(order).toEqual(["blur:a", "focusout:a", "focus:b", "focusin:b"]); + + a.remove(); + b.remove(); + }); + + /** + * Why the focusout side has to be deferred: at focusout time the outgoing + * element has *already* lost focus and `document.activeElement` reads as + * ``, so the destination isn't knowable yet. (`relatedTarget` can't + * substitute — MDN documents it as null in cases like tabbing out of the + * page, and it is unreliable on mobile.) + */ + it("reports as the active element during focusout", async () => { + const a = document.createElement("input"); + document.body.append(a); + let activeDuringFocusOut: Element | null = null; + a.addEventListener("focusout", () => { + activeDuringFocusOut = document.activeElement; + }); + + a.focus(); + a.blur(); + + expect(activeDuringFocusOut).toBe(document.body); + a.remove(); + }); + + it("isFocused() tracks the content area", async () => { + expect(editor.isFocused()).toBe(false); + + editor.focus(); + expect(editor.isFocused()).toBe(true); + + outside.focus(); + expect(editor.isFocused()).toBe(false); + }); + + it("isFocused({ includeEditorUI }) counts the editor's own UI", async () => { + // The portal element is where menus, toolbars and popovers render — it + // lives outside the content area, so plain content focus can't see it. + const popoverInput = document.createElement("input"); + editor.portalElement.append(popoverInput); + + popoverInput.focus(); + + expect(editor.isFocused()).toBe(false); + expect(editor.isFocused({ includeEditorUI: true })).toBe(true); + + outside.focus(); + expect(editor.isFocused({ includeEditorUI: true })).toBe(false); + + popoverInput.remove(); + }); + + it("onFocusChange reports content focus and blur", async () => { + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange((_editor, ctx) => + events.push(ctx.focused), + ); + + editor.focus(); + await settle(); + outside.focus(); + await settle(); + + expect(events).toEqual([true, false]); + unsubscribe(); + }); + + it("onFocusChange({ includeEditorUI }) stays focused across a handoff into the editor's UI", async () => { + const popoverInput = document.createElement("input"); + editor.portalElement.append(popoverInput); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + // Content -> a popover input. This is the handoff that matters: the raw + // channel would report a blur here, which is what used to tear the mobile + // toolbar (and the popover with it) down mid-interaction. + popoverInput.focus(); + await settle(); + + expect(events.at(-1)).toBe(true); + expect(events).not.toContain(false); + + // Leaving the editor entirely does report a blur. + outside.focus(); + await settle(); + expect(events.at(-1)).toBe(false); + + unsubscribe(); + popoverInput.remove(); + }); + + it("does not report a spurious blur while focus moves between UI elements", async () => { + const first = document.createElement("input"); + const second = document.createElement("input"); + editor.portalElement.append(first, second); + + editor.focus(); + await settle(); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + first.focus(); + second.focus(); + editor.focus(); + await settle(); + + expect(events).not.toContain(false); + + unsubscribe(); + first.remove(); + second.remove(); + }); + + it("ignores focus changes that never involve the editor", async () => { + // The tracker listens at the document level, so it sees every focus + // change on the page — including ones with nothing to do with this + // editor. Those must not reach subscribers. + const otherA = document.createElement("input"); + const otherB = document.createElement("input"); + document.body.append(otherA, otherB); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + otherA.focus(); + await settle(); + otherB.focus(); + await settle(); + + expect(events).toEqual([]); + + unsubscribe(); + otherA.remove(); + otherB.remove(); + }); + + it("keeps two editors on one page independent", async () => { + const { editor: other, container: otherContainer } = mountEditor(); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + other.focus(); + await settle(); + + expect(other.isFocused()).toBe(true); + expect(editor.isFocused({ includeEditorUI: true })).toBe(false); + expect(events).toEqual([]); + + unsubscribe(); + other.unmount(); + otherContainer.remove(); + }); + + it("stops delivering events after unsubscribing", async () => { + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + const countWhileSubscribed = events.length; + expect(countWhileSubscribed).toBeGreaterThan(0); + + unsubscribe(); + outside.focus(); + await settle(); + editor.focus(); + await settle(); + + expect(events.length).toBe(countWhileSubscribed); + }); + + it("supports several subscribers independently", async () => { + const first: boolean[] = []; + const second: boolean[] = []; + const unsubscribeFirst = editor.onFocusChange( + (_editor, ctx) => first.push(ctx.focused), + { includeEditorUI: true }, + ); + const unsubscribeSecond = editor.onFocusChange( + (_editor, ctx) => second.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + expect(first.length).toBeGreaterThan(0); + expect(second.length).toBe(first.length); + + // The document listeners are shared and reference-counted, so dropping + // one subscriber must not stop the other's events. + unsubscribeFirst(); + const firstCount = first.length; + outside.focus(); + await settle(); + + expect(first.length).toBe(firstCount); + expect(second.at(-1)).toBe(false); + + unsubscribeSecond(); + }); +}); diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index 4d2f9f581a..beed2a5dd2 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -34,6 +34,20 @@ export class EventManager< onSelectionChange: [ ctx: { editor: BlockNoteEditor; transaction: Transaction }, ]; + onFocusChange: [ + ctx: { + editor: BlockNoteEditor; + focused: boolean; + event: FocusEvent; + }, + ]; + onFocusChangeWithinUI: [ + ctx: { + editor: BlockNoteEditor; + focused: boolean; + event: FocusEvent; + }, + ]; onMount: [ctx: { editor: BlockNoteEditor }]; onUnmount: [ctx: { editor: BlockNoteEditor }]; }> { @@ -51,15 +65,94 @@ export class EventManager< editor._tiptapEditor.on("selectionUpdate", ({ transaction }) => { this.emit("onSelectionChange", { editor, transaction }); }); + editor._tiptapEditor.on("focus", ({ event }) => { + this.emit("onFocusChange", { editor, focused: true, event }); + }); + editor._tiptapEditor.on("blur", ({ event }) => { + this.emit("onFocusChange", { editor, focused: false, event }); + }); editor._tiptapEditor.on("mount", () => { this.emit("onMount", { editor }); }); editor._tiptapEditor.on("unmount", () => { this.emit("onUnmount", { editor }); }); + editor._tiptapEditor.on("destroy", () => { + // Subscribers normally detach the tracker when the last one + // unsubscribes; this covers subscribers that outlive the editor. + this.detachUIFocusTracker?.(); + }); }); } + /** + * Settled focus-within-UI tracking. Document-level listeners (attached only + * while someone subscribes with `includeEditorUI`) cover the case tiptap + * events can't: focus moving from the editor's own UI (which lives in + * `editor.portalElement`, outside the content area) to somewhere else + * entirely. Blur-side changes are re-checked a frame later because + * `document.activeElement` transiently becomes `` during focus + * handoffs (and `relatedTarget` is unreliable on mobile). + */ + private uiFocused = false; + + private uiFocusSubscriberCount = 0; + + private uiFocusSettleHandle: ReturnType | undefined; + + private detachUIFocusTracker: (() => void) | undefined; + + private computeUIFocused(): boolean { + const active = + typeof document !== "undefined" ? document.activeElement : null; + return ( + this.editor.isFocused() || + (!!active && this.editor.isWithinEditor(active)) + ); + } + + private settleUIFocus(event: FocusEvent) { + const focused = this.computeUIFocused(); + if (focused !== this.uiFocused) { + this.uiFocused = focused; + this.emit("onFocusChangeWithinUI", { + editor: this.editor, + focused, + event, + }); + } + } + + private attachUIFocusTracker() { + if (typeof document === "undefined") { + return; + } + this.uiFocused = this.computeUIFocused(); + // On focusin the new element already holds focus, so the state can be + // read immediately. + const onFocusIn = (event: FocusEvent) => this.settleUIFocus(event); + + // On focusout it can't: `document.activeElement` is still the outgoing + // element (and passes through `` mid-handoff), and some UI + // libraries restore focus asynchronously — the ariakit and shadcn link + // popovers both do. The check therefore has to wait for the current task + // to finish. A microtask is too early (verified: those popover tests go + // red), and a frame would work but doesn't run in a background tab. + const onFocusOut = (event: FocusEvent) => { + clearTimeout(this.uiFocusSettleHandle); + this.uiFocusSettleHandle = setTimeout(() => this.settleUIFocus(event)); + }; + + document.addEventListener("focusin", onFocusIn, true); + document.addEventListener("focusout", onFocusOut, true); + this.detachUIFocusTracker = () => { + clearTimeout(this.uiFocusSettleHandle); + document.removeEventListener("focusin", onFocusIn, true); + document.removeEventListener("focusout", onFocusOut, true); + this.detachUIFocusTracker = undefined; + }; + } + /** * Register a callback that will be called when the editor changes. */ @@ -131,6 +224,64 @@ export class EventManager< }; } + /** + * Register a callback that will be called when the editor's content area + * gains or loses DOM focus. + * + * Note that `focused: false` only means the content area itself blurred — + * focus may have moved into the editor's own UI (e.g. a toolbar + * popover's input). Consumers that need to distinguish should check where + * `document.activeElement` ended up. + */ + public onFocusChange( + callback: ( + editor: BlockNoteEditor, + ctx: { focused: boolean; event: FocusEvent }, + ) => void, + options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) counts as focused, + * and events fire only when that combined focus state actually changes, + * after focus movement has settled. Use this to know whether the user + * is still interacting with the editor; the default reports raw + * content-area focus/blur. + */ + includeEditorUI?: boolean; + }, + ): Unsubscribe { + const cb = ({ + focused, + event, + }: { + focused: boolean; + event: FocusEvent; + }) => { + callback(this.editor, { focused, event }); + }; + + if (options?.includeEditorUI) { + this.uiFocusSubscriberCount++; + if (this.uiFocusSubscriberCount === 1) { + this.attachUIFocusTracker(); + } + this.on("onFocusChangeWithinUI", cb); + return () => { + this.off("onFocusChangeWithinUI", cb); + this.uiFocusSubscriberCount--; + if (this.uiFocusSubscriberCount === 0) { + this.detachUIFocusTracker?.(); + } + }; + } + + this.on("onFocusChange", cb); + + return () => { + this.off("onFocusChange", cb); + }; + } + /** * Register a callback that will be called when the editor is mounted. */ diff --git a/packages/core/src/editor/managers/StyleManager.ts b/packages/core/src/editor/managers/StyleManager.ts index e412160e4a..a3ddf0d52b 100644 --- a/packages/core/src/editor/managers/StyleManager.ts +++ b/packages/core/src/editor/managers/StyleManager.ts @@ -183,7 +183,13 @@ export class StyleManager< */ public getSelectedLinkUrl() { return this.editor.transact((tr) => { - return this.getLinkMarkAtPos(tr.selection.from)?.href; + // `from + 1` for the same boundary reason as `editLink` below: at the + // left edge of a link (e.g. when the whole link is selected), the mark + // lookup at `from` itself misses the mark and the link's URL would + // incorrectly read as absent. + return this.getLinkMarkAtPos( + Math.min(tr.selection.from + 1, tr.doc.content.size), + )?.href; }); } diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..abcaeb6035 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -22,6 +22,7 @@ import { getBlockInfoFromSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isAndroid } from "../../../util/browser.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; @@ -31,6 +32,61 @@ export const KeyboardShortcutsExtension = Extension.create<{ }>({ priority: 50, + addProseMirrorPlugins() { + return [ + // On Android, Enter never reaches the keymap: the IME delivers it as a + // `beforeinput` (the keydown is keyCode 229), and prosemirror-view + // additionally ignores Enter keydowns on Android Chrome. ProseMirror's + // fallback — parsing the browser's native DOM split and synthesizing an + // Enter key event — fails to recognize the split in BlockNote's nested + // block DOM and corrupts the document instead (Enter inserting a space, + // doing nothing, or breaking tables — TypeCellOS/BlockNote#3001). + // Intercepting the `beforeinput` and running the keymap chain directly + // bypasses the fragile DOM diffing entirely. + new Plugin({ + key: new PluginKey("blockNoteAndroidEnter"), + props: { + handleDOMEvents: { + beforeinput: (view, event) => { + if (!isAndroid() || view.composing) { + return false; + } + if ( + event.inputType !== "insertParagraph" && + event.inputType !== "insertLineBreak" + ) { + return false; + } + event.preventDefault(); + // Restore the parity prosemirror-view skips here: for normal + // keydowns it force-flushes pending DOM observations (including + // selection changes) before running key handlers, but its + // Android Enter bail returns before that flush — without it the + // synthesized Enter can run against a stale selection (e.g. a + // just-made cross-block selection that hasn't synced yet). + ( + view as typeof view & { + domObserver: { forceFlush(): void }; + } + ).domObserver.forceFlush(); + view.someProp("handleKeyDown", (handler) => + handler( + view, + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey: event.inputType === "insertLineBreak", + }), + ), + ); + return true; + }, + }, + }, + }), + ]; + }, + // TODO: The shortcuts need a refactor. Do we want to use a command priority // design as there is now, or clump the logic into a single function? addKeyboardShortcuts() { diff --git a/packages/core/src/util/browser.ts b/packages/core/src/util/browser.ts index d070115c2a..d8961d526d 100644 --- a/packages/core/src/util/browser.ts +++ b/packages/core/src/util/browser.ts @@ -29,6 +29,9 @@ export function mergeCSSClasses(...classes: (string | false | undefined)[]) { export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +export const isAndroid = () => + typeof navigator !== "undefined" && /android/i.test(navigator.userAgent); + // Cached lazily on first call in a browser environment. Touch capability // doesn't change during a session, so there's no need to re-run `matchMedia` on // every call. We only cache once `navigator`/`window` are available, so a diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index beb3c8182f..7c7aeaa355 100644 --- a/packages/mantine/src/blocknoteStyles.css +++ b/packages/mantine/src/blocknoteStyles.css @@ -257,6 +257,19 @@ on touch devices (e.g. the mobile formatting toolbar). */ font-size: 12px; } +/* On touch devices, enlarge the form-popover inputs (e.g. the link popover's + URL field). The 16px font-size is load-bearing: iOS Safari auto-zooms the + page when focusing an input with a smaller computed font-size, and that zoom + perturbs the visual viewport the mobile toolbar positions itself from. The + taller min-height also gives a comfortable tap target. */ +@media (pointer: coarse) { + .bn-form-popover .mantine-TextInput-input, + .bn-form-popover .mantine-FileInput-input { + font-size: 16px; + min-height: 40px; + } +} + .bn-form-popover .mantine-FileInput-input:hover { background-color: var(--bn-colors-hovered-background); } diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 6c85286e7b..458859992e 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -89,7 +89,16 @@ export const components: Components = { Group: BadgeGroup, }, Form: { - Root: (props) =>
{props.children}
, + Root: (props) => ( + { + event.preventDefault(); + props.onSubmit?.(); + }} + > + {props.children} + + ), TextInput: TextInput, }, Menu: { diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx index c1630fa17f..e73322d14b 100644 --- a/packages/mantine/src/form/TextInput.tsx +++ b/packages/mantine/src/form/TextInput.tsx @@ -2,7 +2,7 @@ import { TextInput as MantineTextInput } from "@mantine/core"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; export const TextInput = forwardRef< HTMLInputElement, @@ -29,6 +29,29 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + return ( in Form.Root. The form is what + // should make the browser treat Enter as a submit; this states it + // outright, so the behaviour doesn't rest on how Chromium scopes its + // "is there a next field to jump to" lookup. Removable once that's + // confirmed on a device — the tell is the keyboard's action key: an + // arrow means it still wants to advance focus. + enterKeyHint="done" + ref={setRefs} name={name} label={label} leftSection={icon} value={value} - autoFocus={autoFocus} data-autofocus={autoFocus ? "true" : undefined} rightSection={rightSection} placeholder={placeholder} diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx index 9a10c4ce44..c87da9aa6d 100644 --- a/packages/mantine/src/popover/Popover.tsx +++ b/packages/mantine/src/popover/Popover.tsx @@ -23,6 +23,12 @@ export const Popover = ( // Do not move focus to the dropdown on mobile, as it blurs the editor's // contentEditable and dismisses the on-screen keyboard. trapFocus={portalRoot ? false : undefined} + // Keep the dropdown visible through virtual-keyboard viewport resizes on + // mobile: hideDetached (default true) reacts to the resize by setting + // display:none on the dropdown, which blurs its focused input and + // dismisses the on-screen keyboard (the input then unmounts with the + // toolbar, so the whole UI collapses). + hideDetached={portalRoot ? false : undefined} opened={open} onChange={onOpenChange} position={position} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 26ce7e04a5..a8dc792605 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -162,7 +162,14 @@ export const CreateLinkButton = () => { text={state.text} range={state.range} showTextField={false} - setToolbarOpen={(open) => formattingToolbar.store.setState(open)} + // Also close this popover directly: the desktop toolbar unmounts on + // the store update (taking the popover with it), but the mobile + // toolbar stays mounted, so without this the popover lingers after + // submitting the link. + setToolbarOpen={(open) => { + setPopoverOpen(open); + formattingToolbar.store.setState(open); + }} /> diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index bd72ea451c..8e2982d946 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -127,7 +127,7 @@ export const FileCaptionButton = () => { className={"bn-popover-content bn-form-popover"} variant={"form-popover"} > - + setPopoverOpen(false)}> } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index b13bb45a88..5932b4f550 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -133,7 +133,7 @@ export const FileRenameButton = () => { className={"bn-popover-content bn-form-popover"} variant={"form-popover"} > - + setPopoverOpen(false)}> } diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index b1ea2f757a..797b201753 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,7 +1,7 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; import { UIModeContext } from "../../editor/UIModeContext.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorFocus } from "../../hooks/useEditorFocus.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; @@ -41,42 +41,13 @@ import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; export const MobileFormattingToolbarController = (props: { formattingToolbar?: FC; }) => { - const editor = useBlockNoteEditor(); const keyboardOpen = useVirtualKeyboard(); - // Whether focus is within this editor's UI, kept in sync via its - // `focus`/`blur` events so the toolbar shows/hides as focus enters or leaves - // the editor. - const [focused, setFocused] = useState(() => editor.isFocused()); - useEffect(() => { - // Re-sync on mount in case focus changed before the listeners attached. - setFocused(editor.isFocused()); - - const onFocus = () => setFocused(true); - // When the editor's content blurs, focus may still be within the editor's - // own floating UI — e.g. a toolbar popover's input autofocusing, which - // portals into `editor.portalElement`. Treating that as "focus left the - // editor" would unmount this toolbar (and the popover with it), so it would - // appear to never open. `relatedTarget` is unreliable on mobile, so we - // re-check `document.activeElement` on the next frame and only hide once - // focus has truly left the editor and its portal. - const onBlur = () => { - requestAnimationFrame(() => { - const active = document.activeElement; - setFocused( - editor.isFocused() || (!!active && editor.isWithinEditor(active)), - ); - }); - }; - - editor._tiptapEditor.on("focus", onFocus); - editor._tiptapEditor.on("blur", onBlur); - - return () => { - editor._tiptapEditor.off("focus", onFocus); - editor._tiptapEditor.off("blur", onBlur); - }; - }, [editor]); + // Whether the user is still interacting with this editor: content focus or + // focus within its UI (a toolbar popover's input, portalled into + // `editor.portalElement`, must not hide the toolbar — unmounting it would + // take the popover down with it). + const focused = useEditorFocus({ includeEditorUI: true }); if (!keyboardOpen || !focused) { return null; diff --git a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx index 1d82a6e7cc..6303126846 100644 --- a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx +++ b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx @@ -81,7 +81,7 @@ export const EditLinkMenuItems = ( }, [editLink, currentUrl, currentText, props]); return ( - + {/* // TODO: add labels? */} {showTextField !== false && ( )} diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 5d71bc58dc..e168022c01 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -304,6 +304,14 @@ export type ComponentProps = { Form: { Root: { children?: ReactNode; + /** + * Called on the form's `submit` event, which is how the browser + * reports Enter-to-submit — including when a mobile IME's action key + * triggers it. Implementations must render a real `
` and + * `preventDefault`, or Enter is left with no submission path at all + * on platforms that don't dispatch a key event for it. + */ + onSubmit?: () => void; }; TextInput: { className?: string; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index ade26292eb..0e15c1c7df 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -1,5 +1,5 @@ import type { BlockNoteEditor } from "@blocknote/core"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -20,6 +20,13 @@ export function useEditorChange( editor = editorContext?.editor; } + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without resubscribing on re-renders. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + useEffect(() => { if (!editor) { throw new Error( @@ -27,6 +34,8 @@ export function useEditorChange( ); } - return editor.onChange(callback); - }, [callback, editor]); + return editor.onChange((...args: Parameters) => + callbackRef.current(...args), + ); + }, [editor]); } diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts new file mode 100644 index 0000000000..98cb68014c --- /dev/null +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -0,0 +1,76 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; + +/** + * Whether the editor is focused, as state — re-rendering the component when + * that changes. + * + * Use this when focus decides what to *render*. + * {@link useEditorFocusChange} is the counterpart for running a side effect on + * focus changes (the same split as `useEditorState` vs `useEditorChange`). + * + * By default this reports raw content-area focus, so `false` may just mean + * focus moved into the editor's own UI — a toolbar popover's input, say. Pass + * `includeEditorUI: true` to instead get "is the user still interacting with + * this editor", which counts toolbars, menus and popovers as focused and only + * changes once focus movement has settled. + * + * @param options - See `editor.onFocusChange`. + * @param editor - The BlockNote editor instance. If omitted, uses the editor + * from the nearest `BlockNoteContext`. + */ +export function useEditorFocus( + options?: { includeEditorUI?: boolean }, + editor?: BlockNoteEditor, +): boolean { + const editorContext = useBlockNoteContext(); + const resolvedEditor = editor ?? editorContext?.editor; + + if (!resolvedEditor) { + // Thrown during render rather than from an effect: the return value is + // used to render, so a deferred throw would first paint a frame with a + // meaningless value. + throw new Error( + "'editor' is required, either from BlockNoteContext or as a function argument", + ); + } + + const includeEditorUI = options?.includeEditorUI ?? false; + + // The snapshot is the last *settled* value, never a live read. With + // `includeEditorUI` the editor's own events are already settled, whereas + // reading focus state during an arbitrary render can catch a mid-handoff + // frame, where `document.activeElement` is transiently `` and the + // editor looks unfocused for one frame. + const focused = useRef(undefined); + if (focused.current === undefined) { + focused.current = resolvedEditor.isFocused({ includeEditorUI }); + } + + const subscribe = useCallback( + (onStoreChange: () => void) => { + // Re-sync: focus can have changed between the render that produced the + // current snapshot and this subscription attaching. React does compare + // the snapshot again after subscribing (its subscribe effect is + // registered before the consistency-check one), so refreshing the + // cached value here is enough — but notifying explicitly keeps that + // independent of React's internal effect ordering. + focused.current = resolvedEditor.isFocused({ includeEditorUI }); + onStoreChange(); + + return resolvedEditor.onFocusChange( + (_editor, ctx) => { + focused.current = ctx.focused; + onStoreChange(); + }, + { includeEditorUI }, + ); + }, + [resolvedEditor, includeEditorUI], + ); + + const getSnapshot = useCallback(() => focused.current!, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/react/src/hooks/useEditorFocusChange.ts b/packages/react/src/hooks/useEditorFocusChange.ts new file mode 100644 index 0000000000..b14b8f514d --- /dev/null +++ b/packages/react/src/hooks/useEditorFocusChange.ts @@ -0,0 +1,50 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { useEffect, useRef } from "react"; +import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; + +/** + * Subscribes to the editor gaining or losing focus. The subscription is + * automatically cleaned up when the component unmounts, and the latest + * `callback` is always invoked without resubscribing on re-renders. + * + * By default this reports raw content-area focus/blur; `focused: false` may + * mean focus moved into the editor's own UI (e.g. a toolbar + * popover's input). Pass `includeEditorUI: true` to instead observe "is the + * user still interacting with this editor" — floating UI counts as focused, + * and the callback fires only on settled changes of that combined state. + * + * @param callback - Function called with the editor and `{ focused, event }`. + * @param editor - The BlockNote editor instance. If omitted, uses the editor + * from the nearest `BlockNoteContext`. + * @param options - See `editor.onFocusChange`. + */ +export function useEditorFocusChange( + callback: Parameters["onFocusChange"]>[0], + editor?: BlockNoteEditor, + options?: Parameters["onFocusChange"]>[1], +) { + const editorContext = useBlockNoteContext(); + const resolvedEditor = editor ?? editorContext?.editor; + + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without retriggering the effect. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + + const includeEditorUI = options?.includeEditorUI ?? false; + + useEffect(() => { + if (!resolvedEditor) { + throw new Error( + "'editor' is required, either from BlockNoteContext or as a function argument", + ); + } + + return resolvedEditor.onFocusChange( + (editorArg, ctx) => callbackRef.current(editorArg, ctx), + { includeEditorUI }, + ); + }, [resolvedEditor, includeEditorUI]); +} diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 08225fc88f..e443487452 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -1,5 +1,5 @@ import type { BlockNoteEditor } from "@blocknote/core"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -23,12 +23,22 @@ export function useEditorSelectionChange( editor = editorContext?.editor; } + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without resubscribing on re-renders. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + useEffect(() => { if (!editor) { throw new Error( "'editor' is required, either from BlockNoteContext or as a function argument", ); } - return editor.onSelectionChange(callback, includeSelectionChangedByRemote); - }, [callback, editor, includeSelectionChangedByRemote]); + return editor.onSelectionChange( + () => callbackRef.current(), + includeSelectionChangedByRemote, + ); + }, [editor, includeSelectionChangedByRemote]); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c8689667b2..e5ba94c223 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -134,6 +134,8 @@ export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; export * from "./hooks/useEditorChange.js"; +export * from "./hooks/useEditorFocus.js"; +export * from "./hooks/useEditorFocusChange.js"; export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; diff --git a/packages/shadcn/src/form/Form.tsx b/packages/shadcn/src/form/Form.tsx index 0ad9930b0e..c8092626a4 100644 --- a/packages/shadcn/src/form/Form.tsx +++ b/packages/shadcn/src/form/Form.tsx @@ -2,9 +2,18 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => { - const { children, ...rest } = props; + const { children, onSubmit, ...rest } = props; assertEmpty(rest); - return <>{children}; + return ( + { + event.preventDefault(); + onSubmit?.(); + }} + > + {children} + + ); }; diff --git a/packages/shadcn/src/form/TextInput.tsx b/packages/shadcn/src/form/TextInput.tsx index 675e7409fa..dd78afc94e 100644 --- a/packages/shadcn/src/form/TextInput.tsx +++ b/packages/shadcn/src/form/TextInput.tsx @@ -1,6 +1,6 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; import { cn } from "../lib/utils.js"; @@ -30,6 +30,29 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + const ShadCNComponents = useShadCNComponentsContext()!; return ( @@ -51,14 +74,20 @@ export const TextInput = forwardRef< className={cn(className, "h-auto border-none p-0")} id={label} name={name} - autoFocus={autoFocus} placeholder={placeholder} disabled={disabled} value={value} onKeyDown={onKeyDown} onChange={onChange} onSubmit={onSubmit} - ref={ref} + // Belt-and-braces alongside the
in Form.Root. The form is what + // should make the browser treat Enter as a submit; this states it + // outright, so the behaviour doesn't rest on how Chromium scopes its + // "is there a next field to jump to" lookup. Removable once that's + // confirmed on a device — the tell is the keyboard's action key: an + // arrow means it still wants to advance focus. + enterKeyHint="done" + ref={setRefs} aria-activedescendant={ariaActivedescendant} /> diff --git a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx index 7f68224498..de2152a711 100644 --- a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx +++ b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx @@ -114,7 +114,9 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { return (
- + onManualPromptSubmit(promptTextToUse)} + > ; +}; + +/** Identifier tying sessions to the tunnel started by the global setup. */ +export const LOCAL_TUNNEL_ID = "bn-device-tests"; + +function capabilities( + platform: Platform, + deviceName: string, + osVersion: string, +): Record { + const auth = browserStackCredentials(); + return { + browserName: platform === "ios" ? "safari" : "chrome", + "bstack:options": { + userName: auth?.userName, + accessKey: auth?.accessKey, + deviceName, + osVersion, + realMobile: "true", + local: "true", + localIdentifier: LOCAL_TUNNEL_ID, + projectName: "BlockNote device tests", + idleTimeout: 60, + }, + }; +} + +/** + * The device matrix. Chosen to cover both platforms and both major Android IME + * families (this Samsung ships Samsung Keyboard; add a Pixel for Gboard when + * widening the matrix). Every entry costs one real-device session per test + * file per run. + */ +export const DEVICE_TARGETS: DeviceTarget[] = [ + { + id: "android-samsung-galaxy-s22", + platform: "android", + capabilities: capabilities("android", "Samsung Galaxy S22", "12.0"), + }, + { + id: "ios-iphone-16e", + platform: "ios", + capabilities: capabilities("ios", "iPhone 16e", "18"), + }, +]; + +/** Devices selected for this run; narrow with DEVICE_FILTER=. */ +export function activeDevices(): DeviceTarget[] { + const filter = process.env.DEVICE_FILTER; + return filter + ? DEVICE_TARGETS.filter((d) => d.id.includes(filter)) + : DEVICE_TARGETS; +} diff --git a/tests/device/editing.device.test.ts b/tests/device/editing.device.test.ts new file mode 100644 index 0000000000..befbb3d412 --- /dev/null +++ b/tests/device/editing.device.test.ts @@ -0,0 +1,115 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "vite-plus/test"; + +import { activeDevices } from "./devices.js"; +import { pressSoftKeyboardEnter, typeText } from "./lib/gestures.js"; +import { + docState, + EDITOR, + openExample, + startEditing, +} from "./lib/editorPage.js"; +import { browserStackCredentials, DeviceSession } from "./lib/webdriver.js"; + +/** + * Basic text-editing behavior on real devices. These flows go through the + * actual IME wherever it matters: soft-keyboard Enter on Android is delivered + * as keyCode 229 + `beforeinput`, a path that synthetic key events cannot + * exercise and that has broken in the wild (TypeCellOS/BlockNote#3001 — Enter + * inserting a space or doing nothing instead of creating a block). + */ +for (const device of activeDevices()) { + describe.skipIf(!browserStackCredentials())( + `basic editing on ${device.id}`, + () => { + let session: DeviceSession; + let failed = false; + + beforeAll(async () => { + const capabilities = structuredClone(device.capabilities) as { + "bstack:options": Record; + }; + capabilities["bstack:options"].sessionName = + `basic editing · ${device.id}`; + session = await DeviceSession.create(device.platform, capabilities); + await openExample(session, "/ui-components/mobile-formatting-toolbar"); + }); + + afterEach(({ task }) => { + if (task.result?.state === "fail") { + failed = true; + } + }); + + afterAll(async () => { + if (session) { + await session.screenshot(`editing-final`); + await session.annotate( + failed ? "failed" : "passed", + failed + ? "basic editing suite failed; see run output" + : "typing + soft-keyboard Enter passed", + ); + await session.close(); + } + }); + + test("typing lands in the document", async () => { + await startEditing(session); + const before = await docState(session); + + await typeText(session, EDITOR, "bndevicetyping"); + + const after = await session.waitFor<{ ok: boolean; text: string }>( + "typed text present", + `const editor = document.querySelector(${JSON.stringify(EDITOR)}); + return { ok: editor.textContent.includes("bndevicetyping"), text: editor.textContent.slice(0, 120) };`, + ); + expect(after.ok).toBe(true); + // Typing must not have destroyed surrounding content. + expect((await docState(session)).blockCount).toBeGreaterThanOrEqual( + before.blockCount, + ); + }); + + test("soft-keyboard Enter creates a new block (#3001)", async () => { + await startEditing(session); + const before = await docState(session); + + // "Any observable document mutation" stops the key-position ladder; + // what the mutation *was* is classified below. + await pressSoftKeyboardEnter( + session, + `const editor = document.querySelector(${JSON.stringify(EDITOR)}); + const blocks = editor.querySelectorAll('[data-node-type="blockContainer"]').length; + return { ok: blocks !== ${before.blockCount} || editor.textContent !== ${JSON.stringify(before.text)} };`, + ); + + const after = await docState(session); + await session.screenshot("after-soft-enter"); + + // Classify the IME's effect so a failure names the bug it found: + // - block count +1 -> correct + // - text grew by a space -> the #3001 signature + // - text shrank -> the ladder hit backspace; key ratios need + // tuning for this device (see gestures.ts) + const gainedSpace = + after.blockCount === before.blockCount && + after.text.length === before.text.length + 1 && + after.text.includes(" "); + expect( + after.blockCount, + gainedSpace + ? "soft Enter inserted a space instead of a new block (TypeCellOS/BlockNote#3001)" + : `soft Enter did not create a block (text before: ${JSON.stringify(before.text.slice(0, 60))}, after: ${JSON.stringify(after.text.slice(0, 60))})`, + ).toBe(before.blockCount + 1); + }); + }, + ); +} diff --git a/tests/device/formattingToolbar.device.test.ts b/tests/device/formattingToolbar.device.test.ts new file mode 100644 index 0000000000..8549d776f0 --- /dev/null +++ b/tests/device/formattingToolbar.device.test.ts @@ -0,0 +1,173 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "vite-plus/test"; + +import { activeDevices } from "./devices.js"; +import { tapElement, typeAndSubmit } from "./lib/gestures.js"; +import { + docState, + LINK_POPOVER, + MOBILE_TOOLBAR, + openExample, + openLinkPopover, + selectFirstWord, + startEditing, + viewportHeight, +} from "./lib/editorPage.js"; +import { browserStackCredentials, DeviceSession } from "./lib/webdriver.js"; + +const KEYBOARD_MIN_HEIGHT = 150; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +for (const device of activeDevices()) { + describe.skipIf(!browserStackCredentials())( + `mobile formatting toolbar on ${device.id}`, + () => { + let session: DeviceSession; + let baselineHeight: number; + let failed = false; + + beforeAll(async () => { + const capabilities = structuredClone(device.capabilities) as { + "bstack:options": Record; + }; + capabilities["bstack:options"].sessionName = + `formatting toolbar · ${device.id}`; + session = await DeviceSession.create(device.platform, capabilities); + await openExample(session, "/ui-components/mobile-formatting-toolbar"); + baselineHeight = await viewportHeight(session); + }); + + afterEach(({ task }) => { + if (task.result?.state === "fail") { + failed = true; + } + }); + + afterAll(async () => { + if (session) { + await session.screenshot(`formatting-toolbar-final`); + await session.annotate( + failed ? "failed" : "passed", + failed + ? "formatting toolbar suite failed; see run output" + : "keyboard/toolbar lifecycle + link popover flow passed", + ); + await session.close(); + } + }); + + test("tapping the editor opens the keyboard and shows the mobile toolbar", async () => { + await startEditing(session); + + // The toolbar only renders while `useVirtualKeyboard` sees the + // keyboard, so its presence + the viewport drop prove the real + // on-screen keyboard opened. + expect(await viewportHeight(session)).toBeLessThan( + baselineHeight - KEYBOARD_MIN_HEIGHT, + ); + }); + + test("toolbar buttons apply reliably", async () => { + await startEditing(session); + await selectFirstWord(session); + // Three bold toggles; every tap must register (covers the reported + // "buttons sometimes don't work", which traced back to a lingering + // popover overlaying the toolbar). + for (const expected of [true, false, true]) { + await tapElement(session, `${MOBILE_TOOLBAR} [data-test="bold"]`, { + keyboard: "open", + verify: `return { ok: ${expected} === !!document.querySelector('.bn-editor strong') };`, + }); + } + }); + + test("link popover holds focus through the IME and creates a link", async () => { + // Captured before the popover opens: iOS Safari auto-zooms the page + // when an input with a computed font-size under 16px takes focus, and + // that zoom perturbs the visual viewport the mobile toolbar positions + // itself from. The `pointer: coarse` rule in blocknoteStyles.css + // prevents it; this pins the behaviour rather than the rule. + const scaleBefore = await session.exec( + `return window.visualViewport ? window.visualViewport.scale : 1;`, + ); + + await openLinkPopover(session); + + // Focusing an input makes the IME reconfigure (on Android this + // resizes the viewport), which historically hid the popover and + // collapsed the keyboard/toolbar (the Mantine `hideDetached` bug). + // The input must still hold focus once that settles. + await sleep(2_500); + const survival = await session.exec<{ + focused: boolean; + popover: boolean; + toolbar: boolean; + }>(` + const active = document.activeElement; + return { + focused: !!(active && active.tagName === 'INPUT' && active.getAttribute('name') === 'url'), + popover: !!document.querySelector(${JSON.stringify(LINK_POPOVER)}), + toolbar: !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}), + };`); + await session.screenshot("link-popover-open"); + + // Focusing the URL input must not have zoomed the page. + const scaleAfter = await session.exec( + `return window.visualViewport ? window.visualViewport.scale : 1;`, + ); + expect( + scaleAfter, + `focusing the URL input zoomed the page (${scaleBefore} -> ${scaleAfter}); ` + + `check the pointer:coarse font-size rule for .bn-form-popover inputs`, + ).toBeLessThanOrEqual(scaleBefore + 0.01); + + expect(survival).toEqual({ + focused: true, + popover: true, + toolbar: true, + }); + + await typeAndSubmit(session, `${LINK_POPOVER} input`, "example.com"); + + await session.waitFor( + "link created and popover closed", + `return { + ok: !!document.querySelector('.bn-editor a[href="https://example.com"]') + && !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + link: !!document.querySelector('.bn-editor a[href="https://example.com"]'), + popoverGone: !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + };`, + ); + + expect((await docState(session)).links).toContain( + "https://example.com", + ); + // Submitting must not dismiss the keyboard — but Appium's typing can + // itself hide the keyboard as an automation side effect (observed on + // Android), which the product can't distinguish from the user closing + // it. So only assert the toolbar survived while the keyboard is + // actually still up; the emulation suite covers this invariant + // deterministically. + if ( + (await viewportHeight(session)) < + baselineHeight - KEYBOARD_MIN_HEIGHT + ) { + expect( + await session.exec( + `return !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});`, + ), + ).toBe(true); + } + }); + }, + ); +} diff --git a/tests/device/lib/editorPage.ts b/tests/device/lib/editorPage.ts new file mode 100644 index 0000000000..f86eaef173 --- /dev/null +++ b/tests/device/lib/editorPage.ts @@ -0,0 +1,149 @@ +/** + * BlockNote page helpers for device tests: everything here speaks in editor + * concepts (blocks, toolbar, popovers) and hides the gesture mechanics. + * + * The pages under test are the playground examples, reached through the + * tunnel origin provided by the global setup (`PROXY_ORIGIN`). + */ +import { tapElement } from "./gestures.js"; +import type { DeviceSession } from "./webdriver.js"; + +export const PROXY_PORT = 45178; +/** `bs-local.com` resolves to the test runner through the BrowserStack tunnel. */ +export const PROXY_ORIGIN = `http://bs-local.com:${PROXY_PORT}`; + +export const EDITOR = ".bn-editor"; +export const PARAGRAPH = ".bn-editor .bn-inline-content"; +export const MOBILE_TOOLBAR = ".bn-mobile-formatting-toolbar"; +export const LINK_BUTTON = `${MOBILE_TOOLBAR} [data-test="createLink"]`; +export const LINK_POPOVER = ".bn-form-popover"; +export const BLOCK = '.bn-editor [data-node-type="blockContainer"]'; + +export async function openExample( + session: DeviceSession, + route: string, +): Promise { + // Cold dev-server transforms through the tunnel can stall a first load; + // one reload recovers it. + for (let attempt = 0; attempt < 2; attempt++) { + await session.navigate(`${PROXY_ORIGIN}${route}`); + try { + await session.waitFor( + "editor rendered", + `return { ok: !!document.querySelector(${JSON.stringify(PARAGRAPH)}) };`, + 60_000, + ); + return; + } catch (error) { + if (attempt === 1) { + throw error; + } + } + } +} + +export type DocState = { + blockCount: number; + text: string; + links: string[]; +}; + +/** Snapshot of the first editor's document, for before/after assertions. */ +export async function docState(session: DeviceSession): Promise { + return await session.exec(` + const editor = document.querySelector(${JSON.stringify(EDITOR)}); + return { + blockCount: editor.querySelectorAll('[data-node-type="blockContainer"]').length, + text: editor.textContent, + links: [...editor.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')), + };`); +} + +/** Viewport height; a drop of >150 CSS px from baseline = keyboard open. */ +export async function viewportHeight(session: DeviceSession): Promise { + return await session.exec( + `return Math.round(visualViewport.height);`, + ); +} + +/** + * Taps into the editor so the on-screen keyboard opens and the mobile toolbar + * appears. Safe to call when already editing. + */ +export async function startEditing(session: DeviceSession): Promise { + const already = await session.exec( + `return !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});`, + ); + if (already) { + return; + } + await session.exec( + `document.querySelector(${JSON.stringify(PARAGRAPH)}).scrollIntoView({ block: 'center' });`, + ); + await tapElement(session, PARAGRAPH, { + keyboard: "closed", + verify: `return { ok: !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}) };`, + verifyTimeoutMs: 15_000, + }); +} + +/** + * Selects the first word of the first paragraph via a DOM range (ProseMirror + * syncs its selection from `selectionchange`, so no editor handle is needed). + * iOS intermittently collapses programmatic selections, so the wait re-applies + * the range on every poll until the toolbar's link button confirms the editor + * sees a non-empty selection. + */ +export async function selectFirstWord(session: DeviceSession): Promise { + const applyAndCheck = ` + if (getSelection().isCollapsed) { + const p = document.querySelector(${JSON.stringify(PARAGRAPH)}); + const textNode = [...p.childNodes].find((n) => n.nodeType === 3) || p.firstChild; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, Math.min(7, textNode.textContent.length)); + const selection = getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + return { + ok: !getSelection().isCollapsed + && !!document.querySelector(${JSON.stringify(LINK_BUTTON)}), + };`; + await session.waitFor("selection + link button", applyAndCheck, 25_000); +} + +/** + * Opens the create-link popover from the mobile toolbar and waits for its URL + * input to hold focus. A mis-aimed tap (iOS chrome-offset guessing) can hit + * the keyboard's accessory bar and collapse the whole editing state, so each + * attempt rebuilds editing + selection from scratch before tapping. + */ +export async function openLinkPopover(session: DeviceSession): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt < 4; attempt++) { + await startEditing(session); + await selectFirstWord(session); + await session.exec(` + const toolbar = document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}); + toolbar.querySelectorAll('*').forEach((el) => { + if (el.scrollWidth > el.clientWidth + 5) el.scrollLeft = el.scrollWidth; + });`); + try { + await tapElement(session, LINK_BUTTON, { + keyboard: "open", + verify: ` + const active = document.activeElement; + return { + ok: !!document.querySelector(${JSON.stringify(LINK_POPOVER)}) + && active && active.tagName === 'INPUT' + && active.getAttribute('name') === 'url', + };`, + }); + return; + } catch (error) { + lastError = error as Error; + } + } + throw new Error(`Could not open the link popover: ${lastError?.message}`); +} diff --git a/tests/device/lib/gestures.ts b/tests/device/lib/gestures.ts new file mode 100644 index 0000000000..9484c65339 --- /dev/null +++ b/tests/device/lib/gestures.ts @@ -0,0 +1,191 @@ +/** + * Platform input layer: every quirk of delivering *genuine* user input on real + * devices lives here, so tests and page helpers stay declarative. + * + * The hard-won iOS facts this module encodes: + * - Safari ignores WebDriver element clicks (synthetic events) and even + * trusted injected W3C touch events for focus/keyboard purposes. Only the + * Appium native-layer tap works. + * - Native taps take screen points = CSS position plus Safari's top chrome, + * which is ~100pt with the keyboard closed (URL bar visible) and ~45-50pt + * with it open (chrome minimized). `getBoundingClientRect()` values are + * already visually correct — do NOT subtract `visualViewport.offsetTop`. + * - A tap that lands ~50pt below a target near the keyboard hits the keyboard + * accessory bar (its "Done" button dismisses the keyboard and collapses the + * whole editing state), so mis-taps must be assumed and recovered from. + */ +import type { DeviceSession } from "./webdriver.js"; + +/** Candidate Safari top-chrome offsets (screen pt), most likely first. */ +const IOS_CHROME_OFFSETS = { + keyboardClosed: [100, 90, 110, 80], + keyboardOpen: [50, 45, 55, 100], +} as const; + +export type KeyboardState = "open" | "closed"; + +/** + * Taps an element. Android uses a plain element click (reliable there); iOS + * walks the chrome-offset ladder with a native tap per candidate, using + * `verify` (a page script returning `{ ok: boolean }`) to detect a hit. + * On iOS a `verify` script is required — without one a mis-aimed tap cannot + * be detected. + */ +export async function tapElement( + session: DeviceSession, + css: string, + options: { + keyboard: KeyboardState; + verify: string; + verifyTimeoutMs?: number; + }, +): Promise { + if (session.platform === "android") { + await session.elementClick(css); + await session.waitFor( + `tap on ${css}`, + options.verify, + options.verifyTimeoutMs ?? 10_000, + ); + return; + } + + const offsets = + IOS_CHROME_OFFSETS[ + options.keyboard === "open" ? "keyboardOpen" : "keyboardClosed" + ]; + for (const offset of offsets) { + const point = await session.exec<{ x: number; y: number }>( + `const b = document.querySelector(arguments[0]).getBoundingClientRect(); + return { x: b.x + Math.min(40, b.width / 2), y: b.y + b.height / 2 };`, + [css], + ); + await session.nativeTap(point.x, point.y + offset); + try { + await session.waitFor( + `tap on ${css} (chrome offset ${offset})`, + options.verify, + options.verifyTimeoutMs ?? 6_000, + ); + return; + } catch { + // Mis-aimed; the caller's flow may need to recover editing state, which + // `verify` scripts typically encode. Try the next offset. + } + } + throw new Error(`No chrome offset produced a verified tap on ${css}`); +} + +/** + * Position of the iOS keyboard's return key, as fractions of the full screen + * (measured on iPhone 16e; return stays bottom-right across iPhones). Android + * doesn't need coordinates — see the key-event convergence note in + * `pressSoftKeyboardEnter`. Override per-run with SOFT_ENTER_X / SOFT_ENTER_Y + * when adding an exotic device. + */ +const RETURN_KEY_RATIOS = { + ios: [ + { x: 0.88, y: 0.88 }, + { x: 0.9, y: 0.91 }, + { x: 0.88, y: 0.85 }, + ], +}; + +/** + * Presses Enter/return on the *on-screen keyboard* with a native tap. + * + * This is deliberately not a WebDriver key event: soft-keyboard Enter goes + * through the IME (keyCode 229 + `beforeinput` on Android), which is exactly + * the path that breaks in bugs like TypeCellOS/BlockNote#3001 while synthetic + * key events keep working. `verify` receives the page state after each tap + * attempt; return `{ ok: true }` once the expected mutation is observed. + * + * The keyboard must be open when calling this. + */ +export async function pressSoftKeyboardEnter( + session: DeviceSession, + verify: string, +): Promise { + if (session.platform === "android") { + // A WebDriver Enter key event converges on the same production code path + // as the soft keyboard's Enter here: prosemirror-view ignores Enter + // keydowns on Android Chrome entirely, so handling proceeds through the + // `beforeinput` (insertParagraph) the browser emits — the exact path the + // IME takes and where #3001-class bugs live. (BrowserStack blocks the + // higher-fidelity options: `mobile: shell` needs an insecure-feature + // opt-in and `clickGesture` isn't allowlisted.) + await session.typeKeys("\uE007"); + await session.waitFor("soft Enter effect", verify, 8_000); + return; + } + const override = + process.env.SOFT_ENTER_X && process.env.SOFT_ENTER_Y + ? [ + { + x: Number(process.env.SOFT_ENTER_X), + y: Number(process.env.SOFT_ENTER_Y), + }, + ] + : undefined; + const candidates = override ?? RETURN_KEY_RATIOS.ios; + + // iOS native taps take screen points (CSS px scale). + const metrics = await session.exec<{ width: number; height: number }>( + `return { width: screen.width, height: screen.height };`, + ); + + let lastError: Error | undefined; + for (const ratio of candidates) { + await session.nativeTap(metrics.width * ratio.x, metrics.height * ratio.y); + try { + await session.waitFor("soft Enter effect", verify, 5_000); + return; + } catch (error) { + lastError = error as Error; + } + } + throw new Error( + `Soft Enter was not observed to take effect: ${lastError?.message}`, + ); +} + +/** + * Types into an input and submits it. Android's value endpoint commits the + * field's action implicitly; iOS gets a dispatched Enter keydown, which React + * handlers process. See `DeviceSession.elementValue` for the fidelity caveat; + * use this for setup steps, not for asserting IME behavior. + */ +/** + * Types plain text into the editor's contenteditable. Android's value endpoint + * handles contenteditables; iOS Safari's does not, but protocol key events do. + */ +export async function typeText( + session: DeviceSession, + editorCss: string, + text: string, +): Promise { + if (session.platform === "android") { + await session.elementValue(editorCss, text); + } else { + await session.typeKeys(text); + } +} + +export async function typeAndSubmit( + session: DeviceSession, + css: string, + text: string, +): Promise { + await session.elementValue(css, text); + // Submit with a dispatched Enter keydown on both platforms: Android's value + // endpoint *sometimes* commits the field's action implicitly and iOS never + // does, so relying on the implicit commit is nondeterministic. React's + // handlers process the dispatched event either way. + await session.exec( + `const el = document.querySelector(arguments[0]); + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + }`, + [css], + ); +} diff --git a/tests/device/lib/tunnel.ts b/tests/device/lib/tunnel.ts new file mode 100644 index 0000000000..49c58ee487 --- /dev/null +++ b/tests/device/lib/tunnel.ts @@ -0,0 +1,141 @@ +/** + * Vitest global setup: makes the locally served playground reachable from + * BrowserStack real devices. + * + * Two pieces: + * 1. A host-rewriting proxy in front of the app server — devices browse + * `http://bs-local.com:`, and Vite's `allowedHosts` check + * rejects that Host header, so the proxy forwards with a localhost Host. + * 2. The BrowserStackLocal tunnel daemon, which resolves `bs-local.com` on + * the device back to this machine. The binary is downloaded on first use + * into tests/device/.cache (gitignored). + */ +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import { join } from "node:path"; + +import { LOCAL_TUNNEL_ID } from "../devices.js"; +import { browserStackCredentials } from "./webdriver.js"; +import { PROXY_PORT } from "./editorPage.js"; + +const CACHE_DIR = join(import.meta.dirname, "..", ".cache"); +const BINARY = join(CACHE_DIR, "BrowserStackLocal"); + +function targetOrigin(): string { + return process.env.DEVICE_TEST_TARGET ?? "http://127.0.0.1:5173"; +} + +async function ensureAppServer(): Promise { + const res = await fetch(targetOrigin(), { redirect: "manual" }).catch( + () => undefined, + ); + if (!res) { + throw new Error( + `No app server at ${targetOrigin()}. Start the playground (\`pnpm run dev\`) ` + + `or point DEVICE_TEST_TARGET at a running server.`, + ); + } +} + +function startProxy(): http.Server { + const server = http.createServer(async (req, res) => { + try { + // Dev servers occasionally stall on cold transforms; a bounded retry + // beats a device-side page load hanging forever mid-progress. + let upstream: Response | undefined; + for (let attempt = 0; attempt < 2 && !upstream; attempt++) { + upstream = await fetch(`${targetOrigin()}${req.url}`, { + headers: { + accept: req.headers["accept"] ?? "*/*", + host: new URL(targetOrigin()).host, + }, + signal: AbortSignal.timeout(20_000), + }).catch((error) => { + if (attempt === 1) { + throw error; + } + return undefined; + }); + } + if (!upstream) { + throw new Error("upstream fetch failed"); + } + const body = Buffer.from(await upstream.arrayBuffer()); + const headers: Record = {}; + for (const name of ["content-type", "cache-control"]) { + const value = upstream.headers.get(name); + if (value) { + headers[name] = value; + } + } + res.writeHead(upstream.status, headers); + res.end(body); + } catch (error) { + res.writeHead(502); + res.end(String(error)); + } + }); + server.listen(PROXY_PORT, "127.0.0.1"); + return server; +} + +async function ensureLocalBinary(): Promise { + if (existsSync(BINARY)) { + return; + } + const platform = process.platform === "darwin" ? "darwin-x64" : "linux-x64"; + const url = `https://www.browserstack.com/browserstack-local/BrowserStackLocal-${platform}.zip`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download BrowserStackLocal: ${res.status}`); + } + mkdirSync(CACHE_DIR, { recursive: true }); + const zipPath = join(CACHE_DIR, "BrowserStackLocal.zip"); + writeFileSync(zipPath, Buffer.from(await res.arrayBuffer())); + const unzip = spawnSync("unzip", ["-o", zipPath, "-d", CACHE_DIR], { + encoding: "utf8", + }); + if (unzip.status !== 0) { + throw new Error(`unzip failed: ${unzip.stderr}`); + } + chmodSync(BINARY, 0o755); +} + +function tunnelCommand(action: "start" | "stop", accessKey: string): void { + const result = spawnSync( + BINARY, + [ + "--key", + accessKey, + "--local-identifier", + LOCAL_TUNNEL_ID, + "--daemon", + action, + ], + { encoding: "utf8", timeout: 60_000 }, + ); + if (action === "start" && !result.stdout.includes('"connected"')) { + throw new Error( + `BrowserStackLocal did not connect: ${result.stdout} ${result.stderr}`, + ); + } +} + +export default async function setup(): Promise<(() => void) | void> { + const auth = browserStackCredentials(); + if (!auth) { + // The suites self-skip without credentials; nothing to set up. + return; + } + + await ensureAppServer(); + const proxy = startProxy(); + await ensureLocalBinary(); + tunnelCommand("start", auth.accessKey); + + return () => { + tunnelCommand("stop", auth.accessKey); + proxy.close(); + }; +} diff --git a/tests/device/lib/webdriver.ts b/tests/device/lib/webdriver.ts new file mode 100644 index 0000000000..fb3e362cfc --- /dev/null +++ b/tests/device/lib/webdriver.ts @@ -0,0 +1,209 @@ +/** + * Dependency-free WebDriver REST client for BrowserStack real-device sessions. + * + * Deliberately not WebdriverIO/Appium-client based: the handful of endpoints + * we need (session, execute, element, actions, screenshot) are stable W3C + * WebDriver routes, and a plain `fetch` client keeps the device suite free of + * its own dependency tree. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type Platform = "android" | "ios"; + +const HUB = "https://hub-cloud.browserstack.com/wd/hub"; +const ARTIFACTS_DIR = join(import.meta.dirname, "..", ".artifacts"); + +export function browserStackCredentials(): + | { userName: string; accessKey: string } + | undefined { + const userName = process.env.BROWSERSTACK_USERNAME; + const accessKey = process.env.BROWSERSTACK_ACCESS_KEY; + return userName && accessKey ? { userName, accessKey } : undefined; +} + +export class DeviceSession { + private constructor( + public readonly sessionId: string, + public readonly platform: Platform, + private readonly auth: { userName: string; accessKey: string }, + ) {} + + static async create( + platform: Platform, + capabilities: Record, + ): Promise { + const auth = browserStackCredentials(); + if (!auth) { + throw new Error( + "BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY must be set", + ); + } + // Device allocation occasionally hiccups; one retry absorbs it. + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${HUB}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }), + }); + const json = (await res.json()) as { + value: { sessionId: string; error?: string; message?: string }; + }; + if (res.ok) { + return new DeviceSession(json.value.sessionId, platform, auth); + } + if (attempt === 1) { + throw new Error( + `BrowserStack session creation failed: ${JSON.stringify(json).slice(0, 400)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + } + + private async request(method: string, path: string, body?: unknown) { + const res = await fetch(`${HUB}/session/${this.sessionId}${path}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as { value?: unknown }; + if (!res.ok) { + throw new Error( + `${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 300)}`, + ); + } + return json.value; + } + + async navigate(url: string): Promise { + await this.request("POST", "/url", { url }); + } + + /** Runs a script in the page. The script body may use `arguments`. */ + async exec(script: string, args: unknown[] = []): Promise { + return (await this.request("POST", "/execute/sync", { + script, + args, + })) as T; + } + + /** + * Polls a page script until it returns `{ ok: true, ... }`. Returns the + * final result; throws with the last observed value on timeout so failures + * carry the page state they timed out on. + */ + async waitFor( + label: string, + script: string, + timeoutMs = 20_000, + ): Promise { + const start = Date.now(); + let last: T | undefined; + while (Date.now() - start < timeoutMs) { + last = await this.exec(script); + if (last && last.ok) { + return last; + } + await new Promise((resolve) => setTimeout(resolve, 700)); + } + throw new Error( + `Timed out at "${label}": ${JSON.stringify(last).slice(0, 300)}`, + ); + } + + private async findElement(css: string): Promise { + const el = (await this.request("POST", "/element", { + using: "css selector", + value: css, + })) as Record; + return Object.values(el)[0]; + } + + /** + * WebDriver element click. Sufficient on Android; on iOS Safari the + * resulting events are synthetic and never move focus or open the keyboard — + * use `nativeTap` (via the gestures module) there instead. + */ + async elementClick(css: string): Promise { + await this.request("POST", `/element/${await this.findElement(css)}/click`); + } + + /** + * Types into an element via the WebDriver value endpoint. Fidelity caveat: + * this inserts text through the automation layer, not by tapping keys on the + * on-screen keyboard, so IME-specific behavior (autocorrect, composition, + * the soft Enter key) is not exercised. On Android it also commits the + * field's action, on iOS it does not. + */ + async elementValue(css: string, text: string): Promise { + await this.request( + "POST", + `/element/${await this.findElement(css)}/value`, + { text }, + ); + } + + /** + * OS-level tap through the Appium driver — the only input that iOS Safari + * honors for focus/keyboard purposes, and the only way to press keys on the + * on-screen keyboard on either platform. + * + * Coordinates are screen points on iOS (CSS px scale) and physical pixels on + * Android. + */ + async nativeTap(x: number, y: number): Promise { + const command = + this.platform === "ios" ? "mobile: tap" : "mobile: clickGesture"; + await this.exec(command, [{ x: Math.round(x), y: Math.round(y) }]); + } + + /** Sends W3C key actions (protocol-level key events) to the focused element. */ + async typeKeys(text: string): Promise { + const actions: { type: string; value: string }[] = []; + for (const character of text) { + actions.push({ type: "keyDown", value: character }); + actions.push({ type: "keyUp", value: character }); + } + await this.request("POST", "/actions", { + actions: [{ type: "key", id: "keyboard", actions }], + }); + await this.request("DELETE", "/actions").catch(() => {}); + } + + /** Saves a PNG screenshot under tests/device/.artifacts. */ + async screenshot(name: string): Promise { + const b64 = (await this.request("GET", "/screenshot")) as string; + mkdirSync(ARTIFACTS_DIR, { recursive: true }); + const file = join(ARTIFACTS_DIR, `${this.platform}-${name}.png`); + writeFileSync(file, Buffer.from(b64, "base64")); + return file; + } + + /** Marks the session passed/failed on the BrowserStack dashboard. */ + async annotate(status: "passed" | "failed", reason: string): Promise { + await fetch( + `https://api.browserstack.com/automate/sessions/${this.sessionId}.json`, + { + method: "PUT", + headers: { + "content-type": "application/json", + authorization: + "Basic " + + Buffer.from( + `${this.auth.userName}:${this.auth.accessKey}`, + ).toString("base64"), + }, + body: JSON.stringify({ status, reason: reason.slice(0, 250) }), + }, + ).catch(() => { + // Annotation is cosmetic; never fail a test run over it. + }); + } + + async close(): Promise { + await this.request("DELETE", "").catch(() => { + // The session may already have timed out server-side. + }); + } +} diff --git a/tests/device/vitest.config.mts b/tests/device/vitest.config.mts new file mode 100644 index 0000000000..d058ed078b --- /dev/null +++ b/tests/device/vitest.config.mts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite-plus"; + +/** + * Real-device suite (BrowserStack). Not part of the workspace projects on + * purpose: it costs device minutes and needs credentials, so it only runs via + * `pnpm run test:device` (locally or from the device-tests workflow). + */ +export default defineConfig({ + root: import.meta.dirname, + test: { + include: ["**/*.device.test.ts"], + globalSetup: ["./lib/tunnel.ts"], + // Real-device sessions are slow to create and drive. + testTimeout: 240_000, + hookTimeout: 180_000, + teardownTimeout: 60_000, + // One retry absorbs genuine device flake (session allocation, tunnel + // hiccups) without hiding real regressions. + retry: 1, + // Serial keeps BrowserStack parallel-session usage predictable; raise via + // maxConcurrency/fileParallelism once the matrix outgrows the plan. + fileParallelism: false, + passWithNoTests: true, + }, +}); diff --git a/tests/src/end-to-end/copypaste/copypaste.test.tsx b/tests/src/end-to-end/copypaste/copypaste.test.tsx index eb5400db18..930dd45f9c 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.tsx +++ b/tests/src/end-to-end/copypaste/copypaste.test.tsx @@ -25,6 +25,11 @@ import { import { getRect, mouseSequence } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +// The android browser instance runs this suite too (see +// vite.config.browser.ts); tests that drive selection or resizing with +// positional mouse drags don't translate to the touch-emulated context: +const onAndroid = /android/i.test(navigator.userAgent); + describe("Check Copy/Paste Functionality", () => { beforeEach(async () => { await render(); @@ -128,51 +133,53 @@ describe("Check Copy/Paste Functionality", () => { }, ); - test.skipIf(browserName === "firefox" || browserName === "webkit")( - "Images should keep props", - async () => { - await focusOnEditor(); - await userEvent.keyboard("paragraph"); - - const IMAGE_EMBED_URL = "https://placehold.co/800x540.png"; - await executeSlashCommand("image"); - - await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); - await userEvent.click(await waitForSelector(`[data-test="embed-input"]`)); - await userEvent.keyboard(IMAGE_EMBED_URL); - await userEvent.click( - await waitForSelector(`[data-test="embed-input-button"]`), - ); - await waitForSelector(`img[src="${IMAGE_EMBED_URL}"]`); - - await userEvent.click(await waitForSelector(`img`)); - - await waitForSelector(`[class*="bn-resize-handle"][style*="right"]`); - const resizeHandleBoundingBox = getRect( - `[class*="bn-resize-handle"][style*="right"]`, - ); - await mouseSequence([ - { - type: "move", - x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, - y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, - steps: 5, - }, - { type: "down" }, - { - type: "move", - x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2 - 50, - y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, - steps: 5, - }, - { type: "up" }, - ]); - - await copyPaste(); - - await compareDocToSnapshot("images"); - }, - ); + // Skipped on android: sets previewWidth by mouse-dragging the resize + // handle, which doesn't operate under touch emulation, so the prop is + // legitimately absent from the pasted result. + test.skipIf( + browserName === "firefox" || browserName === "webkit" || onAndroid, + )("Images should keep props", async () => { + await focusOnEditor(); + await userEvent.keyboard("paragraph"); + + const IMAGE_EMBED_URL = "https://placehold.co/800x540.png"; + await executeSlashCommand("image"); + + await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); + await userEvent.click(await waitForSelector(`[data-test="embed-input"]`)); + await userEvent.keyboard(IMAGE_EMBED_URL); + await userEvent.click( + await waitForSelector(`[data-test="embed-input-button"]`), + ); + await waitForSelector(`img[src="${IMAGE_EMBED_URL}"]`); + + await userEvent.click(await waitForSelector(`img`)); + + await waitForSelector(`[class*="bn-resize-handle"][style*="right"]`); + const resizeHandleBoundingBox = getRect( + `[class*="bn-resize-handle"][style*="right"]`, + ); + await mouseSequence([ + { + type: "move", + x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, + y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, + steps: 5, + }, + { type: "down" }, + { + type: "move", + x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2 - 50, + y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, + steps: 5, + }, + { type: "up" }, + ]); + + await copyPaste(); + + await compareDocToSnapshot("images"); + }); }); describe("Check Copy/Paste From Non-Editable Block", () => { @@ -183,32 +190,34 @@ describe("Check Copy/Paste From Non-Editable Block", () => { // Firefox doesn't yet support the async clipboard API. Webkit copy/paste // stopped working after updating to Playwright 1.33. - test.skipIf(browserName === "firefox" || browserName === "webkit")( - "Should be able to copy/paste text from a non-editable block", - async () => { - // Click and drag across the non-editable block's text to select part of it. - const box = getRect('[data-content-type="nonEditable"] p'); - await mouseSequence([ - { type: "move", x: box.x + 2, y: box.y + box.height / 2 }, - { type: "down" }, - { - type: "move", - x: box.x + box.width * 0.25, - y: box.y + box.height / 2, - steps: 5, - }, - { type: "up" }, - ]); - - await userEvent.keyboard(`{${MOD}>}c{/${MOD}}`); - - // Click the trailing block to create a new empty paragraph and focus - // the editor there. - await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); - - await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`); - - await compareDocToSnapshot("nonEditableBlock"); - }, - ); + // Skipped on android: selects text with a positional mouse drag, which + // doesn't operate under touch emulation — Mod+C then copies nothing and the + // paste emits whatever the previous test left on the shared clipboard. + test.skipIf( + browserName === "firefox" || browserName === "webkit" || onAndroid, + )("Should be able to copy/paste text from a non-editable block", async () => { + // Click and drag across the non-editable block's text to select part of it. + const box = getRect('[data-content-type="nonEditable"] p'); + await mouseSequence([ + { type: "move", x: box.x + 2, y: box.y + box.height / 2 }, + { type: "down" }, + { + type: "move", + x: box.x + box.width * 0.25, + y: box.y + box.height / 2, + steps: 5, + }, + { type: "up" }, + ]); + + await userEvent.keyboard(`{${MOD}>}c{/${MOD}}`); + + // Click the trailing block to create a new empty paragraph and focus + // the editor there. + await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); + + await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`); + + await compareDocToSnapshot("nonEditableBlock"); + }); }); diff --git a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx index c33f704dd2..1a53345f13 100644 --- a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx +++ b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx @@ -22,27 +22,42 @@ beforeEach(async () => { await waitForSelector(EDITOR_SELECTOR); }); -describe("Check Keyboard Handlers' Behaviour", () => { - test("Check Enter when selection is not empty", async () => { - await focusOnEditor(); - await insertHeading(1); - await userEvent.keyboard("{Enter}"); - await insertHeading(2); - - await sleep(500); - - await userEvent.keyboard("{ArrowUp}"); - await userEvent.keyboard(`{${MOD}>}{ArrowLeft}{/${MOD}}`); - await userEvent.keyboard("{ArrowRight}"); - await userEvent.keyboard( - `{Shift>}{ArrowDown}{${MOD}>}{ArrowRight}{/${MOD}}{ArrowLeft}{/Shift}`, - ); +// The android browser instance runs this suite too (see +// vite.config.browser.ts); a couple of tests use idioms that don't transfer: +const onAndroid = /android/i.test(navigator.userAgent); - await userEvent.keyboard("{Enter}"); +describe("Check Keyboard Handlers' Behaviour", () => { + // Skipped on the android instance: the chord-built cross-block selection + // intermittently hasn't synced into ProseMirror state when Enter's + // beforeinput path runs (Android skips PM's pre-keydown DOM flush), so the + // outcome races between split-only and delete+split. Needs its own + // investigation — see the androidEnter tests for the covered Enter paths. + test.skipIf(onAndroid)( + "Check Enter when selection is not empty", + async () => { + await focusOnEditor(); + await insertHeading(1); + await userEvent.keyboard("{Enter}"); + await insertHeading(2); + + await sleep(500); - await compareDocToSnapshot("enterSelectionNotEmpty"); - }); - test("Check Enter preserves marks", async () => { + await userEvent.keyboard("{ArrowUp}"); + await userEvent.keyboard(`{${MOD}>}{ArrowLeft}{/${MOD}}`); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard( + `{Shift>}{ArrowDown}{${MOD}>}{ArrowRight}{/${MOD}}{ArrowLeft}{/Shift}`, + ); + + await userEvent.keyboard("{Enter}"); + + await compareDocToSnapshot("enterSelectionNotEmpty"); + }, + ); + // Skipped on the android instance: drives selection with coordinate + // double-clicks, a mouse idiom that doesn't translate to touch emulation at + // phone width. + test.skipIf(onAndroid)("Check Enter preserves marks", async () => { await focusOnEditor(); await insertHeading(1); diff --git a/tests/src/end-to-end/mobile/androidEnter.test.tsx b/tests/src/end-to-end/mobile/androidEnter.test.tsx new file mode 100644 index 0000000000..a31d29890f --- /dev/null +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -0,0 +1,59 @@ +import App from "@examples/01-basic/testing/src/App"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; +import { + BLOCK_CONTAINER_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +// Runs in the "android" browser instance (Android UA + touch emulation at +// context level — see vite.config.browser.ts), which makes prosemirror-view +// take its Android code path: Enter keydowns are ignored there, and handling +// happens via the `beforeinput` (insertParagraph) the browser emits. PM's own +// fallback — parsing the native DOM split — misparses BlockNote's nested +// block DOM and corrupts the document (TypeCellOS/BlockNote#3001: Enter +// inserting a space, doing nothing, or breaking tables), so BlockNote +// intercepts the `beforeinput` instead (see KeyboardShortcutsExtension). +// This test pins that path. +describe("Enter on Android", () => { + test("beforeinput insertParagraph splits the block", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("First line"); + + const blocksBefore = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + const textBefore = document.querySelector(EDITOR_SELECTOR)!.textContent; + + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + const blocks = document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length; + if (blocks !== blocksBefore + 1) { + throw new Error( + `Enter did not split the block (blocks ${blocksBefore} -> ${blocks})`, + ); + } + }); + // The classic #3001 misbehavior inserts a space or mangles text instead. + expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe( + textBefore, + ); + + await userEvent.keyboard("Second line"); + await vi.waitFor(() => { + if ( + !document + .querySelector(EDITOR_SELECTOR)! + .textContent!.includes("Second line") + ) { + throw new Error("typing after Enter did not land in the new block"); + } + }); + }); +}); diff --git a/tests/src/end-to-end/mobile/linkSubmit.test.tsx b/tests/src/end-to-end/mobile/linkSubmit.test.tsx new file mode 100644 index 0000000000..35b6781b42 --- /dev/null +++ b/tests/src/end-to-end/mobile/linkSubmit.test.tsx @@ -0,0 +1,133 @@ +import App from "@examples/03-ui-components/14-mobile-formatting-toolbar/src/App"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { waitForSelector } from "../../utils/editor.js"; +import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; + +// Submitting the link popover from an editor that is *not* the last on the +// page. Reported from a device: the link was never created and focus jumped +// to the second editor instead. +// +// Coverage limit worth knowing: the device-only half of that bug is which +// action Android's IME assigns to the Enter key. It picks "Next" (advance +// focus, no key event at all) when something focusable follows, and "Done" +// (dispatch Enter) otherwise — which is why it only misbehaved from the first +// editor. No automated environment we have can exercise that: emulation +// always dispatches a real Enter, and on BrowserStack no input channel +// reaches the on-screen keyboard (see tests/device/README.md). The +// `enterkeyhint` assertion below is the only part of it a test can hold onto; +// the rest is a release-checklist item. + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(393, 727); +}); + +afterEach(async () => { + await page.viewport(393, 727); +}); + +describe("Submitting the link popover", () => { + test("creates the link in its own editor and keeps focus there", async () => { + await render(); + await vi.waitFor(() => { + if (document.querySelectorAll(EDITOR_SELECTOR).length < 2) { + throw new Error("expected the example's two editors"); + } + }); + const [first, second] = + document.querySelectorAll(EDITOR_SELECTOR); + + await userEvent.click(first.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + + // The popover must advertise a submitting action to the IME; without it + // Android turns this Enter into a focus advance. + expect(input.getAttribute("enterkeyhint")).toBe("done"); + + await userEvent.click(input); + await userEvent.keyboard("example.com{Enter}"); + + await vi.waitFor(() => { + if (!first.querySelector('a[href="https://example.com"]')) { + throw new Error( + "link was not created in the editor it was opened from", + ); + } + }); + expect(second.querySelector('a[href="https://example.com"]')).toBeNull(); + + // Focus must not have escaped into the other editor. + expect(document.activeElement?.closest(EDITOR_SELECTOR)).not.toBe(second); + }); + + // The path a mobile IME actually takes. When its action key means "submit", + // the browser submits the form — it does not necessarily deliver an Enter + // keydown, so a popover that only listens for that key has no way to + // commit. Driving the form's own submit is how that arrives, and it is the + // part of the device-only bug a test can reproduce: without a real + // wired to a submit handler, nothing happens at all. + test("submitting the form creates the link, without any key event", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + const [first] = document.querySelectorAll(EDITOR_SELECTOR); + + await userEvent.click(first.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + await userEvent.click(input); + await userEvent.keyboard("example.com"); + + const form = input.closest("form"); + expect( + form, + "the popover must be a real , or the browser has no way to " + + "submit it when a mobile IME's action key asks it to", + ).not.toBeNull(); + + // No Enter anywhere: this is the browser submitting the form itself. + form!.requestSubmit(); + + await vi.waitFor(() => { + if (!first.querySelector('a[href="https://example.com"]')) { + throw new Error("submitting the form did not create the link"); + } + }); + }); +}); diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx new file mode 100644 index 0000000000..2fffdc3f38 --- /dev/null +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -0,0 +1,161 @@ +import App from "@examples/01-basic/testing/src/App"; +import { afterEach, beforeEach, describe, test, vi } from "vite-plus/test"; +import { 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 { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; +const LINK_POPOVER_SELECTOR = ".bn-form-popover"; + +// Runs in the "android" browser instance (Android UA + touch emulation at +// context level — see vite.config.browser.ts), so `isTouchDevice()` is +// genuinely true. The on-screen keyboard is emulated by resizing the +// viewport: `useVirtualKeyboard` treats a >150px height drop as the keyboard +// opening — which is exactly how a real keyboard manifests with +// `interactive-widget=resizes-content`. The extra ±60px step mimics Gboard +// showing its suggestion strip when focus moves into an input: the resize +// that used to make Mantine's `hideDetached` hide the link popover, blurring +// its focused input and collapsing the keyboard, toolbar, and popover (the +// Android Chrome bug behind PR #2982). +const VIEWPORT_WIDTH = 393; +const KEYBOARD_CLOSED = 727; +const KEYBOARD_OPEN = 427; +const KEYBOARD_OPEN_WITH_SUGGESTION_STRIP = 367; + +// Lets a viewport resize propagate: the resize event, the floating-ui +// autoUpdate pass it triggers, and React's commit each take a frame. +async function settleFrames(count = 3) { + for (let i = 0; i < count; i++) { + await new Promise(requestAnimationFrame); + } +} + +function activeUrlInput() { + const active = document.activeElement; + return active instanceof HTMLInputElement && active.name === "url" + ? active + : undefined; +} + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await render(); + await waitForSelector(EDITOR_SELECTOR); +}); + +afterEach(async () => { + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); +}); + +describe("Mobile formatting toolbar", () => { + test("shows while the virtual keyboard is open and hides when it closes", async () => { + await focusOnEditor(); + await userEvent.keyboard("Mobile toolbar"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await vi.waitFor(() => { + if (document.querySelector(MOBILE_TOOLBAR_SELECTOR)) { + throw new Error( + "mobile toolbar still visible after the keyboard closed", + ); + } + }); + }); + + test("link popover holds focus through keyboard resizes and creates the link", async () => { + await focusOnEditor(); + await userEvent.keyboard("Link target"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + + // The URL input autofocuses when the popover opens. + await vi.waitFor(() => { + if (!activeUrlInput()) { + throw new Error("URL input did not receive focus on popover open"); + } + }); + + // iOS Safari auto-zooms the page when an input with a computed font-size + // under 16px takes focus, and that zoom perturbs the visual viewport the + // toolbar positions itself from. Emulation can't reproduce the zoom + // itself (it's device behaviour, not engine behaviour — the real-device + // suite asserts visualViewport.scale directly), so this guards the CSS + // contract that prevents it. + { + const fontSize = parseFloat(getComputedStyle(activeUrlInput()!).fontSize); + if (fontSize < 16) { + throw new Error( + `URL input font-size is ${fontSize}px; iOS Safari auto-zooms below ` + + `16px (see the pointer:coarse rule in blocknoteStyles.css)`, + ); + } + } + + // Focusing an input makes the keyboard show its suggestion strip, then + // settle back. The focused input must survive both resizes. + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN_WITH_SUGGESTION_STRIP); + await settleFrames(); + if (!activeUrlInput()) { + throw new Error("URL input lost focus when the suggestion strip resized"); + } + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await settleFrames(); + if (!activeUrlInput()) { + throw new Error( + "URL input lost focus when the suggestion strip resize settled", + ); + } + + await userEvent.keyboard("example.com"); + await userEvent.keyboard("{Enter}"); + + await waitForSelector(`${EDITOR_SELECTOR} a[href="https://example.com"]`); + + // Submitting closes the popover but leaves the toolbar up: on mobile the + // toolbar stays mounted (unlike desktop, which unmounts it and the popover + // with it), so the popover must close itself — the lingering popover + // otherwise covers the toolbar and swallows taps on its buttons. + await vi.waitFor(() => { + if (document.querySelector(LINK_POPOVER_SELECTOR)) { + throw new Error("link popover still open after submitting"); + } + if (!document.querySelector(MOBILE_TOOLBAR_SELECTOR)) { + throw new Error("mobile toolbar disappeared after submitting a link"); + } + }); + + // Reopening the popover with the whole link selected must pre-fill its + // URL: `getSelectedLinkUrl` reads the mark just inside the selection + // start, since a lookup exactly at the link's left boundary misses it. + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + await vi.waitFor(() => { + const input = activeUrlInput(); + if (input?.value !== "https://example.com") { + throw new Error( + `URL input not pre-filled for a fully selected link (value: ${JSON.stringify(input?.value)})`, + ); + } + }); + }); +}); diff --git a/tests/src/end-to-end/mobile/popoverScroll.test.tsx b/tests/src/end-to-end/mobile/popoverScroll.test.tsx new file mode 100644 index 0000000000..f77703be48 --- /dev/null +++ b/tests/src/end-to-end/mobile/popoverScroll.test.tsx @@ -0,0 +1,86 @@ +import App from "@examples/03-ui-components/14-mobile-formatting-toolbar/src/App"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { waitForSelector } from "../../utils/editor.js"; +import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; + +// Uses the mobile-formatting-toolbar example because it is a realistic page: +// long static text with editors partway down, and two of them. Opening a +// toolbar popover there used to reset the page scroll to the top, taking the +// block being edited off screen entirely — the popover's input autofocused +// while floating-ui had not positioned the popover yet, so the browser's +// scroll-into-view chased it to its pre-positioned spot. + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(393, 727); +}); + +afterEach(async () => { + await page.viewport(393, 727); +}); + +describe("Opening a toolbar popover", () => { + test("does not scroll the page away from the block being edited", async () => { + await render(); + await vi.waitFor(() => { + if (document.querySelectorAll(EDITOR_SELECTOR).length < 2) { + throw new Error("expected the example's two editors"); + } + }); + + const editor = document.querySelectorAll(EDITOR_SELECTOR)[0]; + await userEvent.click(editor.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + + // "Keyboard opens". + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + // The example defaults to the pinned scroll-container layout, where that + // element scrolls rather than the document. + const scroller = + document.querySelector(".bn-scroll-container") ?? + document.scrollingElement!; + const scrollBefore = scroller.scrollTop; + const editorTopBefore = editor.getBoundingClientRect().top; + // The regression only shows when the page is actually scrolled. + expect(scrollBefore).toBeGreaterThan(0); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + await vi.waitFor(() => { + if (!document.querySelector('input[name="url"]')) { + throw new Error("link popover did not open"); + } + }); + // Let any scroll-into-view settle before measuring. + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect( + Math.abs(scroller.scrollTop - scrollBefore), + `opening the popover scrolled the page (${scrollBefore} -> ${scroller.scrollTop})`, + ).toBeLessThanOrEqual(2); + expect( + Math.abs(editor.getBoundingClientRect().top - editorTopBefore), + "the edited editor moved on screen when the popover opened", + ).toBeLessThanOrEqual(2); + }); +}); diff --git a/tests/src/end-to-end/mobile/useEditorFocus.test.tsx b/tests/src/end-to-end/mobile/useEditorFocus.test.tsx new file mode 100644 index 0000000000..7ec2a416cd --- /dev/null +++ b/tests/src/end-to-end/mobile/useEditorFocus.test.tsx @@ -0,0 +1,196 @@ +import { + useCreateBlockNote, + useEditorFocus, + useEditorFocusChange, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useRef, useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +// `useEditorFocus` is the state counterpart to `useEditorFocusChange`. What +// needs proving is that it reports *settled* focus and doesn't re-render on +// every focus event in the page — the reasons it exists rather than each +// consumer wiring up useState + useEffect itself. + +function Probe(props: { includeEditorUI: boolean }) { + const editor = useCreateBlockNote(); + return ( + + + + ); +} + +function Readout(props: { includeEditorUI: boolean }) { + const focused = useEditorFocus({ includeEditorUI: props.includeEditorUI }); + const renders = useRef(0); + renders.current += 1; + return ( +
+ ); +} + +function readout() { + return document.querySelector('[data-test="readout"]')!; +} + +function focusedValue() { + return readout().dataset.focused; +} + +afterEach(() => { + document.querySelectorAll(".zz-outside").forEach((el) => el.remove()); +}); + +function addOutsideInput() { + const input = document.createElement("input"); + input.className = "zz-outside"; + document.body.append(input); + return input; +} + +describe("useEditorFocus", () => { + test("reports content focus and blur", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + expect(focusedValue()).toBe("false"); + + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + addOutsideInput().focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + }); + + test("with includeEditorUI, stays focused across a handoff into the editor's UI", async () => { + await render(); + const editorElement = await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + // A popover input, portalled outside the content area: the portal is the + // container child that isn't an ancestor of the content element. + const container = editorElement.closest(".bn-container")!; + const portal = Array.from(container.children).find( + (child) => !child.contains(editorElement), + ) as HTMLElement; + expect(portal).toBeDefined(); + const popoverInput = document.createElement("input"); + portal.append(popoverInput); + popoverInput.focus(); + + // Give the settle a chance to run, then confirm it never dropped. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(focusedValue()).toBe("true"); + + addOutsideInput().focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + popoverInput.remove(); + }); + + test("does not re-render for focus changes elsewhere on the page", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const rendersBefore = Number(readout().dataset.renders); + const a = addOutsideInput(); + const b = addOutsideInput(); + // Focus bouncing between two unrelated inputs: the editor goes unfocused + // once, and must not re-render for every subsequent hop. + a.focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + const rendersAfterBlur = Number(readout().dataset.renders); + for (let i = 0; i < 5; i++) { + (i % 2 === 0 ? b : a).focus(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(rendersAfterBlur).toBeGreaterThan(rendersBefore); + expect(Number(readout().dataset.renders)).toBe(rendersAfterBlur); + }); + + test("typing does not re-render the consumer", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const before = Number(readout().dataset.renders); + await userEvent.keyboard("some typing that changes the document"); + + expect(Number(readout().dataset.renders)).toBe(before); + }); +}); + +// `useEditorFocusChange` (the callback counterpart) keeps its subscription +// alive across re-renders via the latest-ref pattern. Without it, an inline +// callback — the common case — has a new identity every render, so the effect +// re-runs and the editor is unsubscribed and resubscribed each time. That +// matters more than it looks: with `includeEditorUI` the subscription is +// reference-counted, so cycling it tears down and re-attaches the document +// focus listeners and resets the settled baseline. Measured: naive +// implementation resubscribes once per render (6 after 5 re-renders), this +// one stays at 1. +describe("useEditorFocusChange", () => { + test("does not resubscribe when the callback identity changes", async () => { + let subscribes = 0; + + function CountingProbe() { + const editor = useCreateBlockNote(); + // Patch once, not on every render. + useState(() => { + const original = editor.onFocusChange.bind(editor); + (editor as any).onFocusChange = (...args: any[]) => { + subscribes += 1; + return (original as any)(...args); + }; + return null; + }); + return ( + + + + ); + } + + function Rerenderer() { + const [n, setN] = useState(0); + useEditorFocusChange(() => { + /* inline: new identity every render */ + }); + return ( + + ); + } + + await render(); + await waitForSelector(EDITOR_SELECTOR); + const afterMount = subscribes; + expect(afterMount).toBe(1); + + const button = document.querySelector( + '[data-test="rerender"]', + )!; + for (let i = 0; i < 5; i++) { + button.click(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(Number(button.textContent)).toBe(5); + expect(subscribes).toBe(afterMount); + }); +}); diff --git a/tests/src/utils/ensureTouchEmulation.ts b/tests/src/utils/ensureTouchEmulation.ts new file mode 100644 index 0000000000..77e5f78d7a --- /dev/null +++ b/tests/src/utils/ensureTouchEmulation.ts @@ -0,0 +1,43 @@ +/** + * Restores touch *detection* for the android instance if a previously run + * test dropped the real emulation. + * + * Playwright's element-screenshot path for **iframe elements** (what + * `screenshotFull` captures for export previews) rewrites the device-metrics + * override and permanently drops the context's touch emulation — + * `navigator.maxTouchPoints` becomes 0 for every later test file, turning + * `isTouchDevice()` (and with it the mobile formatting toolbar) off. Plain + * element screenshots and `page.viewport()` calls are fine; only + * iframe-element captures trip it. The android instance therefore keeps such + * suites out of its include, and touch-dependent tests call this in + * `beforeEach` as a self-healing guard in case that ever regresses. + * + * Property stubs rather than CDP: re-arming the emulation over CDP only + * affects future documents — `navigator.maxTouchPoints` is fixed at document + * creation, so the already-created tester iframe wouldn't see it. The stubs + * restore exactly what `isTouchDevice()` reads. + */ +export function ensureTouchEmulation() { + if (navigator.maxTouchPoints === 0) { + Object.defineProperty(navigator, "maxTouchPoints", { + value: 1, + configurable: true, + }); + } + if (!window.matchMedia("(pointer: coarse)").matches) { + const original = window.matchMedia; + window.matchMedia = ((query: string) => + query.includes("pointer: coarse") + ? ({ + matches: true, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList) + : original(query)) as typeof window.matchMedia; + } +} diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index 21fb2a1e1b..d1180e31e0 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -88,6 +88,7 @@ export default defineConfig( "./src/end-to-end/**/*.test.tsx", "../packages/*/src/**/*.browser.test.{ts,tsx}", ], + setupFiles: ["./vitestSetup.browser.ts"], // Running three browsers concurrently inside one Docker container already // saturates CPU; layering per-browser file parallelism on top causes @@ -151,12 +152,58 @@ export default defineConfig( "--disable-dev-shm-usage", ], }, + // end-to-end/mobile runs only in the "android" instance below. + exclude: ["**/end-to-end/mobile/**"], }, { browser: "firefox", + exclude: ["**/end-to-end/mobile/**"], }, { browser: "webkit", + exclude: ["**/end-to-end/mobile/**"], + }, + { + // Android-emulated chromium: mobile-specific end-to-end tests. + // The context makes `isTouchDevice()` genuinely true and puts + // prosemirror-view on its Android code paths (it samples the + // user agent at module load), so the mobile tests need no + // platform stubs. See tests/src/end-to-end/mobile/. + browser: "chromium", + name: "android", + launchOptions: { + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ], + }, + provider: playwright({ + contextOptions: { + viewport: { width: 393, height: 727 }, + userAgent: + "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36", + isMobile: true, + hasTouch: true, + }, + }), + // Mobile-specific tests plus the screenshot-free behavioral + // suites where Android genuinely differs (IME key handling, + // suggestion menus). Keep iframe-screenshotting suites (the + // exporters' `screenshotFull` previews) out: Playwright's + // element-screenshot path for iframe elements permanently drops + // the context's touch emulation for later files (see + // utils/ensureTouchEmulation.ts). Individual tests that + // drive selection/resizing with positional mouse drags carry + // `skipIf(onAndroid)` guards. Not yet included: indentation + // (drives the desktop floating toolbar, which is clipped at + // phone width). + include: [ + "./src/end-to-end/mobile/**/*.test.tsx", + "./src/end-to-end/keyboardhandlers/**/*.test.tsx", + "./src/end-to-end/emojipicker/**/*.test.tsx", + "./src/end-to-end/copypaste/**/*.test.tsx", + ], }, ], },