Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cc87b53
fix(core): handle Enter via beforeinput on Android (#3001)
YousefED Aug 31, 2026
2d8b6ed
fix(core): also handle Enter delivered as a keypress on Android
YousefED Aug 31, 2026
bfbeba0
test: run the form suites on the android instance too
YousefED Sep 1, 2026
995c76f
fix(test): the android instance now tests true phone geometry
YousefED Sep 1, 2026
70d1e8d
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 5, 2026
7fa47d7
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 5, 2026
a9b7ecb
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 5, 2026
082212c
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 5, 2026
6f4c457
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 5, 2026
859c6cd
fix(core): only claim an Android Enter keypress a handler took
YousefED Sep 6, 2026
4539ef6
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 6, 2026
a055479
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 7, 2026
0752a75
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 7, 2026
79ececf
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 7, 2026
63a0b4d
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 7, 2026
27108cc
fix(core): treat an IME newline commit as Enter on Android
YousefED Sep 7, 2026
93a2852
test: plain comment on the IME newline case
YousefED Sep 7, 2026
b135fb3
fix(react): remove the viewport properties when the last publisher un…
YousefED Sep 7, 2026
55dcfcd
test: the mobile toolbar's inputs are at least 16px
YousefED Sep 7, 2026
7f9c42b
test: assert the overlap the ariakit z-index pin relies on; name the …
YousefED Sep 7, 2026
c0cee53
fix(core): hide the formatting toolbar when the user leaves the editor
YousefED Sep 7, 2026
637fce0
fix(react): reset the keyboard baseline when the last viewport publis…
YousefED Sep 7, 2026
d07101c
fix(core): side menu ignores pointer moves over the editor's own UI
YousefED Sep 7, 2026
f382ccb
fix(core): ignore only Dark Reader's writes in node views
YousefED Sep 7, 2026
1db3a92
Merge remote-tracking branch 'origin/fix/dark-reader-mutation-filter'…
YousefED Sep 7, 2026
2ae0be2
refactor(core): drop the Android Enter interception
YousefED Sep 7, 2026
7d71529
fix(core): keep prosemirror-view's default for node views without a c…
YousefED Sep 7, 2026
7c28fa4
Merge remote-tracking branch 'origin/fix/dark-reader-mutation-filter'…
YousefED Sep 7, 2026
9c2014f
Merge branch 'mobile/link-popover' into mobile/android-enter
YousefED Sep 7, 2026
48f7a9f
test: iOS-emulated webkit instance for the mobile suites
YousefED Sep 7, 2026
34056eb
test: name the cause of the touch-emulation loss
YousefED Sep 7, 2026
0e5904d
test: drop the touch-emulation repro script
YousefED Sep 7, 2026
86fa8e2
fix(react): the pinned scroll container lets pull-to-refresh through …
YousefED Sep 7, 2026
54f7f5d
fix(react): keep the mobile toolbar on the keyboard on iOS Safari
YousefED Sep 8, 2026
4718821
test(mobile): select typed link text with character steps, not Shift+…
YousefED Sep 8, 2026
e1ca278
test(mobile): say why Shift+Home is avoided, not who noticed
YousefED Sep 8, 2026
af0300e
Reverted fix for gap between virtual keyboard and formatting toolbar …
matthewlipski Sep 17, 2026
2bb6a35
Merge branch 'mobile/link-popover' into mobile/android-enter
matthewlipski Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => {
// re-evaluate whether the toolbar should be shown
store.setState(shouldShow());
});
// The selection survives a blur, so without this the state would too:
// on a phone, tapping the page away from the editor closes the
// keyboard, and a controller mounting after that showed the toolbar over
// a blurred editor. Focus within the editor's own UI (a toolbar button,
// a popover's input) still counts as focused, and the event only fires
// once a focus handoff has settled. Known edge: with focus inside the
// toolbar (a menu open), scrolling the selection out of view hides the
// toolbar and the browser drops that focus, so the toolbar is gone until
// the next selection change.
const unsubscribeOnFocusChange = editor.onFocusChange(
(_editor, { focused }) => {
if (!focused) {
store.setState(false);
return;
}
if (preventShowWhileMouseDown || preventShowWhileDragging) {
return;
}
store.setState(shouldShow());
},
{ includeEditorUI: true },
);

// To mimic Notion's behavior, we listen to the mouse down event to set the `preventShowWhileMouseDown` flag
dom.addEventListener(
Expand Down Expand Up @@ -122,6 +144,7 @@ export const FormattingToolbarExtension = createExtension(({ editor }) => {
signal.addEventListener("abort", () => {
unsubscribeOnChange();
unsubscribeOnSelectionChange();
unsubscribeOnFocusChange();
});
},
} as const;
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/extensions/SideMenu/SideMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,21 @@ export class SideMenuView<
return;
}

