From 7cee0b028932c4599fbc495612f8df24ddb81d4d Mon Sep 17 00:00:00 2001 From: rovshanmuradov Date: Sat, 24 Oct 2026 11:20:58 +0400 Subject: [PATCH 1/3] fix(core): collapse the selection when focus leaves a touch editor On iOS Safari, a tap on the collapsed address bar closes the keyboard and then hands focus back to the editor without it. The selection survived the blur, so the formatting toolbar showed again, as the desktop toolbar since the keyboard is closed, and without the keyboard no tap cleared the selection. On touch devices, collapse the selection once focus has left the editor and its UI. Desktop keeps the selection across a blur. --- .../FormattingToolbar.browser.test.ts | 59 +++++++++++++++++++ .../FormattingToolbar/FormattingToolbar.ts | 16 ++++- .../end-to-end/mobile/mobileToolbar.test.tsx | 29 +++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts 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..157fa3c901 --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts @@ -0,0 +1,59 @@ +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); + }); +}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts index 8ab1d52b0a..736ddd77df 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. iOS Safari + // hands focus back without the keyboard after a tap on its + // collapsed address bar: 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) { diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx index efa5f16c57..766dfd5de4 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,31 @@ describe("Mobile formatting toolbar", () => { expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); }); + // iOS Safari hands focus back to the editor without the keyboard after a + // tap on its collapsed address bar. 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(); + }); + test("shows while the virtual keyboard is open and hides when it closes", async () => { await focusOnEditor(); await userEvent.keyboard("Mobile toolbar"); From b4c14abe709e244079833e7b039a2a4b2aafc3cb Mon Sep 17 00:00:00 2001 From: rovshanmuradov Date: Sat, 24 Oct 2026 20:38:32 +0400 Subject: [PATCH 2/3] fix(core): drop the editor's focus when the window blurs on touch A tap on iOS Safari's collapsed address bar blurs only the page: WebKit fires `blur` on the editor without `focusout` and leaves it as `document.activeElement`. `onFocusChange({ includeEditorUI: true })` never saw that blur, so the selection survived it, and the editor got focus back without the keyboard. On touch, blur the editor along with the window so the existing handler collapses the selection. --- .../FormattingToolbar/FormattingToolbar.ts | 27 ++++++++++++++----- .../end-to-end/mobile/mobileToolbar.test.tsx | 21 +++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts index 736ddd77df..5e0b45d3bf 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts @@ -84,12 +84,12 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { (_editor, { focused }) => { if (!focused) { store.setState(false); - // On touch, leaving the editor also ends the selection. iOS Safari - // hands focus back without the keyboard after a tap on its - // collapsed address bar: 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. + // 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) { @@ -112,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.prosemirrorView.dom.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 766dfd5de4..c105162a92 100644 --- a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -135,6 +135,27 @@ describe("Mobile formatting toolbar", () => { 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)!; + 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"); From a8ff0f2a321407b2e1e8bb8498fa7a8488bb4cd5 Mon Sep 17 00:00:00 2001 From: rovshanmuradov Date: Sat, 24 Oct 2026 20:41:56 +0400 Subject: [PATCH 3/3] test(core): model WebKit's page blur in the touch focus tests Dispatch `blur` on the editor before the window, as WebKit does. The test then pins that the fix reads `document.activeElement`, not a focus flag the element's `blur` has already cleared. Cover the desktop side too: a blur of only the window keeps focus, the selection and the toolbar. Blur through `editor.blur()`. --- .../FormattingToolbar.browser.test.ts | 21 +++++++++++++++++++ .../FormattingToolbar/FormattingToolbar.ts | 2 +- .../end-to-end/mobile/mobileToolbar.test.tsx | 11 ++++++---- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts index 157fa3c901..9094bc0ec0 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.browser.test.ts @@ -56,4 +56,25 @@ describe("Formatting toolbar focus", () => { 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 5e0b45d3bf..18dd451ee8 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts @@ -121,7 +121,7 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => { "blur", () => { if (editor.isFocused()) { - editor.prosemirrorView.dom.blur(); + editor.blur(); } }, { signal }, diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx index c105162a92..6f8adc041b 100644 --- a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -110,10 +110,10 @@ describe("Mobile formatting toolbar", () => { expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); }); - // iOS Safari hands focus back to the editor without the keyboard after a - // tap on its collapsed address bar. The selection used to survive the blur, - // so the desktop toolbar showed over it, and without the keyboard no tap - // could clear it. + // 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"); @@ -147,6 +147,9 @@ describe("Mobile formatting toolbar", () => { 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();