From 207c5068ad68e77227d1bb721a970b8e478f6657 Mon Sep 17 00:00:00 2001 From: movm Date: Thu, 2 Apr 2026 13:27:19 +0200 Subject: [PATCH 1/2] fix: flicker-free mobile formatting toolbar via CSS custom properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace React state-driven positioning with CSS custom property (--bn-mobile-keyboard-offset) for zero re-render toolbar positioning. Two-tier keyboard detection: 1. VirtualKeyboard API (Chrome/Edge 94+) — exact geometry, no delay 2. Visual Viewport API fallback (Safari iOS 13+, Firefox 68+) — with focus-based prediction for instant initial positioning Additional improvements: - Auto-scroll selection into view when keyboard opens - touch-action: pan-x for horizontal toolbar scrolling - env(safe-area-inset-bottom) for notch/home indicator handling - Smooth 150ms CSS transition instead of React re-renders Closes #2616 --- ...entalMobileFormattingToolbarController.tsx | 150 +++++++++++++----- packages/react/src/editor/styles.css | 15 ++ 2 files changed, 125 insertions(+), 40 deletions(-) diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx index e7122ac16c..67fa24d034 100644 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx @@ -1,22 +1,31 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { FormattingToolbarExtension } from "@blocknote/core/extensions"; -import { FC, CSSProperties, useMemo, useRef, useState, useEffect } from "react"; +import { FC, useRef, useEffect } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; +const TOOLBAR_HEIGHT = 44; + /** - * Experimental formatting toolbar controller for mobile devices. - * Uses Visual Viewport API to position the toolbar above the virtual keyboard. + * Flicker-free mobile formatting toolbar controller. + * + * Uses a CSS custom property (`--bn-mobile-keyboard-offset`) instead of React + * state to position the toolbar above the virtual keyboard. This avoids the + * re-render storm that caused visible flickering in the previous implementation. * - * Currently marked experimental due to the flickering issue with positioning cause by the use of the API (and likely a delay in its updates). + * Two-tier keyboard detection: + * 1. **VirtualKeyboard API** (Chrome / Edge 94+, Samsung Internet) — provides + * exact keyboard geometry before the animation starts. + * 2. **Visual Viewport API fallback** (Safari iOS 13+, Firefox Android 68+) — + * computes keyboard height from the difference between layout and visual + * viewport, with focus-based prediction for instant initial positioning. */ export const ExperimentalMobileFormattingToolbarController = (props: { formattingToolbar?: FC; }) => { - const [transform, setTransform] = useState("none"); const divRef = useRef(null); const editor = useBlockNoteEditor< BlockSchema, @@ -28,60 +37,121 @@ export const ExperimentalMobileFormattingToolbarController = (props: { editor, }); - const style = useMemo(() => { - return { - display: "flex", - position: "fixed", - bottom: 0, - zIndex: `calc(var(--bn-ui-base-z-index) + 40)`, - transform, - }; - }, [transform]); - useEffect(() => { - const viewport = window.visualViewport!; - function viewportHandler() { - // Calculate the offset necessary to set the toolbar above the virtual keyboard (using the offset info from the visualViewport) - const layoutViewport = document.body; - const offsetLeft = viewport.offsetLeft; - const offsetTop = - viewport.height - - layoutViewport.getBoundingClientRect().height + - viewport.offsetTop; - - setTransform( - `translate(${offsetLeft}px, ${offsetTop}px) scale(${ - 1 / viewport.scale - })`, + const el = divRef.current; + if (!el) return; + + const setOffset = (px: number) => { + el.style.setProperty( + "--bn-mobile-keyboard-offset", + px > 0 ? `${px}px` : "0px", ); + }; + + let scrollTimer: ReturnType; + + const scrollSelectionIntoView = () => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + const rect = sel.getRangeAt(0).getBoundingClientRect(); + const vp = window.visualViewport; + if (!vp) return; + const visibleBottom = vp.offsetTop + vp.height - TOOLBAR_HEIGHT; + if (rect.bottom > visibleBottom) { + window.scrollBy({ + top: rect.bottom - visibleBottom + 16, + behavior: "smooth", + }); + } else if (rect.top < vp.offsetTop) { + window.scrollBy({ + top: rect.top - vp.offsetTop - 16, + behavior: "smooth", + }); + } + }; + + // Tier 1: VirtualKeyboard API (Chrome/Edge 94+) — exact geometry, no delay + const vk = (navigator as any).virtualKeyboard; + if (vk) { + vk.overlaysContent = true; + const onGeometryChange = () => { + setOffset(vk.boundingRect.height); + clearTimeout(scrollTimer); + scrollTimer = setTimeout(scrollSelectionIntoView, 100); + }; + vk.addEventListener("geometrychange", onGeometryChange); + const onSelectionChange = () => scrollSelectionIntoView(); + document.addEventListener("selectionchange", onSelectionChange); + return () => { + vk.removeEventListener("geometrychange", onGeometryChange); + document.removeEventListener("selectionchange", onSelectionChange); + clearTimeout(scrollTimer); + }; } - window.visualViewport!.addEventListener("scroll", viewportHandler); - window.visualViewport!.addEventListener("resize", viewportHandler); - viewportHandler(); + // Tier 2: Visual Viewport API fallback (Safari iOS, Firefox Android) + const vp = window.visualViewport; + if (!vp) return; + + let lastKnownKeyboardHeight = 0; + + const update = () => { + const layoutHeight = document.documentElement.clientHeight; + const keyboardHeight = layoutHeight - vp.height - vp.offsetTop; + if (keyboardHeight > 50) lastKnownKeyboardHeight = keyboardHeight; + setOffset(keyboardHeight); + clearTimeout(scrollTimer); + scrollTimer = setTimeout(scrollSelectionIntoView, 100); + }; + + const onFocusIn = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if ( + target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" + ) { + if (lastKnownKeyboardHeight > 0) { + setOffset(lastKnownKeyboardHeight); + } + } + }; + + const onFocusOut = () => { + setOffset(0); + }; + + const onSelectionChange = () => scrollSelectionIntoView(); + + vp.addEventListener("resize", update); + vp.addEventListener("scroll", update); + document.addEventListener("focusin", onFocusIn); + document.addEventListener("focusout", onFocusOut); + document.addEventListener("selectionchange", onSelectionChange); return () => { - window.visualViewport!.removeEventListener("scroll", viewportHandler); - window.visualViewport!.removeEventListener("resize", viewportHandler); + vp.removeEventListener("resize", update); + vp.removeEventListener("scroll", update); + document.removeEventListener("focusin", onFocusIn); + document.removeEventListener("focusout", onFocusOut); + document.removeEventListener("selectionchange", onSelectionChange); + clearTimeout(scrollTimer); }; }, []); if (!show && divRef.current) { - // The component is fading out. Use the previous state to render the toolbar with innerHTML, - // because otherwise the toolbar will quickly flickr (i.e.: show a different state) while fading out, - // which looks weird return (
+ /> ); } const Component = props.formattingToolbar || FormattingToolbar; return ( -
+
); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 3862946986..e6c9920859 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -294,6 +294,21 @@ inline styles, it is added to the base z-index. */ border: 2px solid #23405b; } +/* Mobile formatting toolbar positioning */ +.bn-mobile-formatting-toolbar { + display: flex; + position: fixed; + bottom: var(--bn-mobile-keyboard-offset, 0px); + left: 0; + right: 0; + z-index: calc(var(--bn-ui-base-z-index) + 40); + transition: bottom 0.15s ease-out; + touch-action: pan-x; + -webkit-overflow-scrolling: touch; + overflow-x: auto; + padding-bottom: env(safe-area-inset-bottom, 0); +} + /* Emoji Picker styling */ em-emoji-picker { max-height: 100%; From 63e5b4444cf9881d2761b3caee86ff0ce7489192 Mon Sep 17 00:00:00 2001 From: movm Date: Fri, 3 Apr 2026 19:39:17 +0200 Subject: [PATCH 2/2] fix: replace hardcoded TOOLBAR_HEIGHT with dynamic measurement --- .../ExperimentalMobileFormattingToolbarController.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx index 67fa24d034..d7cfe57d86 100644 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx @@ -7,8 +7,6 @@ import { useExtensionState } from "../../hooks/useExtension.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; -const TOOLBAR_HEIGHT = 44; - /** * Flicker-free mobile formatting toolbar controller. * @@ -56,7 +54,8 @@ export const ExperimentalMobileFormattingToolbarController = (props: { const rect = sel.getRangeAt(0).getBoundingClientRect(); const vp = window.visualViewport; if (!vp) return; - const visibleBottom = vp.offsetTop + vp.height - TOOLBAR_HEIGHT; + const toolbarHeight = el.getBoundingClientRect().height || 44; + const visibleBottom = vp.offsetTop + vp.height - toolbarHeight; if (rect.bottom > visibleBottom) { window.scrollBy({ top: rect.bottom - visibleBottom + 16,