// Leaves the menu as it is while the pointer is over this editor's own UI
// (a toolbar, a menu, the side menu itself) rather than its content: the
// block under that UI is not what the pointer is about. It also keeps taps
// on the UI working on iOS Safari, which delivers a tap as a hover first
// and drops the click when that hover changes the page (WebKit's
// ContentChangeObserver); with the mobile toolbar far below the blocks,
// the hover would hide a shown side menu and every button needed two taps.
if (
event.target instanceof Node &&
!this.pmView.dom.contains(event.target) &&
this.editor.isWithinEditor(event.target as Element)
) {
return;
}

this.mousePos = { x: event.clientX, y: event.clientY };

// We want the full area of the editor to check if the cursor is hovering
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/util/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't love the complexity of what is happening here, but I take it that this fixes a bunch of bugs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, but yes, added some workarounds in the latest iteration to fix bugs some bugs (mostly iOS).

Mostly to fix weird positioning when scrolling at the top (pull-to-refresh) / bottom ("rubber band")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately looks like the bottom scroll behaviour on iOS is still not quite right. Before, we had an issue where there was a gap at the very bottom of the document between the formatting toolbar and keyboard. Now, we can't scroll to the bottom of the page at all, and trying to do so results in a lot of jerkiness. Both cases are only applicable when not using a .bn-scroll-container.

Before:

ScreenRecording_09-16-2026.12-59-30_1.MP4

After:

