-
-
Notifications
You must be signed in to change notification settings - Fork 718
fix: flicker-free mobile formatting toolbar via CSS custom properties #2617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FormattingToolbarProps>; | ||
| }) => { | ||
| const [transform, setTransform] = useState<string>("none"); | ||
| const divRef = useRef<HTMLDivElement>(null); | ||
| const editor = useBlockNoteEditor< | ||
| BlockSchema, | ||
|
|
@@ -28,60 +37,121 @@ export const ExperimentalMobileFormattingToolbarController = (props: { | |
| editor, | ||
| }); | ||
|
|
||
| const style = useMemo<CSSProperties>(() => { | ||
| 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<typeof setTimeout>; | ||
|
|
||
| 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", | ||
| }); | ||
| } | ||
| }; | ||
|
Comment on lines
+51
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unclear to me why we would need to be scrolling for this?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the keyboard opens, the visible area shrinks. If the cursor is near the bottom, it ends up hidden behind the keyboard + toolbar. This scrolls it back into view so users can see what they're typing. That said — happy to split this into a separate PR if you'd prefer to keep this one focused on the flicker fix. |
||
|
|
||
| // 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 ( | ||
| <div | ||
| ref={divRef} | ||
| style={style} | ||
| className="bn-mobile-formatting-toolbar" | ||
| dangerouslySetInnerHTML={{ __html: divRef.current.innerHTML }} | ||
| ></div> | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| const Component = props.formattingToolbar || FormattingToolbar; | ||
|
|
||
| return ( | ||
| <div ref={divRef} style={style}> | ||
| <div ref={divRef} className="bn-mobile-formatting-toolbar"> | ||
| <Component /> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure that it is a great idea to hard code this. I understand reading it once & then using a cached value, but hard coding upfront feels wrong to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call — replaced with
el.getBoundingClientRect().height || 44so it measures the actual rendered toolbar at call time. Works correctly with customformattingToolbarcomponents too.