Skip to content

Commit a33541e

Browse files
authored
fix: flicker-free mobile formatting toolbar via CSS custom properties (#2617)
* fix: flicker-free mobile formatting toolbar via CSS custom properties 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 * fix: replace hardcoded TOOLBAR_HEIGHT with dynamic measurement
1 parent 2e2d225 commit a33541e

2 files changed

Lines changed: 124 additions & 40 deletions

File tree

packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx

Lines changed: 109 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
22
import { FormattingToolbarExtension } from "@blocknote/core/extensions";
3-
import { FC, CSSProperties, useMemo, useRef, useState, useEffect } from "react";
3+
import { FC, useRef, useEffect } from "react";
44

55
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
66
import { useExtensionState } from "../../hooks/useExtension.js";
77
import { FormattingToolbar } from "./FormattingToolbar.js";
88
import { 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
*/
1624
export 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
);

packages/react/src/editor/styles.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,21 @@ inline styles, it is added to the base z-index. */
294294
border: 2px solid #23405b;
295295
}
296296

297+
/* Mobile formatting toolbar positioning */
298+
.bn-mobile-formatting-toolbar {
299+
display: flex;
300+
position: fixed;
301+
bottom: var(--bn-mobile-keyboard-offset, 0px);
302+
left: 0;
303+
right: 0;
304+
z-index: calc(var(--bn-ui-base-z-index) + 40);
305+
transition: bottom 0.15s ease-out;
306+
touch-action: pan-x;
307+
-webkit-overflow-scrolling: touch;
308+
overflow-x: auto;
309+
padding-bottom: env(safe-area-inset-bottom, 0);
310+
}
311+
297312
/* Emoji Picker styling */
298313
.bn-root em-emoji-picker {
299314
max-height: 100%;

0 commit comments

Comments
 (0)