From 3ef29a8269ed5ffbe5130782e1b6470c493ee777 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 11:44:21 +0200 Subject: [PATCH 1/3] Sort of fixed scrolling to bottom of the page --- .../FormattingToolbar/useVirtualKeyboard.ts | 60 +++++--- .../mobile/virtualKeyboardScroll.test.tsx | 133 ++++++++++++++++++ 2 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx diff --git a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts index ae15d988aa..38a6509df7 100644 --- a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts +++ b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts @@ -6,6 +6,10 @@ import { useLayoutEffect, useState } from "react"; // refreshing it from a render pass is safe. let maxLayoutViewportHeight = 0; let baselineLayoutWidth = 0; +// The last keyboard-open height measured away from the document's bottom, +// multiplied by the zoom scale. Safari clips its reported viewport to the +// document there, even though the keyboard hasn't moved. +let unclippedKeyboardHeight: number | undefined; /** * Whether the on-screen keyboard is open, from the current visual viewport. We @@ -44,6 +48,7 @@ function isVirtualKeyboardOpen(): boolean { if (Math.abs(layoutWidth - baselineLayoutWidth) > baselineLayoutWidth * 0.2) { baselineLayoutWidth = layoutWidth; maxLayoutViewportHeight = 0; + unclippedKeyboardHeight = undefined; } maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); @@ -115,23 +120,43 @@ export function useVirtualKeyboard(): boolean { !keyboardOpen && container.scrollTop <= 0, ); - // iOS Safari lets the document scroll past its layout maximum while the - // keyboard is open (by its accessory bar, 98px measured) and clips the - // visual viewport there, so the toolbar pinned to that edge floats above - // the keyboard. Holding the document at the maximum keeps the end - // reachable through the viewport pan. A no-op with a pinned scroll - // container, or where the keyboard resizes the layout viewport. - // How-to-test: without it, iOS Safari, pinned scroll container off, focus - // the editor and drag the page past its end: the toolbar sits about 100px - // above the keyboard with an empty band below it (no emulated instance - // reproduces the range; on the release checklist). - const clampDocumentScroll = () => { - const max = html.scrollHeight - html.clientHeight; - // Safari went past the layout maximum: pull the document back to it. + // Safari adds its accessory-bar inset to the native scroll range while + // the keyboard is open: https://bugs.webkit.org/show_bug.cgi?id=292603. + // Limit only that blank region. The visual viewport, not the unchanged + // layout viewport, determines how far the user must scroll to reach the + // document's end. Using clientHeight snaps back before the end is visible. + function clampDocumentScroll() { + const height = vp?.height ?? window.innerHeight; + const scale = vp?.scale ?? 1; + const previousHeight = + (unclippedKeyboardHeight ?? height * scale) / scale; + + // At the bottom Safari also clips visualViewport.height to the remaining + // document. Retain the last unclipped measurement there; otherwise each + // smaller height would admit more of the blank region. Account for the + // document scroll event arriving before the visual viewport catches up. + // innerHeight is clipped too, including briefly after scrollTo. A real + // taller keyboard leaves room below the visual viewport in innerHeight. + // When the keyboard resizes the layout viewport (Android), use the new + // height directly instead of retaining the previous keyboard's size. + if ( + unclippedKeyboardHeight === undefined || + html.clientHeight <= height * scale + 1 || + height >= previousHeight || + (window.innerHeight > height + 1 && + Math.max(window.scrollY, vp?.pageTop ?? 0) + height < + html.scrollHeight - 1) + ) { + unclippedKeyboardHeight = height * scale; + } + const max = Math.max( + 0, + html.scrollHeight - unclippedKeyboardHeight / scale, + ); if (window.scrollY > max + 1) { - window.scrollTo(0, max); + window.scrollTo(window.scrollX, max); } - }; + } const update = () => { const keyboardOpen = isVirtualKeyboardOpen(); @@ -141,9 +166,9 @@ export function useVirtualKeyboard(): boolean { markPullToRefresh(container, keyboardOpen); } if (keyboardOpen) { - // The keyboard resized or panned the viewport: keep the document within - // its layout maximum (see `clampDocumentScroll`). clampDocumentScroll(); + } else { + unclippedKeyboardHeight = undefined; } }; viewportPublishers++; @@ -198,6 +223,7 @@ export function useVirtualKeyboard(): boolean { // what it measures itself, not from a maximum seen on another page. maxLayoutViewportHeight = 0; baselineLayoutWidth = 0; + unclippedKeyboardHeight = undefined; } }; }, []); diff --git a/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx b/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx new file mode 100644 index 0000000000..e1b154bb0e --- /dev/null +++ b/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx @@ -0,0 +1,133 @@ +import { useVirtualKeyboard } from "@blocknote/react"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { cleanup, render } from "vitest-browser-react"; + +import { page } from "../../utils/context.js"; + +function ScrollingPage() { + useVirtualKeyboard(); + return
Scrollable document
; +} + +beforeEach(async () => { + await page.viewport(393, 727); + await render(); +}); + +afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); +}); + +// Browser emulation resizes the layout viewport with the keyboard. Real iOS +// leaves it unchanged, allows window.scrollY to include the viewport pan, and +// can scroll another 98px into Safari's accessory-bar inset. Reproduce those +// measurements here; native gestures on the iOS simulator verify the model. +test("can scroll to the visual viewport's bottom with the keyboard open", () => { + const viewport = window.visualViewport!; + vi.spyOn(viewport, "height", "get").mockReturnValue(427); + viewport.dispatchEvent(new Event("resize")); + + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const scrollY = vi.spyOn(window, "scrollY", "get"); + const height = document.documentElement.scrollHeight; + + // Both positions exceed the layout maximum, but still contain real content. + scrollY.mockReturnValue(height - 727 + 100); + document.dispatchEvent(new Event("scroll")); + scrollY.mockReturnValue(height - 427); + document.dispatchEvent(new Event("scroll")); + viewport.dispatchEvent(new Event("scroll")); + + expect(scrollTo).not.toHaveBeenCalled(); +}); + +test("excludes Safari's blank bottom inset even when it clips the viewport measurement", () => { + const viewport = window.visualViewport!; + const viewportHeight = vi.spyOn(viewport, "height", "get"); + viewportHeight.mockReturnValue(427); + viewport.dispatchEvent(new Event("resize")); + + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const height = document.documentElement.scrollHeight; + const scrollY = vi.spyOn(window, "scrollY", "get"); + scrollY.mockReturnValue(height - 427 + 98); + vi.spyOn(window, "scrollX", "get").mockReturnValue(12); + + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); + + // A second drag at the bottom can shrink the reported height before the + // scroll listener runs. It must not reopen the extra 98px of scroll range. + viewportHeight.mockReturnValue(427 - 98); + viewport.dispatchEvent(new Event("resize")); + expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); + + // The document responds to scrollTo before Safari restores its viewport + // measurements. That stale resize must not replace the unclipped height. + scrollY.mockReturnValue(height - 427); + vi.spyOn(window, "innerHeight", "get").mockReturnValue(427 - 98); + viewport.dispatchEvent(new Event("resize")); + scrollY.mockReturnValue(height - 427 + 98); + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); +}); + +test("remeasures the keyboard after it closes and preserves keyboard-closed overscroll", () => { + const viewport = window.visualViewport!; + const viewportHeight = vi.spyOn(viewport, "height", "get"); + viewportHeight.mockReturnValue(427); + viewport.dispatchEvent(new Event("resize")); + + viewportHeight.mockReturnValue(727); + viewport.dispatchEvent(new Event("resize")); + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const height = document.documentElement.scrollHeight; + const scrollY = vi.spyOn(window, "scrollY", "get"); + scrollY.mockReturnValue(height - 727 + 50); + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).not.toHaveBeenCalled(); + + // Reopen a taller keyboard while still at the bottom of the closed viewport. + viewportHeight.mockReturnValue(367); + viewport.dispatchEvent(new Event("resize")); + scrollY.mockReturnValue(height - 367 + 98); + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).toHaveBeenLastCalledWith(window.scrollX, height - 367); +}); + +test("a taller keyboard at the bottom makes the remaining content reachable", () => { + const viewport = window.visualViewport!; + const viewportHeight = vi.spyOn(viewport, "height", "get"); + viewportHeight.mockReturnValue(427); + viewport.dispatchEvent(new Event("resize")); + + const height = document.documentElement.scrollHeight; + const scrollY = vi.spyOn(window, "scrollY", "get"); + scrollY.mockReturnValue(height - 427); + document.dispatchEvent(new Event("scroll")); + + // A suggestion strip shrinks the visible area without scrolling beyond the + // document, unlike Safari's clipped measurement after a drag into the inset. + vi.spyOn(window, "innerHeight", "get").mockReturnValue(427); + viewportHeight.mockReturnValue(367); + viewport.dispatchEvent(new Event("resize")); + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + scrollY.mockReturnValue(height - 367); + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).not.toHaveBeenCalled(); +}); + +test("updates the scroll limit when the keyboard resizes the layout viewport", async () => { + await page.viewport(393, 427); + const height = document.documentElement.scrollHeight; + const scrollY = vi.spyOn(window, "scrollY", "get"); + scrollY.mockReturnValue(height - 427); + document.dispatchEvent(new Event("scroll")); + + await page.viewport(393, 367); + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + scrollY.mockReturnValue(height - 367); + document.dispatchEvent(new Event("scroll")); + expect(scrollTo).not.toHaveBeenCalled(); +}); From 09e3a8f9efcdd989102fe4ae3da5ed15cf067df3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 15:43:11 +0200 Subject: [PATCH 2/3] Simplified `useVirtualKeyboard` logic --- .../FormattingToolbar/useVirtualKeyboard.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts index 38a6509df7..6e29cf6a87 100644 --- a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts +++ b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts @@ -128,27 +128,20 @@ export function useVirtualKeyboard(): boolean { function clampDocumentScroll() { const height = vp?.height ?? window.innerHeight; const scale = vp?.scale ?? 1; - const previousHeight = - (unclippedKeyboardHeight ?? height * scale) / scale; - - // At the bottom Safari also clips visualViewport.height to the remaining - // document. Retain the last unclipped measurement there; otherwise each - // smaller height would admit more of the blank region. Account for the - // document scroll event arriving before the visual viewport catches up. - // innerHeight is clipped too, including briefly after scrollTo. A real - // taller keyboard leaves room below the visual viewport in innerHeight. - // When the keyboard resizes the layout viewport (Android), use the new - // height directly instead of retaining the previous keyboard's size. - if ( - unclippedKeyboardHeight === undefined || - html.clientHeight <= height * scale + 1 || - height >= previousHeight || + const scaledHeight = height * scale; + + // Accept a smaller height when the keyboard resizes the layout (Android), + // or leaves space below the visual viewport before the document's end. + // Safari's clipped measurements at the bottom satisfy neither condition. + const keyboardResized = + html.clientHeight <= scaledHeight + 1 || (window.innerHeight > height + 1 && Math.max(window.scrollY, vp?.pageTop ?? 0) + height < - html.scrollHeight - 1) - ) { - unclippedKeyboardHeight = height * scale; - } + html.scrollHeight - 1); + + unclippedKeyboardHeight = keyboardResized + ? scaledHeight + : Math.max(unclippedKeyboardHeight ?? 0, scaledHeight); const max = Math.max( 0, html.scrollHeight - unclippedKeyboardHeight / scale, From 28bc8081c9c5152fe9810f896d73fc24845a3935 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 17 Sep 2026 15:43:19 +0200 Subject: [PATCH 3/3] Removed tests --- .../mobile/virtualKeyboardScroll.test.tsx | 133 ------------------ 1 file changed, 133 deletions(-) delete mode 100644 tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx diff --git a/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx b/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx deleted file mode 100644 index e1b154bb0e..0000000000 --- a/tests/src/end-to-end/mobile/virtualKeyboardScroll.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useVirtualKeyboard } from "@blocknote/react"; -import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; -import { cleanup, render } from "vitest-browser-react"; - -import { page } from "../../utils/context.js"; - -function ScrollingPage() { - useVirtualKeyboard(); - return
Scrollable document
; -} - -beforeEach(async () => { - await page.viewport(393, 727); - await render(); -}); - -afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); -}); - -// Browser emulation resizes the layout viewport with the keyboard. Real iOS -// leaves it unchanged, allows window.scrollY to include the viewport pan, and -// can scroll another 98px into Safari's accessory-bar inset. Reproduce those -// measurements here; native gestures on the iOS simulator verify the model. -test("can scroll to the visual viewport's bottom with the keyboard open", () => { - const viewport = window.visualViewport!; - vi.spyOn(viewport, "height", "get").mockReturnValue(427); - viewport.dispatchEvent(new Event("resize")); - - const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); - const scrollY = vi.spyOn(window, "scrollY", "get"); - const height = document.documentElement.scrollHeight; - - // Both positions exceed the layout maximum, but still contain real content. - scrollY.mockReturnValue(height - 727 + 100); - document.dispatchEvent(new Event("scroll")); - scrollY.mockReturnValue(height - 427); - document.dispatchEvent(new Event("scroll")); - viewport.dispatchEvent(new Event("scroll")); - - expect(scrollTo).not.toHaveBeenCalled(); -}); - -test("excludes Safari's blank bottom inset even when it clips the viewport measurement", () => { - const viewport = window.visualViewport!; - const viewportHeight = vi.spyOn(viewport, "height", "get"); - viewportHeight.mockReturnValue(427); - viewport.dispatchEvent(new Event("resize")); - - const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); - const height = document.documentElement.scrollHeight; - const scrollY = vi.spyOn(window, "scrollY", "get"); - scrollY.mockReturnValue(height - 427 + 98); - vi.spyOn(window, "scrollX", "get").mockReturnValue(12); - - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); - - // A second drag at the bottom can shrink the reported height before the - // scroll listener runs. It must not reopen the extra 98px of scroll range. - viewportHeight.mockReturnValue(427 - 98); - viewport.dispatchEvent(new Event("resize")); - expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); - - // The document responds to scrollTo before Safari restores its viewport - // measurements. That stale resize must not replace the unclipped height. - scrollY.mockReturnValue(height - 427); - vi.spyOn(window, "innerHeight", "get").mockReturnValue(427 - 98); - viewport.dispatchEvent(new Event("resize")); - scrollY.mockReturnValue(height - 427 + 98); - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).toHaveBeenLastCalledWith(12, height - 427); -}); - -test("remeasures the keyboard after it closes and preserves keyboard-closed overscroll", () => { - const viewport = window.visualViewport!; - const viewportHeight = vi.spyOn(viewport, "height", "get"); - viewportHeight.mockReturnValue(427); - viewport.dispatchEvent(new Event("resize")); - - viewportHeight.mockReturnValue(727); - viewport.dispatchEvent(new Event("resize")); - const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); - const height = document.documentElement.scrollHeight; - const scrollY = vi.spyOn(window, "scrollY", "get"); - scrollY.mockReturnValue(height - 727 + 50); - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).not.toHaveBeenCalled(); - - // Reopen a taller keyboard while still at the bottom of the closed viewport. - viewportHeight.mockReturnValue(367); - viewport.dispatchEvent(new Event("resize")); - scrollY.mockReturnValue(height - 367 + 98); - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).toHaveBeenLastCalledWith(window.scrollX, height - 367); -}); - -test("a taller keyboard at the bottom makes the remaining content reachable", () => { - const viewport = window.visualViewport!; - const viewportHeight = vi.spyOn(viewport, "height", "get"); - viewportHeight.mockReturnValue(427); - viewport.dispatchEvent(new Event("resize")); - - const height = document.documentElement.scrollHeight; - const scrollY = vi.spyOn(window, "scrollY", "get"); - scrollY.mockReturnValue(height - 427); - document.dispatchEvent(new Event("scroll")); - - // A suggestion strip shrinks the visible area without scrolling beyond the - // document, unlike Safari's clipped measurement after a drag into the inset. - vi.spyOn(window, "innerHeight", "get").mockReturnValue(427); - viewportHeight.mockReturnValue(367); - viewport.dispatchEvent(new Event("resize")); - const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); - scrollY.mockReturnValue(height - 367); - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).not.toHaveBeenCalled(); -}); - -test("updates the scroll limit when the keyboard resizes the layout viewport", async () => { - await page.viewport(393, 427); - const height = document.documentElement.scrollHeight; - const scrollY = vi.spyOn(window, "scrollY", "get"); - scrollY.mockReturnValue(height - 427); - document.dispatchEvent(new Event("scroll")); - - await page.viewport(393, 367); - const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); - scrollY.mockReturnValue(height - 367); - document.dispatchEvent(new Event("scroll")); - expect(scrollTo).not.toHaveBeenCalled(); -});