ScreenRecording_09-16-2026.12-57-08_1.MP4

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ let baselineLayoutWidth = 0;
* keyboard is reopened.
*/
function isVirtualKeyboardOpen(): boolean {
// Server render: no viewport to measure, so no keyboard.
if (typeof window === "undefined") {
return false;
}
Expand All @@ -38,6 +39,8 @@ function isVirtualKeyboardOpen(): boolean {
const layoutHeight = (vp?.height ?? window.innerHeight) * scale;
const layoutWidth = document.documentElement.clientWidth;

// Orientation changed: the tallest height seen so far belongs to the other
// orientation, start measuring afresh.
if (Math.abs(layoutWidth - baselineLayoutWidth) > baselineLayoutWidth * 0.2) {
baselineLayoutWidth = layoutWidth;
maxLayoutViewportHeight = 0;
Expand All @@ -63,15 +66,32 @@ function isVirtualKeyboardOpen(): boolean {
* content — the matching styles live in `editor/styles.css`, keyed off that
* class and the `--bn-vv-*` variables this hook publishes.
*/
const VIEWPORT_PROPERTIES = [
"--bn-vv-top",
"--bn-vv-left",
"--bn-vv-width",
"--bn-vv-height",
"--bn-vv-scale",
] as const;

// How many mounted hooks publish the `--bn-vv-*` properties. The last one
// out removes them: left behind, they pin a `bn-scroll-container` to the
// keyboard-open size after the editor is gone (a client-side navigation to a
// page without an editor), and a page-level property is shared by every
// editor on the page, so no single hook may remove it while another still
// needs it.
let viewportPublishers = 0;

export function useVirtualKeyboard(): boolean {
const [open, setOpen] = useState(isVirtualKeyboardOpen);

useLayoutEffect(() => {
const html = document.documentElement;

const vp = window.visualViewport;
const update = () => {
setOpen(isVirtualKeyboardOpen());
const scrollContainers = () =>
document.querySelectorAll<HTMLElement>(".bn-scroll-container");

const publishViewport = () => {
html.style.setProperty("--bn-vv-top", `${vp?.offsetTop ?? 0}px`);
html.style.setProperty("--bn-vv-left", `${vp?.offsetLeft ?? 0}px`);
html.style.setProperty(
Expand All @@ -84,17 +104,67 @@ export function useVirtualKeyboard(): boolean {
);
html.style.setProperty("--bn-vv-scale", `${vp?.scale ?? 1}`);
};

// A pinned `bn-scroll-container` contains its overscroll, except at its
// top with the keyboard closed, where the overscroll must reach the
// document for the browser's pull-to-refresh to fire (see the rules in
// `editor/styles.css`).
const markPullToRefresh = (container: HTMLElement, keyboardOpen: boolean) =>
container.toggleAttribute(
"data-bn-allow-pull-to-refresh",
!keyboardOpen && container.scrollTop <= 0,
);

const update = () => {
const keyboardOpen = isVirtualKeyboardOpen();
setOpen(keyboardOpen);
publishViewport();
for (const container of scrollContainers()) {
markPullToRefresh(container, keyboardOpen);
}
};
viewportPublishers++;
update();

// Fire on keyboard open/close, zoom/pan, and content scroll.
vp?.addEventListener("resize", update);
vp?.addEventListener("scroll", update);
window.addEventListener("resize", update);

// Scroll events don't bubble; capture them to track the pinned containers.
const onScroll = (event: Event) => {
if (
event.target instanceof HTMLElement &&
event.target.classList.contains("bn-scroll-container")
) {
markPullToRefresh(event.target, isVirtualKeyboardOpen());
}
};
document.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
});

return () => {
vp?.removeEventListener("resize", update);
vp?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
document.removeEventListener("scroll", onScroll, { capture: true });
viewportPublishers--;
if (viewportPublishers === 0) {
// Last publisher gone: nothing on the page positions itself from the
// properties any more, so take them and the keyboard baseline down.
for (const property of VIEWPORT_PROPERTIES) {
html.style.removeProperty(property);
}
for (const container of scrollContainers()) {
container.removeAttribute("data-bn-allow-pull-to-refresh");
}
// The keyboard baseline goes with them: a later editor starts from
// what it measures itself, not from a maximum seen on another page.
maxLayoutViewportHeight = 0;
baselineLayoutWidth = 0;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}, []);

Expand Down
28 changes: 24 additions & 4 deletions packages/react/src/editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,15 @@ SideMenuController offsets its position to keep it centered on the line. */
padding-bottom: env(safe-area-inset-bottom, 0);
}

/* iOS Safari with the keyboard open answers a vertical drag on the toolbar
with a viewport pan or pull-to-refresh, and the toolbar pinned to that
viewport moves under the finger. On the strip, not the wrapper: WebKit reads
`touch-action` only up to the nearest scroll container of the touched
element, and the strip is one. */
.bn-mobile-formatting-toolbar .bn-toolbar {
touch-action: pan-x;
}

@media (prefers-reduced-motion: reduce) {
.bn-mobile-formatting-toolbar {
transition: none;
Expand All @@ -549,13 +558,24 @@ SideMenuController offsets its position to keep it centered on the line. */
height: var(--bn-vv-height, 100dvh);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
/* Stop overscroll at the boundary from chaining to the document. Without
this, dragging past the bottom on iOS rubber-bands the whole page, which
shifts the visual viewport (repinning the container mid-bounce → jitter)
and surfaces a second, document-level scrollbar. */
/* Overscroll stays inside the container. Chained to the document it moves
the page behind the pinned container, and the toolbar with it: scrolled
to the end and dragging further, the whole page rubber-bands, content and
toolbar jump, and a document-level scrollbar appears; at the top with the
keyboard up, dragging down starts Safari's pull-to-refresh, the page
slides with the finger and the toolbar drifts up from the keyboard and
snaps back. */
overscroll-behavior: contain;
}

/* The one exception: at the top with the keyboard closed, the overscroll must
reach the document or the page can no longer be refreshed by pulling.
`useVirtualKeyboard` sets the attribute in exactly that state. Not with the
keyboard up, where the pull moves the toolbar (see above). */
.bn-scroll-container[data-bn-allow-pull-to-refresh] {
overscroll-behavior-y: auto;
}

/* Emoji Picker styling */
.bn-root em-emoji-picker {
max-height: 100%;
Expand Down
7 changes: 4 additions & 3 deletions tests/src/end-to-end/ariakit/ariakit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ describe("Check Ariakit UI", () => {
handle.right > submenuRect.x &&
handle.y < submenuRect.bottom &&
handle.bottom > submenuRect.y;
if (overlaps) {
expect(submenu.contains(onTop)).toBe(true);
}
// The pin below is only meaningful while the submenu covers the handle;
// without this the test passes vacuously when the layout changes.
expect(overlaps).toBe(true);
expect(submenu.contains(onTop)).toBe(true);
});
test("Check image toolbar", async () => {
await focusOnEditor();
Expand Down
54 changes: 37 additions & 17 deletions tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,41 @@ 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);
// 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 sleep(500);
describe("Check Keyboard Handlers' Behaviour", () => {
// Enter on a selection across blocks is a no-op on Android: prosemirror-view
// ignores the keydown there and its keypress handler cancels the browser
// default for cross-parent selections without doing anything. A rare
// pattern, deliberately not worked around; see mobile/androidEnter.test.tsx.
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 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("{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 userEvent.keyboard("{Enter}");

await compareDocToSnapshot("enterSelectionNotEmpty");
});
test("Check Enter preserves marks", async () => {
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);

Expand Down Expand Up @@ -313,6 +327,12 @@ describe("Check Keyboard Handlers' Behaviour", () => {
await insertParagraph();

await userEvent.keyboard("{ArrowUp}");
// ArrowUp crosses from an unnested line into an indented one, so its
// goal-x lands near the last character's boundary — which side it falls
// on varies with subpixel text metrics (flaky on the mobile-emulated
// instances). The test is about Delete at the *end* of the block; make
// that position explicit.
await userEvent.keyboard("{End}");
await userEvent.keyboard("{Delete}");

await compareDocToSnapshot("deleteShallowerBlock");
Expand Down
Loading
Loading