11import { BlockSchema , InlineContentSchema , StyleSchema } from "@blocknote/core" ;
22import { FormattingToolbarExtension } from "@blocknote/core/extensions" ;
3- import { FC , CSSProperties , useMemo , useRef , useState , useEffect } from "react" ;
3+ import { FC , useRef , useEffect } from "react" ;
44
55import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js" ;
66import { useExtensionState } from "../../hooks/useExtension.js" ;
77import { FormattingToolbar } from "./FormattingToolbar.js" ;
88import { FormattingToolbarProps } from "./FormattingToolbarProps.js" ;
99
1010/**
11- * Experimental formatting toolbar controller for mobile devices.
12- * Uses Visual Viewport API to position the toolbar above the virtual keyboard.
11+ * Flicker-free mobile formatting toolbar controller.
1312 *
14- * Currently marked experimental due to the flickering issue with positioning cause by the use of the API (and likely a delay in its updates).
13+ * Uses a CSS custom property (`--bn-mobile-keyboard-offset`) instead of React
14+ * state to position the toolbar above the virtual keyboard. This avoids the
15+ * re-render storm that caused visible flickering in the previous implementation.
16+ *
17+ * Two-tier keyboard detection:
18+ * 1. **VirtualKeyboard API** (Chrome / Edge 94+, Samsung Internet) — provides
19+ * exact keyboard geometry before the animation starts.
20+ * 2. **Visual Viewport API fallback** (Safari iOS 13+, Firefox Android 68+) —
21+ * computes keyboard height from the difference between layout and visual
22+ * viewport, with focus-based prediction for instant initial positioning.
1523 */
1624export const ExperimentalMobileFormattingToolbarController = ( props : {
1725 formattingToolbar ?: FC < FormattingToolbarProps > ;
1826} ) => {
19- const [ transform , setTransform ] = useState < string > ( "none" ) ;
2027 const divRef = useRef < HTMLDivElement > ( null ) ;
2128 const editor = useBlockNoteEditor <
2229 BlockSchema ,
@@ -28,60 +35,122 @@ export const ExperimentalMobileFormattingToolbarController = (props: {
2835 editor,
2936 } ) ;
3037
31- const style = useMemo < CSSProperties > ( ( ) => {
32- return {
33- display : "flex" ,
34- position : "fixed" ,
35- bottom : 0 ,
36- zIndex : `calc(var(--bn-ui-base-z-index) + 40)` ,
37- transform,
38- } ;
39- } , [ transform ] ) ;
40-
4138 useEffect ( ( ) => {
42- const viewport = window . visualViewport ! ;
43- function viewportHandler ( ) {
44- // Calculate the offset necessary to set the toolbar above the virtual keyboard (using the offset info from the visualViewport)
45- const layoutViewport = document . body ;
46- const offsetLeft = viewport . offsetLeft ;
47- const offsetTop =
48- viewport . height -
49- layoutViewport . getBoundingClientRect ( ) . height +
50- viewport . offsetTop ;
51-
52- setTransform (
53- `translate(${ offsetLeft } px, ${ offsetTop } px) scale(${
54- 1 / viewport . scale
55- } )`,
39+ const el = divRef . current ;
40+ if ( ! el ) return ;
41+
42+ const setOffset = ( px : number ) => {
43+ el . style . setProperty (
44+ "--bn-mobile-keyboard-offset" ,
45+ px > 0 ? `${ px } px` : "0px" ,
5646 ) ;
47+ } ;
48+
49+ let scrollTimer : ReturnType < typeof setTimeout > ;
50+
51+ const scrollSelectionIntoView = ( ) => {
52+ const sel = window . getSelection ( ) ;
53+ if ( ! sel || sel . rangeCount === 0 ) return ;
54+ const rect = sel . getRangeAt ( 0 ) . getBoundingClientRect ( ) ;
55+ const vp = window . visualViewport ;
56+ if ( ! vp ) return ;
57+ const toolbarHeight = el . getBoundingClientRect ( ) . height || 44 ;
58+ const visibleBottom = vp . offsetTop + vp . height - toolbarHeight ;
59+ if ( rect . bottom > visibleBottom ) {
60+ window . scrollBy ( {
61+ top : rect . bottom - visibleBottom + 16 ,
62+ behavior : "smooth" ,
63+ } ) ;
64+ } else if ( rect . top < vp . offsetTop ) {
65+ window . scrollBy ( {
66+ top : rect . top - vp . offsetTop - 16 ,
67+ behavior : "smooth" ,
68+ } ) ;
69+ }
70+ } ;
71+
72+ // Tier 1: VirtualKeyboard API (Chrome/Edge 94+) — exact geometry, no delay
73+ const vk = ( navigator as any ) . virtualKeyboard ;
74+ if ( vk ) {
75+ vk . overlaysContent = true ;
76+ const onGeometryChange = ( ) => {
77+ setOffset ( vk . boundingRect . height ) ;
78+ clearTimeout ( scrollTimer ) ;
79+ scrollTimer = setTimeout ( scrollSelectionIntoView , 100 ) ;
80+ } ;
81+ vk . addEventListener ( "geometrychange" , onGeometryChange ) ;
82+ const onSelectionChange = ( ) => scrollSelectionIntoView ( ) ;
83+ document . addEventListener ( "selectionchange" , onSelectionChange ) ;
84+ return ( ) => {
85+ vk . removeEventListener ( "geometrychange" , onGeometryChange ) ;
86+ document . removeEventListener ( "selectionchange" , onSelectionChange ) ;
87+ clearTimeout ( scrollTimer ) ;
88+ } ;
5789 }
58- window . visualViewport ! . addEventListener ( "scroll" , viewportHandler ) ;
59- window . visualViewport ! . addEventListener ( "resize" , viewportHandler ) ;
60- viewportHandler ( ) ;
6190
91+ // Tier 2: Visual Viewport API fallback (Safari iOS, Firefox Android)
92+ const vp = window . visualViewport ;
93+ if ( ! vp ) return ;
94+
95+ let lastKnownKeyboardHeight = 0 ;
96+
97+ const update = ( ) => {
98+ const layoutHeight = document . documentElement . clientHeight ;
99+ const keyboardHeight = layoutHeight - vp . height - vp . offsetTop ;
100+ if ( keyboardHeight > 50 ) lastKnownKeyboardHeight = keyboardHeight ;
101+ setOffset ( keyboardHeight ) ;
102+ clearTimeout ( scrollTimer ) ;
103+ scrollTimer = setTimeout ( scrollSelectionIntoView , 100 ) ;
104+ } ;
105+
106+ const onFocusIn = ( e : FocusEvent ) => {
107+ const target = e . target as HTMLElement ;
108+ if (
109+ target . isContentEditable ||
110+ target . tagName === "INPUT" ||
111+ target . tagName === "TEXTAREA"
112+ ) {
113+ if ( lastKnownKeyboardHeight > 0 ) {
114+ setOffset ( lastKnownKeyboardHeight ) ;
115+ }
116+ }
117+ } ;
118+
119+ const onFocusOut = ( ) => {
120+ setOffset ( 0 ) ;
121+ } ;
122+
123+ const onSelectionChange = ( ) => scrollSelectionIntoView ( ) ;
124+
125+ vp . addEventListener ( "resize" , update ) ;
126+ vp . addEventListener ( "scroll" , update ) ;
127+ document . addEventListener ( "focusin" , onFocusIn ) ;
128+ document . addEventListener ( "focusout" , onFocusOut ) ;
129+ document . addEventListener ( "selectionchange" , onSelectionChange ) ;
62130 return ( ) => {
63- window . visualViewport ! . removeEventListener ( "scroll" , viewportHandler ) ;
64- window . visualViewport ! . removeEventListener ( "resize" , viewportHandler ) ;
131+ vp . removeEventListener ( "resize" , update ) ;
132+ vp . removeEventListener ( "scroll" , update ) ;
133+ document . removeEventListener ( "focusin" , onFocusIn ) ;
134+ document . removeEventListener ( "focusout" , onFocusOut ) ;
135+ document . removeEventListener ( "selectionchange" , onSelectionChange ) ;
136+ clearTimeout ( scrollTimer ) ;
65137 } ;
66138 } , [ ] ) ;
67139
68140 if ( ! show && divRef . current ) {
69- // The component is fading out. Use the previous state to render the toolbar with innerHTML,
70- // because otherwise the toolbar will quickly flickr (i.e.: show a different state) while fading out,
71- // which looks weird
72141 return (
73142 < div
74143 ref = { divRef }
75- style = { style }
144+ className = "bn-mobile-formatting-toolbar"
76145 dangerouslySetInnerHTML = { { __html : divRef . current . innerHTML } }
77- > </ div >
146+ / >
78147 ) ;
79148 }
80149
81150 const Component = props . formattingToolbar || FormattingToolbar ;
82151
83152 return (
84- < div ref = { divRef } style = { style } >
153+ < div ref = { divRef } className = "bn-mobile-formatting-toolbar" >
85154 < Component />
86155 </ div >
87156 ) ;
0 commit comments