diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts new file mode 100644 index 0000000000..9094bc0ec0 --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts @@ -0,0 +1,80 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { FormattingToolbarExtension } from "./FormattingToolbar.js"; + +// Runs in the desktop browser instances, where `isTouchDevice()` is false. +// The touch side is pinned in tests/src/end-to-end/mobile/mobileToolbar.test.tsx. + +/** Resolves once the deferred focus settle has run (see attachUIFocusTracker). */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe("Formatting toolbar focus", () => { + let editor: BlockNoteEditor; + let container: HTMLElement; + let outside: HTMLInputElement; + + beforeEach(() => { + outside = document.createElement("input"); + document.body.append(outside); + + // Nested like `BlockNoteView`: the mount element's parent is the + // editor's boundary (see `isWithinEditor`). + container = document.createElement("div"); + const mountPoint = document.createElement("div"); + container.append(mountPoint); + document.body.append(container); + editor = BlockNoteEditor.create({ + initialContent: [{ type: "paragraph", content: "Selected text" }], + }); + editor.mount(mountPoint); + }); + + afterEach(() => { + editor.unmount(); + container.remove(); + outside.remove(); + }); + + it("keeps the selection and the toolbar across a blur", async () => { + const toolbar = editor.getExtension(FormattingToolbarExtension)!; + editor.focus(); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, 3, 11)), + ); + expect(toolbar.store.state).toBe(true); + + outside.focus(); + await settle(); + expect(toolbar.store.state).toBe(false); + + editor.focus(); + await settle(); + expect(editor.transact((tr) => tr.selection.empty)).toBe(false); + expect(toolbar.store.state).toBe(true); + }); + + // Only touch devices drop the editor's focus when the window blurs; a + // desktop keeps it, e.g. across a switch to another window and back. + it("keeps focus, the selection and the toolbar when only the window blurs", async () => { + const toolbar = editor.getExtension(FormattingToolbarExtension)!; + editor.focus(); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, 3, 11)), + ); + expect(toolbar.store.state).toBe(true); + + // WebKit's page blur: `blur` on the focused element, without focusout and + // without moving `document.activeElement`, then on the window. + editor.prosemirrorView.dom.dispatchEvent(new FocusEvent("blur")); + window.dispatchEvent(new FocusEvent("blur")); + await settle(); + + expect(editor.isFocused()).toBe(true); + expect(editor.transact((tr) => tr.selection.empty)).toBe(false); + expect(toolbar.store.state).toBe(true); + }); +}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts index 8ab1d52b0a..18dd451ee8 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts @@ -1,9 +1,10 @@ -import { TextSelection } from "prosemirror-state"; +import { Selection, TextSelection } from "prosemirror-state"; import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; +import { isTouchDevice } from "../../util/browser.js"; export const FormattingToolbarExtension = createExtension(({ editor }) => { const store = createStore(false); @@ -83,6 +84,19 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { (_editor, { focused }) => { if (!focused) { store.setState(false); + // On touch, leaving the editor also ends the selection. A phone + // can hand focus back without the keyboard (see the window blur + // listener below): the old selection then showed the desktop + // toolbar, and without the keyboard no tap cleared it. A tablet + // with a mouse or hardware keyboard loses the selection on blur + // too, unlike a desktop. + if (isTouchDevice()) { + editor.transact((tr) => { + if (!tr.selection.empty) { + tr.setSelection(Selection.near(tr.selection.$head)); + } + }); + } return; } if (preventShowWhileMouseDown || preventShowWhileDragging) { @@ -98,6 +112,21 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { }, { includeEditorUI: true }, ); + // A tap on iOS Safari's collapsed address bar only blurs the window: the + // editor keeps DOM focus, so no focusout reaches the handler above, and + // Safari later hands that focus back without the keyboard. On touch, + // drop the editor's focus along with the window's so the handler runs. + if (isTouchDevice()) { + window.addEventListener( + "blur", + () => { + if (editor.isFocused()) { + editor.blur(); + } + }, + { signal }, + ); + } // To mimic Notion's behavior, we listen to the mouse down event to set the `preventShowWhileMouseDown` flag dom.addEventListener( diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx index efa5f16c57..6f8adc041b 100644 --- a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -50,6 +50,10 @@ async function settleFrames(count = 3) { } } +function selectionIsEmpty(): boolean { + return (window as any).ProseMirror.state.selection.empty; +} + function activeUrlInput() { const active = document.activeElement; return active instanceof HTMLInputElement && active.name === "url" @@ -106,6 +110,55 @@ describe("Mobile formatting toolbar", () => { expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); }); + // A real blur, then focus coming back without a gesture (a programmatic + // `focus()`), so the keyboard stays closed. The selection used to survive + // the blur, so the desktop toolbar showed over it, and without the keyboard + // no tap could clear it. + test("focus returning without the keyboard brings back neither the selection nor a toolbar", async () => { + await focusOnEditor(); + await userEvent.keyboard("Mobile toolbar"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + const editorElement = document.querySelector(EDITOR_SELECTOR)!; + editorElement.blur(); + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await settleFrames(); + + // No gesture: the keyboard stays closed. + editorElement.focus(); + await settleFrames(); + + expect(selectionIsEmpty()).toBe(true); + expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); + }); + + // The tap on iOS Safari's address bar blurs only the window: the editor + // keeps DOM focus, so without dropping it no focusout ever reached the + // editor and the selection survived. + test("a window blur that leaves the editor focused ends the selection", async () => { + await focusOnEditor(); + await userEvent.keyboard("Mobile toolbar"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + const editorElement = document.querySelector(EDITOR_SELECTOR)!; + // WebKit's page blur: `blur` on the focused element, without focusout and + // without moving `document.activeElement`, then on the window. + editorElement.dispatchEvent(new FocusEvent("blur")); + window.dispatchEvent(new FocusEvent("blur")); + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await settleFrames(); + + expect(document.activeElement).not.toBe(editorElement); + expect(selectionIsEmpty()).toBe(true); + expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); + }); + test("shows while the virtual keyboard is open and hides when it closes", async () => { await focusOnEditor(); await userEvent.keyboard("Mobile toolbar");