From 03e86dd23c55f1ede1e9b8a6a3a275d9248d542a Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 3 Aug 2026 18:58:39 +0200 Subject: [PATCH 1/4] Added demo --- .../src/App.tsx | 167 +++++++++++++++--- .../src/DummyUI.tsx | 78 ++++++++ .../src/style.css | 145 ++++++++++++++- 3 files changed, 357 insertions(+), 33 deletions(-) create mode 100644 examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx index 47d59e453c..def7b64b5b 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx @@ -1,39 +1,148 @@ import "@blocknote/core/fonts/inter.css"; -import { - ExperimentalMobileFormattingToolbarController, - useCreateBlockNote, -} from "@blocknote/react"; +import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import "./style.css"; +import { useEffect, useState } from "react"; +import { StaticText, NavBar } from "./DummyUI"; +import { createPortal } from "react-dom"; + +// Enough content that the editor actually overflows, so scrolling is testable. +const initialContent = [ + { type: "paragraph" as const, content: "Welcome to this demo!" }, + { + type: "paragraph" as const, + content: + "Select some text to bring up the keyboard, then scroll — the bar stays " + + "pinned above the keyboard because the document itself doesn't scroll.", + }, + ...Array.from({ length: 20 }, (_, i) => ({ + type: "paragraph" as const, + content: + `Filler paragraph ${i + 1}. Select some text here and bring up the ` + + "keyboard to see the bar sit above it.", + })), +]; + +type VisualViewportRect = { + top: number; + left: number; + width: number; + height: number; + scale: number; +}; + +function readVisualViewport(): VisualViewportRect { + const vp = visualViewport; + return { + top: vp?.offsetTop ?? 0, + left: vp?.offsetLeft ?? 0, + width: vp?.width ?? window.innerWidth, + height: vp?.height ?? window.innerHeight, + scale: vp?.scale ?? 1, + }; +} + +/** + * Owns everything about the visual viewport: + * + * - Locks the document so it never scrolls — a `.scroll-host` element does (see + * the CSS). With a non-scrolling document, content scrolling is an element + * scroll that never moves the visual viewport, so anything pinned to it stays + * put during scroll with no per-frame work. + * - Tracks the viewport rectangle + pinch-zoom scale and publishes it two ways: + * as the returned object (for JS, e.g. keyboard detection) and as CSS custom + * properties on the root (`--app-top/left/width/height/scale`) so elements can + * position themselves off the viewport without a React re-render. + */ +function useVisualViewport(): VisualViewportRect { + const [rect, setRect] = useState(readVisualViewport); + + useEffect(() => { + const html = document.documentElement; + const body = document.body; + + // Original values only saved to be able to restore when the parent component unmounts. + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + const prevHtmlOverscroll = html.style.overscrollBehavior; + + // Disables scrolling on `document` & `document.body`. + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + // TODO: Manually test if necessary. + // html.style.overscrollBehavior = "none"; + + const vp = visualViewport; + const update = () => { + const next = readVisualViewport(); + + setRect(next); + + html.style.setProperty("--app-top", `${next.top}px`); + html.style.setProperty("--app-left", `${next.left}px`); + html.style.setProperty("--app-width", `${next.width}px`); + html.style.setProperty("--app-height", `${next.height}px`); + html.style.setProperty("--app-scale", `${next.scale}`); + }; + update(); + + // These fire on keyboard open/close and zoom/pan — never on (element) content scroll, since + // the document itself can't scroll. + vp?.addEventListener("resize", update); + vp?.addEventListener("scroll", update); + window.addEventListener("resize", update); + + return () => { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + html.style.overscrollBehavior = prevHtmlOverscroll; + + html.style.removeProperty("--app-top"); + html.style.removeProperty("--app-left"); + html.style.removeProperty("--app-width"); + html.style.removeProperty("--app-height"); + html.style.removeProperty("--app-scale"); + + vp?.removeEventListener("resize", update); + vp?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, []); + + return rect; +} + +let maxLayoutViewportHeight = 0; +function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { + const layoutHeight = viewport.height * viewport.scale; + maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); + return maxLayoutViewportHeight - layoutHeight > 150; +} + +function VirtualKeyboardToolbar() { + return createPortal( +
Virtual Keyboard Toolbar
, + document.body, + ); +} export default function App() { - // Creates a new editor instance. - const editor = useCreateBlockNote({ - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - content: - "Check out the experimental mobile formatting toolbar by selecting some text (best experienced on a mobile device).", - }, - ], - }); - - // Renders the editor instance using a React component. + const editor = useCreateBlockNote({ initialContent }); + + const viewport = useVisualViewport(); + return ( - // Disables the default formatting toolbar and re-adds it without the - // `FormattingToolbarController` component. You may have seen - // `FormattingToolbarController` used in other examples, but we omit it here - // as we want to control the position and visibility ourselves. BlockNote - // also uses the `FormattingToolbarController` when displaying the - // Formatting Toolbar by default. - - - +
+ +
+ + + {isVirtualKeyboardOpen(viewport) && } + + +
+
); } diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx new file mode 100644 index 0000000000..a778e4f26f --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +function HamburgerMenu() { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( + + )} +
+ ); +} + +export function NavBar() { + return ( +
+ + Lorem Ipsum +
+ ); +} + +/** A block of static page text, to sit around the editor. */ +export function StaticText() { + return ( +
+

Lorem Ipsum

+

+ Elit ipsum qui deserunt deserunt. Qui labore eu esse veniam excepteur. + Aute ipsum qui dolore in ipsum commodo adipisicing velit. Qui + consectetur et cupidatat consectetur sunt anim excepteur reprehenderit + sunt quis magna aliqua laborum. Lorem irure est ipsum ea nisi incididunt + culpa qui consequat eiusmod deserunt ipsum nostrud velit laboris. +

+

+ Culpa quis id ipsum enim proident dolore non. Ad occaecat nostrud + eiusmod pariatur occaecat nisi voluptate nulla. Nisi quis ut esse ex + reprehenderit Lorem tempor ex tempor id sit officia. Commodo sunt sint + aliqua quis reprehenderit. Occaecat id ad dolor officia qui sunt dolor. + Consectetur magna excepteur in minim pariatur qui elit in sit consequat + aliquip voluptate laboris. Reprehenderit et eu dolor ex cupidatat aliqua + in elit anim eiusmod et adipisicing. Cupidatat fugiat fugiat amet duis. +

+

+ Voluptate quis dolor ipsum commodo fugiat sit tempor tempor non aliqua + qui. Veniam consectetur mollit consequat exercitation sit ad. Lorem amet + deserunt qui sint et. Sint aute cillum aliqua pariatur cillum id. + Consectetur proident Lorem qui laborum id in sit. Aute aute irure nisi + est veniam Lorem. Anim labore irure ut sit mollit velit et duis veniam + ipsum aliquip. +

+

+ Occaecat dolore excepteur qui proident laborum. Dolor deserunt cillum + veniam nulla minim eu in est aute nulla anim incididunt ea. Anim aliquip + aute duis aliqua eu pariatur est dolor magna Lorem dolore do sunt + aliquip est. Laborum pariatur fugiat do reprehenderit tempor cupidatat + proident ipsum ad dolor laboris. +

+
+ ); +} diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css index 98e93611cd..09aa2a9963 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css @@ -1,9 +1,146 @@ +html, +body { + margin: 0; +} + +/* Fixed-height, internally scrollable editor — a nested scroll container inside + the page's `.scroll-host`, to check nested scrolling works. */ .bn-container { + height: 300px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +/* --- App shell (see DemoChrome) --- */ + +.top-nav { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 12px; + height: 48px; + padding: 0 12px; + background: #1a1a1a; + color: #fff; +} + +.top-nav-title { + font: 600 15px/1 sans-serif; +} + +.hamburger { + position: relative; +} + +.hamburger-button { display: flex; - flex-direction: column-reverse; - gap: 8px; + flex-direction: column; + justify-content: space-between; + width: 22px; + height: 16px; + padding: 0; + background: none; + border: none; + cursor: pointer; } -.bn-formatting-toolbar { - margin-inline: auto; +.hamburger-button span { + display: block; + height: 2px; + border-radius: 1px; + background: #fff; +} + +.hamburger-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + display: flex; + flex-direction: column; + min-width: 180px; + padding: 8px; + background: #fff; + color: #111; + border-radius: 8px; + box-shadow: 0 6px 20px rgb(0 0 0 / 0.15); +} + +.hamburger-menu a { + padding: 8px 10px; + color: inherit; + text-decoration: none; + border-radius: 6px; +} + +.hamburger-menu a:hover { + background: #f0f0f0; +} + +.app-main { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 720px; + margin: 0 auto; + padding: 16px; +} + +.prose h2 { + margin: 0 0 8px; + font: 600 18px/1.2 sans-serif; +} + +.prose p { + margin: 0 0 8px; + font: 14px/1.6 sans-serif; + color: #333; +} + +/* A top-level wrapper div is the scroll container (the document itself doesn't + scroll), pinned to the visual viewport rectangle via the `--app-*` variables + (see `useVisualViewport`) so the bar sits directly above the keyboard on iOS — + where the layout viewport doesn't resize and can be left with a nonzero + `offsetTop`. */ +.scroll-host { + position: fixed; + top: var(--app-top, 0px); + left: var(--app-left, 0px); + width: var(--app-width, 100vw); + height: var(--app-height, 100dvh); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + /* Stop overscroll at the boundary from chaining to the document. Without + this, dragging past the bottom on iOS rubber-bands the whole page, which + shifts the visual viewport (repinning the host mid-bounce → jitter) and + surfaces a second, document-level scrollbar. */ + overscroll-behavior: contain; +} + +/* The bar, pinned to the bottom of the visual viewport straight from the + `--app-*` variables — no JS positioning. `translateY(-100%)` puts its bottom + edge on the viewport bottom (avoiding a height measurement), and + `scale(1 / --app-scale)`, around that anchored bottom-left corner, cancels + pinch-zoom so it keeps its on-screen size. */ +.viewport-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + height: 20px; + font: 11px/1 sans-serif; + color: #fff; + background: rgb(255 0 0 / 0.6); + transform: translate( + var(--app-left, 0px), + calc(var(--app-top, 0px) + var(--app-height, 0px)) + ) + translateY(-100%) scale(calc(1 / var(--app-scale, 1))); + transform-origin: left bottom; + will-change: transform; } From e45f5a1ad5a9c3428fe4610f7522482a68b6031b Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 6 Aug 2026 18:02:51 +0200 Subject: [PATCH 2/4] Replaced demo toolbar with actual toolbar --- .../src/App.tsx | 120 +------ .../src/style.css | 42 +-- .../ariakit/src/toolbar/ToolbarSelect.tsx | 8 +- packages/core/src/util/browser.ts | 3 + packages/mantine/src/menu/Menu.tsx | 14 +- .../mantine/src/toolbar/ToolbarButton.tsx | 6 +- .../mantine/src/toolbar/ToolbarSelect.tsx | 24 +- .../DefaultButtons/ColorStyleButton.tsx | 6 +- .../DefaultButtons/CreateLinkButton.tsx | 3 +- .../DefaultButtons/FileCaptionButton.tsx | 3 +- .../DefaultButtons/FileRenameButton.tsx | 3 +- .../DefaultButtons/FileReplaceButton.tsx | 6 +- .../DefaultSelects/BlockTypeSelect.tsx | 6 +- ...entalMobileFormattingToolbarController.tsx | 306 ++++++++++-------- .../FormattingToolbar/FormattingToolbar.tsx | 19 +- .../MobileFormattingToolbar.tsx | 29 ++ .../react/src/editor/ComponentsContext.tsx | 1 + packages/react/src/editor/styles.css | 34 +- packages/react/src/index.ts | 1 + packages/shadcn/src/toolbar/Toolbar.tsx | 10 +- 20 files changed, 327 insertions(+), 317 deletions(-) create mode 100644 packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx index def7b64b5b..b5acb31f21 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx @@ -1,12 +1,13 @@ import "@blocknote/core/fonts/inter.css"; -import { useCreateBlockNote } from "@blocknote/react"; +import { + ExperimentalMobileFormattingToolbarController, + useCreateBlockNote, +} from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import "./style.css"; -import { useEffect, useState } from "react"; import { StaticText, NavBar } from "./DummyUI"; -import { createPortal } from "react-dom"; // Enough content that the editor actually overflows, so scrolling is testable. const initialContent = [ @@ -14,132 +15,29 @@ const initialContent = [ { type: "paragraph" as const, content: - "Select some text to bring up the keyboard, then scroll — the bar stays " + + "Select some text to bring up the toolbar, then scroll — it stays " + "pinned above the keyboard because the document itself doesn't scroll.", }, ...Array.from({ length: 20 }, (_, i) => ({ type: "paragraph" as const, content: `Filler paragraph ${i + 1}. Select some text here and bring up the ` + - "keyboard to see the bar sit above it.", + "keyboard to see the toolbar sit above it.", })), ]; -type VisualViewportRect = { - top: number; - left: number; - width: number; - height: number; - scale: number; -}; - -function readVisualViewport(): VisualViewportRect { - const vp = visualViewport; - return { - top: vp?.offsetTop ?? 0, - left: vp?.offsetLeft ?? 0, - width: vp?.width ?? window.innerWidth, - height: vp?.height ?? window.innerHeight, - scale: vp?.scale ?? 1, - }; -} - -/** - * Owns everything about the visual viewport: - * - * - Locks the document so it never scrolls — a `.scroll-host` element does (see - * the CSS). With a non-scrolling document, content scrolling is an element - * scroll that never moves the visual viewport, so anything pinned to it stays - * put during scroll with no per-frame work. - * - Tracks the viewport rectangle + pinch-zoom scale and publishes it two ways: - * as the returned object (for JS, e.g. keyboard detection) and as CSS custom - * properties on the root (`--app-top/left/width/height/scale`) so elements can - * position themselves off the viewport without a React re-render. - */ -function useVisualViewport(): VisualViewportRect { - const [rect, setRect] = useState(readVisualViewport); - - useEffect(() => { - const html = document.documentElement; - const body = document.body; - - // Original values only saved to be able to restore when the parent component unmounts. - const prevHtmlOverflow = html.style.overflow; - const prevBodyOverflow = body.style.overflow; - const prevHtmlOverscroll = html.style.overscrollBehavior; - - // Disables scrolling on `document` & `document.body`. - html.style.overflow = "hidden"; - body.style.overflow = "hidden"; - // TODO: Manually test if necessary. - // html.style.overscrollBehavior = "none"; - - const vp = visualViewport; - const update = () => { - const next = readVisualViewport(); - - setRect(next); - - html.style.setProperty("--app-top", `${next.top}px`); - html.style.setProperty("--app-left", `${next.left}px`); - html.style.setProperty("--app-width", `${next.width}px`); - html.style.setProperty("--app-height", `${next.height}px`); - html.style.setProperty("--app-scale", `${next.scale}`); - }; - update(); - - // These fire on keyboard open/close and zoom/pan — never on (element) content scroll, since - // the document itself can't scroll. - vp?.addEventListener("resize", update); - vp?.addEventListener("scroll", update); - window.addEventListener("resize", update); - - return () => { - html.style.overflow = prevHtmlOverflow; - body.style.overflow = prevBodyOverflow; - html.style.overscrollBehavior = prevHtmlOverscroll; - - html.style.removeProperty("--app-top"); - html.style.removeProperty("--app-left"); - html.style.removeProperty("--app-width"); - html.style.removeProperty("--app-height"); - html.style.removeProperty("--app-scale"); - - vp?.removeEventListener("resize", update); - vp?.removeEventListener("scroll", update); - window.removeEventListener("resize", update); - }; - }, []); - - return rect; -} - -let maxLayoutViewportHeight = 0; -function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { - const layoutHeight = viewport.height * viewport.scale; - maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); - return maxLayoutViewportHeight - layoutHeight > 150; -} - -function VirtualKeyboardToolbar() { - return createPortal( -
Virtual Keyboard Toolbar
, - document.body, - ); -} - export default function App() { const editor = useCreateBlockNote({ initialContent }); - const viewport = useVisualViewport(); - return ( + // The document itself doesn't scroll — the controller locks it and pins this + // `.scroll-host` to the visual viewport via the `--bn-vv-*` variables.
- {isVirtualKeyboardOpen(viewport) && } +
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css index 09aa2a9963..f9c4d1f613 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css @@ -100,16 +100,16 @@ body { } /* A top-level wrapper div is the scroll container (the document itself doesn't - scroll), pinned to the visual viewport rectangle via the `--app-*` variables - (see `useVisualViewport`) so the bar sits directly above the keyboard on iOS — - where the layout viewport doesn't resize and can be left with a nonzero - `offsetTop`. */ + scroll — the controller locks it), pinned to the visual viewport rectangle via + the `--bn-vv-*` variables the controller publishes, so it sits directly above + the keyboard on iOS — where the layout viewport doesn't resize and can be left + with a nonzero `offsetTop`. */ .scroll-host { position: fixed; - top: var(--app-top, 0px); - left: var(--app-left, 0px); - width: var(--app-width, 100vw); - height: var(--app-height, 100dvh); + top: var(--bn-vv-top, 0px); + left: var(--bn-vv-left, 0px); + width: var(--bn-vv-width, 100vw); + height: var(--bn-vv-height, 100dvh); overflow-y: auto; -webkit-overflow-scrolling: touch; /* Stop overscroll at the boundary from chaining to the document. Without @@ -118,29 +118,3 @@ body { surfaces a second, document-level scrollbar. */ overscroll-behavior: contain; } - -/* The bar, pinned to the bottom of the visual viewport straight from the - `--app-*` variables — no JS positioning. `translateY(-100%)` puts its bottom - edge on the viewport bottom (avoiding a height measurement), and - `scale(1 / --app-scale)`, around that anchored bottom-left corner, cancels - pinch-zoom so it keeps its on-screen size. */ -.viewport-bar { - position: fixed; - top: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - height: 20px; - font: 11px/1 sans-serif; - color: #fff; - background: rgb(255 0 0 / 0.6); - transform: translate( - var(--app-left, 0px), - calc(var(--app-top, 0px) + var(--app-height, 0px)) - ) - translateY(-100%) scale(calc(1 / var(--app-scale, 1))); - transform-origin: left bottom; - will-change: transform; -} diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index f596cbbae6..405794d05b 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,7 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, direction, ...rest } = props; assertEmpty(rest); @@ -27,7 +27,11 @@ export const ToolbarSelect = forwardRef< }; return ( - + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +export const isTouchDevice = () => + typeof navigator !== "undefined" && navigator.maxTouchPoints > 0; diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index c81ed870d7..24317378ea 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -20,12 +20,16 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { assertEmpty(rest); + // When explicitly positioned to a `top` placement (e.g. the mobile toolbar's + // color menu, opening above the keyboard) don't let `flip` send it back down. + const flip = !position?.startsWith("top"); + if (sub) { return ( @@ -37,10 +41,16 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { return ( {children} diff --git a/packages/mantine/src/toolbar/ToolbarButton.tsx b/packages/mantine/src/toolbar/ToolbarButton.tsx index 179b08b03c..60dae465cc 100644 --- a/packages/mantine/src/toolbar/ToolbarButton.tsx +++ b/packages/mantine/src/toolbar/ToolbarButton.tsx @@ -6,7 +6,7 @@ import { Tooltip as MantineTooltip, } from "@mantine/core"; -import { assertEmpty, isSafari } from "@blocknote/core"; +import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { forwardRef, useState } from "react"; @@ -60,7 +60,7 @@ export const ToolbarButton = forwardRef( // Needed as Safari doesn't focus button elements on mouse down // unlike other browsers. onMouseDown={(e) => { - if (isSafari()) { + if (isSafari() && !isTouchDevice()) { (e.currentTarget as HTMLButtonElement).focus(); } }} @@ -93,7 +93,7 @@ export const ToolbarButton = forwardRef( // Needed as Safari doesn't focus button elements on mouse down // unlike other browsers. onMouseDown={(e) => { - if (isSafari()) { + if (isSafari() && !isTouchDevice()) { (e.currentTarget as HTMLButtonElement).focus(); } }} diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 21cee2a1fd..51218b5f18 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -4,7 +4,7 @@ import { Menu as MantineMenu, } from "@mantine/core"; -import { assertEmpty, isSafari } from "@blocknote/core"; +import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { forwardRef } from "react"; import { HiChevronDown } from "react-icons/hi"; @@ -14,7 +14,7 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, direction, ...rest } = props; assertEmpty(rest); @@ -27,18 +27,34 @@ export const ToolbarSelect = forwardRef< return ( { - if (isSafari()) { + if (isSafari() && !isTouchDevice()) { (e.currentTarget as HTMLButtonElement).focus(); } }} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index d0e98c5c8f..a4259ed4cf 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -40,7 +40,7 @@ function checkColorInSchema( ); } -export const ColorStyleButton = () => { +export const ColorStyleButton = (props: { direction?: "up" | "down" }) => { const Components = useComponentsContext()!; const dict = useDictionary(); const editor = useBlockNoteEditor< @@ -136,7 +136,9 @@ export const ColorStyleButton = () => { } return ( - + { +export const CreateLinkButton = (props: { direction?: "up" | "down" }) => { const editor = useBlockNoteEditor(); const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; @@ -115,6 +115,7 @@ export const CreateLinkButton = () => { {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index a73bf3c5aa..c168131b0f 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -13,7 +13,7 @@ import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; -export const FileCaptionButton = () => { +export const FileCaptionButton = (props: { direction?: "up" | "down" }) => { const dict = useDictionary(); const Components = useComponentsContext()!; @@ -88,6 +88,7 @@ export const FileCaptionButton = () => { { +export const FileRenameButton = (props: { direction?: "up" | "down" }) => { const dict = useDictionary(); const Components = useComponentsContext()!; @@ -88,6 +88,7 @@ export const FileRenameButton = () => { { +export const FileReplaceButton = (props: { direction?: "up" | "down" }) => { const dict = useDictionary(); const Components = useComponentsContext()!; @@ -56,7 +56,9 @@ export const FileReplaceButton = () => { } return ( - + { +export const BlockTypeSelect = (props: { + items?: BlockTypeSelectItem[]; + direction?: "up" | "down"; +}) => { const Components = useComponentsContext()!; const editor = useBlockNoteEditor< @@ -212,6 +215,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx index a729bb4433..17725a0f65 100644 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx @@ -1,167 +1,191 @@ -import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; -import { FormattingToolbarExtension } from "@blocknote/core/extensions"; -import { FC, useRef, useEffect } from "react"; +import { isSafari } from "@blocknote/core"; +import { FC, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useExtensionState } from "../../hooks/useExtension.js"; -import { FormattingToolbar } from "./FormattingToolbar.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; +import { MobileFormattingToolbar } from "./MobileFormattingToolbar.js"; + +type VisualViewportRect = { + top: number; + left: number; + width: number; + height: number; + scale: number; +}; + +function readVisualViewport(): VisualViewportRect { + const vp = window.visualViewport; + return { + top: vp?.offsetTop ?? 0, + left: vp?.offsetLeft ?? 0, + width: vp?.width ?? window.innerWidth, + height: vp?.height ?? window.innerHeight, + scale: vp?.scale ?? 1, + }; +} /** - * 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. + * Owns the viewport setup the mobile toolbar relies on: * - * 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. + * - Locks the document so it never scrolls. The host app is expected to put its + * scrollable content in an element sized to the visual viewport (via the + * `--bn-vv-*` variables below). With a non-scrolling document, content + * scrolling is an element scroll that never moves the visual viewport, so the + * toolbar stays pinned above the keyboard during scroll with no per-frame + * work — and, on iOS, so browser chrome doesn't shift things mid-scroll. + * - Tracks the viewport rectangle + pinch-zoom scale, publishing it as CSS + * custom properties on the root (`--bn-vv-top/left/width/height/scale`) so the + * toolbar (and the app's scroll container) position themselves off the + * viewport without a React re-render, and as the returned object for JS. */ -export const ExperimentalMobileFormattingToolbarController = (props: { - formattingToolbar?: FC; -}) => { - const divRef = useRef(null); - const editor = useBlockNoteEditor< - BlockSchema, - InlineContentSchema, - StyleSchema - >(); - - const show = useExtensionState(FormattingToolbarExtension, { - editor, - }); +function useVisualViewport(): VisualViewportRect { + const [rect, setRect] = useState(readVisualViewport); useEffect(() => { - const el = divRef.current; - if (!el) { - return; - } + const html = document.documentElement; + const body = document.body; + + // Saved only so they can be restored when the controller unmounts. + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; - const setOffset = (px: number) => { - el.style.setProperty( - "--bn-mobile-keyboard-offset", - px > 0 ? `${px}px` : "0px", - ); + const vp = window.visualViewport; + const update = () => { + const next = readVisualViewport(); + setRect(next); + html.style.setProperty("--bn-vv-top", `${next.top}px`); + html.style.setProperty("--bn-vv-left", `${next.left}px`); + html.style.setProperty("--bn-vv-width", `${next.width}px`); + html.style.setProperty("--bn-vv-height", `${next.height}px`); + html.style.setProperty("--bn-vv-scale", `${next.scale}`); }; + update(); - let scrollTimer: ReturnType; + // These fire on keyboard open/close and zoom/pan — never on (element) + // content scroll, since the document itself can't scroll. + vp?.addEventListener("resize", update); + vp?.addEventListener("scroll", update); + window.addEventListener("resize", update); - 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 toolbarHeight = el.getBoundingClientRect().height || 44; - const visibleBottom = vp.offsetTop + vp.height - toolbarHeight; - 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", - }); - } + return () => { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + html.style.removeProperty("--bn-vv-top"); + html.style.removeProperty("--bn-vv-left"); + html.style.removeProperty("--bn-vv-width"); + html.style.removeProperty("--bn-vv-height"); + html.style.removeProperty("--bn-vv-scale"); + vp?.removeEventListener("resize", update); + vp?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); }; + }, []); - // 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); - }; - } - - // Tier 2: Visual Viewport API fallback (Safari iOS, Firefox Android) - const vp = window.visualViewport; - if (!vp) { - return; - } + return rect; +} - let lastKnownKeyboardHeight = 0; +// The tallest layout-equivalent viewport height seen so far — our stand-in for +// "keyboard closed". Module scope so it survives re-renders; it only ever grows, +// so refreshing it from a render pass is safe. +let maxLayoutViewportHeight = 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); - }; +/** + * Whether the on-screen keyboard is open, from a visual-viewport snapshot. We + * compare `height * scale` — the zoom-invariant layout-equivalent height, so + * pinch-zoom (which also shrinks `height`) doesn't count — against the tallest + * value seen, treating a drop of more than 150px as open: comfortably above + * URL-bar show/hide (~60-100px) and below any real keyboard (~250px+). + */ +function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { + const layoutHeight = viewport.height * viewport.scale; + maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); + return maxLayoutViewportHeight - layoutHeight > 150; +} - const onFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if ( - target.isContentEditable || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" - ) { - if (lastKnownKeyboardHeight > 0) { - setOffset(lastKnownKeyboardHeight); - } - } - }; +/** + * Experimental mobile formatting toolbar controller. + * + * Pins the formatting toolbar to the bottom of the visual viewport — just above + * the on-screen keyboard — using the "non-scrolling document" approach (see + * {@link useVisualViewport}): the document is locked so the visual viewport + * never moves during content scroll, and the toolbar positions itself purely + * from the `--bn-vv-*` CSS variables (see `.bn-mobile-formatting-toolbar` in the + * styles). So it stays put during scroll with no per-frame work, and needs no + * re-render to follow the viewport. + * + * The host app must place its scrollable content in an element sized to the + * visual viewport via those same variables (an element scroll, since the + * document itself can no longer scroll). + * + * Shown while the virtual keyboard is open. + */ +export const ExperimentalMobileFormattingToolbarController = (props: { + formattingToolbar?: FC; +}) => { + const editor = useBlockNoteEditor(); + const viewport = useVisualViewport(); - const onFocusOut = () => { - setOffset(0); - }; + // The toolbar is `position: fixed` (pinned to the visual viewport), but a + // scroll container clips fixed descendants on mobile — and `.bn-container` + // (which BlockNoteView renders) is often the scroll container. So we portal + // the toolbar onto ``, out of the editor. That also escapes the editor's + // theme root, so we mirror its theme classes onto our body-level container to + // keep the toolbar's colors. + const [container] = useState(() => document.createElement("div")); - const onSelectionChange = () => scrollSelectionIntoView(); + useEffect(() => { + document.body.appendChild(container); + return () => container.remove(); + }, [container]); - vp.addEventListener("resize", update); - vp.addEventListener("scroll", update); - document.addEventListener("focusin", onFocusIn); - document.addEventListener("focusout", onFocusOut); - document.addEventListener("selectionchange", onSelectionChange); - return () => { - vp.removeEventListener("resize", update); - vp.removeEventListener("scroll", update); - document.removeEventListener("focusin", onFocusIn); - document.removeEventListener("focusout", onFocusOut); - document.removeEventListener("selectionchange", onSelectionChange); - clearTimeout(scrollTimer); - }; - }, []); + // Keep the container's theme in sync with the editor's themed portal element. + // No dependency array: this also runs after BlockNoteView's own theming effect + // (which runs after this child's effects on first mount) and on re-renders. + useEffect(() => { + const themeRoot = editor.portalElement; + container.className = themeRoot.className; + for (const attr of ["data-color-scheme", "data-mantine-color-scheme"]) { + const value = themeRoot.getAttribute(attr); + if (value !== null) { + container.setAttribute(attr, value); + } + } + }); - if (!show && divRef.current) { - return ( -
- ); + if (!isVirtualKeyboardOpen(viewport)) { + return null; } - const Component = props.formattingToolbar || FormattingToolbar; - - return ( -
- -
+ const Component = props.formattingToolbar || MobileFormattingToolbar; + + return createPortal( +
{ + if (!isSafari()) { + e.preventDefault(); + } + }} + > + {/* Inner element owns the horizontal scroll. The outer toolbar must NOT + clip, or upward-opening dropdowns (rendered above it) get cut off. The + dropdowns escape this scroller because their containing block is the + positioned outer toolbar, not this element. */} +
+ +
+
, + container, ); }; diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx index afb382a563..eb22d52e4a 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx @@ -27,12 +27,19 @@ import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; export const getFormattingToolbarItems = ( blockTypeSelectItems?: BlockTypeSelectItem[], + // Which way the toolbar's dropdowns/popovers open. The mobile toolbar passes + // `"up"` so its menus open above the on-screen keyboard rather than behind it. + direction?: "up" | "down", ): JSX.Element[] => [ - , + , , - , - , - , + , + , + , , , , @@ -46,10 +53,10 @@ export const getFormattingToolbarItems = ( , , , - , + , , , - , + , , , ]; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx new file mode 100644 index 0000000000..25ad639abc --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; + +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { getFormattingToolbarItems } from "./FormattingToolbar.js"; +import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; + +/** + * A formatting toolbar tailored for mobile — where it sits just above the + * on-screen keyboard (see `ExperimentalMobileFormattingToolbarController`). + * + * For now it renders the same items as the regular `FormattingToolbar`, but its + * dropdowns/popovers open *upward* (`direction="up"`) so they appear above the + * toolbar instead of opening downward behind the keyboard. Over time this can + * diverge from the desktop toolbar with mobile-specific items/behavior. + */ +export const MobileFormattingToolbar = ( + props: FormattingToolbarProps & { children?: ReactNode }, +) => { + const Components = useComponentsContext()!; + + return ( + + {props.children || + getFormattingToolbarItems(props.blockTypeSelectItems, "up")} + + ); +}; diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 35d8a1ee3c..6b8f89bf3f 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -47,6 +47,7 @@ type ToolbarSelectType = { isDisabled?: boolean; }[]; isDisabled?: boolean; + direction?: "up" | "down"; }; type MenuButtonType = { diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index c83123d24b..89c6583ba8 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -535,19 +535,43 @@ inline styles, it is added to the base z-index. */ gap: 4px; } -/* Mobile formatting toolbar positioning */ +/* Mobile formatting toolbar positioning. Pinned to the bottom of the visual + viewport from the `--bn-vv-*` variables published by + ExperimentalMobileFormattingToolbarController: `translateY(-100%)` puts the + toolbar's bottom edge on the viewport bottom without measuring its height, and + `scale(1 / --bn-vv-scale)` around that anchored corner cancels pinch-zoom so + it keeps its on-screen size. */ .bn-mobile-formatting-toolbar { display: flex; position: fixed; - bottom: var(--bn-mobile-keyboard-offset, 0px); + top: 0; left: 0; right: 0; z-index: calc(var(--bn-ui-base-z-index) + 40); - transition: bottom 0.15s ease-out; + transform: translate( + var(--bn-vv-left, 0px), + calc(var(--bn-vv-top, 0px) + var(--bn-vv-height, 0px)) + ) + translateY(-100%) scale(calc(1 / var(--bn-vv-scale, 1))); + transform-origin: left bottom; + will-change: transform; + padding-bottom: env(safe-area-inset-bottom, 0); + /* No `overflow` here: `direction: up` dropdowns render above the toolbar and + must not be clipped. Note `overflow-x` would force `overflow-y: auto` too, + which is exactly what clips them. Horizontal scroll lives on the inner + `.bn-mobile-formatting-toolbar-scroll` element instead. */ +} + +/* Inner horizontal scroller for the toolbar buttons. Its overflow does not clip + the dropdowns: their containing block is the positioned outer toolbar, so they + escape this element entirely. */ +.bn-mobile-formatting-toolbar-scroll { + display: flex; + flex: 1; + min-width: 0; + overflow-x: auto; touch-action: pan-x; -webkit-overflow-scrolling: touch; - overflow-x: auto; - padding-bottom: env(safe-area-inset-bottom, 0); } /* Emoji Picker styling */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bae514dc94..ecec9308c9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -35,6 +35,7 @@ export * from "./components/FormattingToolbar/DefaultButtons/TableCellMergeButto export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton.js"; export * from "./components/FormattingToolbar/DefaultSelects/BlockTypeSelect.js"; export * from "./components/FormattingToolbar/FormattingToolbar.js"; +export * from "./components/FormattingToolbar/MobileFormattingToolbar.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx index 5ea063892e..baed06608f 100644 --- a/packages/shadcn/src/toolbar/Toolbar.tsx +++ b/packages/shadcn/src/toolbar/Toolbar.tsx @@ -125,7 +125,15 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + // TODO: `direction` (up/down) isn't wired to the Radix Select's side yet; + // destructured here so it doesn't trip `assertEmpty`. + const { + className, + items, + isDisabled, + direction: _direction, + ...rest + } = props; assertEmpty(rest); From d9902bf6ae0bc0e01a2273c04dd9547049922340 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 7 Aug 2026 11:34:51 +0200 Subject: [PATCH 3/4] Properly fixed dropdowns --- .../src/style.css | 6 +- packages/ariakit/src/menu/Menu.tsx | 14 +++- .../ariakit/src/toolbar/ToolbarSelect.tsx | 4 +- packages/mantine/src/blocknoteStyles.css | 5 +- packages/mantine/src/menu/Menu.tsx | 5 +- .../mantine/src/toolbar/ToolbarButton.tsx | 18 ++++- .../mantine/src/toolbar/ToolbarSelect.tsx | 20 +++-- .../DefaultButtons/ColorStyleButton.tsx | 12 +-- .../DefaultButtons/CreateLinkButton.tsx | 10 ++- .../DefaultButtons/FileCaptionButton.tsx | 16 +++- .../DefaultButtons/FileRenameButton.tsx | 16 +++- .../DefaultButtons/FileReplaceButton.tsx | 11 ++- .../DefaultSelects/BlockTypeSelect.tsx | 15 ++-- ...entalMobileFormattingToolbarController.tsx | 78 +++++-------------- ...talMobileFormattingToolbarPortalContext.ts | 17 ++++ .../FormattingToolbar/FormattingToolbar.tsx | 19 ++--- .../MobileFormattingToolbar.tsx | 11 ++- .../react/src/editor/ComponentsContext.tsx | 2 + packages/react/src/index.ts | 1 + packages/shadcn/src/menu/Menu.tsx | 41 +++++++--- packages/shadcn/src/toolbar/Toolbar.tsx | 28 ++++--- 21 files changed, 206 insertions(+), 143 deletions(-) create mode 100644 packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css index f9c4d1f613..678ff5bc68 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css @@ -7,11 +7,15 @@ body { the page's `.scroll-host`, to check nested scrolling works. */ .bn-container { height: 300px; - overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 8px; } +.bn-editor { + height: 100%; + overflow: auto; +} + /* --- App shell (see DemoChrome) --- */ .top-nav { diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index c2a401204a..d2a5b7542b 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -11,13 +11,18 @@ import { import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { createContext, forwardRef, useContext } from "react"; + +const PortalRootContext = createContext( + undefined, +); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { children, onOpenChange, position, + portalRoot, sub: _sub, // unused ...rest } = props; @@ -30,7 +35,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { setOpen={onOpenChange} virtualFocus={true} > - {children} + + {children} + ); }; @@ -48,10 +55,13 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); + const portalRoot = useContext(PortalRootContext); + return ( {children} diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 405794d05b..18a9d33e98 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,8 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, direction, ...rest } = props; + const { className, items, isDisabled, direction, portalRoot, ...rest } = + props; assertEmpty(rest); @@ -44,6 +45,7 @@ export const ToolbarSelect = forwardRef< className={mergeCSSClasses("bn-ak-popover", className || "")} ref={ref} gutter={4} + portalElement={portalRoot ?? undefined} > {items.map((option) => ( (undefined); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - const { children, onOpenChange, position, sub, ...rest } = props; + const { children, onOpenChange, position, portalRoot, sub, ...rest } = props; assertEmpty(rest); @@ -40,7 +40,8 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { return ( ( { - if (isSafari() && !isTouchDevice()) { + onPointerDown={(e) => { + // Prevents focus shift on mo + if (isTouchDevice()) { + e.preventDefault(); + return; + } + + // Needed as Safari doesn't focus button elements on mouse down + // unlike other browsers. + if (isSafari()) { (e.currentTarget as HTMLButtonElement).focus(); } }} @@ -97,6 +103,10 @@ export const ToolbarButton = forwardRef( (e.currentTarget as HTMLButtonElement).focus(); } }} + onPointerDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} onClick={(event) => { // We manually hide the tooltip onclick, because the click event // might open a popover which would then show both the tooltip and the popover diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 51218b5f18..09f03e919c 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -14,7 +14,8 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, direction, ...rest } = props; + const { className, items, isDisabled, direction, portalRoot, ...rest } = + props; assertEmpty(rest); @@ -26,7 +27,8 @@ export const ToolbarSelect = forwardRef< return ( { - if (isSafari() && !isTouchDevice()) { + onPointerDown={(e) => { + // Prevents focus shift on mo + if (isTouchDevice()) { + e.preventDefault(); + return; + } + + // Needed as Safari doesn't focus button elements on mouse down + // unlike other browsers. + if (isSafari()) { (e.currentTarget as HTMLButtonElement).focus(); } }} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index a4259ed4cf..60469e616e 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -4,9 +4,10 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { useCallback } from "react"; +import { useCallback, useContext } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -40,9 +41,12 @@ function checkColorInSchema( ); } -export const ColorStyleButton = (props: { direction?: "up" | "down" }) => { +export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -136,9 +140,7 @@ export const ColorStyleButton = (props: { direction?: "up" | "down" }) => { } return ( - + , @@ -40,11 +41,14 @@ function checkLinkInSchema( ); } -export const CreateLinkButton = (props: { direction?: "up" | "down" }) => { +export const CreateLinkButton = () => { const editor = useBlockNoteEditor(); const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -115,7 +119,7 @@ export const CreateLinkButton = (props: { direction?: "up" | "down" }) => { {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index c168131b0f..f888a75db8 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -5,17 +5,27 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; +import { + ChangeEvent, + KeyboardEvent, + useCallback, + useContext, + useState, +} from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; +import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; -export const FileCaptionButton = (props: { direction?: "up" | "down" }) => { +export const FileCaptionButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const editor = useBlockNoteEditor< BlockSchema, @@ -88,7 +98,7 @@ export const FileCaptionButton = (props: { direction?: "up" | "down" }) => { { +export const FileRenameButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const editor = useBlockNoteEditor< BlockSchema, @@ -88,7 +98,7 @@ export const FileRenameButton = (props: { direction?: "up" | "down" }) => { { +export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const editor = useBlockNoteEditor< BlockSchema, @@ -56,9 +61,7 @@ export const FileReplaceButton = (props: { direction?: "up" | "down" }) => { } return ( - + { +export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; + // Set inside the mobile formatting toolbar, so the dropdown portals out of the + // toolbar's scroll container instead of being clipped by it. + const portalRoot = useContext( + ExperimentalMobileFormattingToolbarPortalContext, + ); const editor = useBlockNoteEditor< BlockSchema, @@ -215,7 +218,7 @@ export const BlockTypeSelect = (props: { ); }; diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx index 17725a0f65..1c7e81f994 100644 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx @@ -1,10 +1,8 @@ -import { isSafari } from "@blocknote/core"; import { FC, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { ExperimentalMobileFormattingToolbarPortalContext } from "./ExperimentalMobileFormattingToolbarPortalContext.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; -import { MobileFormattingToolbar } from "./MobileFormattingToolbar.js"; +import { FormattingToolbar } from "./FormattingToolbar.js"; type VisualViewportRect = { top: number; @@ -120,72 +118,38 @@ function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { * visual viewport via those same variables (an element scroll, since the * document itself can no longer scroll). * + * The toolbar itself scrolls horizontally (`overflow-x: auto`), which clips any + * inline dropdown on mobile. So the outer `.bn-mobile-formatting-toolbar` + * wrapper — outside that scroll container — is published via + * {@link ExperimentalMobileFormattingToolbarPortalContext}, and buttons portal + * their menus/popovers into it (see e.g. `ColorStyleButton`). + * * Shown while the virtual keyboard is open. */ export const ExperimentalMobileFormattingToolbarController = (props: { formattingToolbar?: FC; }) => { - const editor = useBlockNoteEditor(); const viewport = useVisualViewport(); - - // The toolbar is `position: fixed` (pinned to the visual viewport), but a - // scroll container clips fixed descendants on mobile — and `.bn-container` - // (which BlockNoteView renders) is often the scroll container. So we portal - // the toolbar onto ``, out of the editor. That also escapes the editor's - // theme root, so we mirror its theme classes onto our body-level container to - // keep the toolbar's colors. - const [container] = useState(() => document.createElement("div")); - - useEffect(() => { - document.body.appendChild(container); - return () => container.remove(); - }, [container]); - - // Keep the container's theme in sync with the editor's themed portal element. - // No dependency array: this also runs after BlockNoteView's own theming effect - // (which runs after this child's effects on first mount) and on re-renders. - useEffect(() => { - const themeRoot = editor.portalElement; - container.className = themeRoot.className; - for (const attr of ["data-color-scheme", "data-mantine-color-scheme"]) { - const value = themeRoot.getAttribute(attr); - if (value !== null) { - container.setAttribute(attr, value); - } - } - }); + // The non-scrolling wrapper, published so buttons can portal their dropdowns + // out of the horizontally scrolling toolbar. A callback ref into state so the + // context updates once the element mounts. + const [toolbarElement, setToolbarElement] = useState( + null, + ); if (!isVirtualKeyboardOpen(viewport)) { return null; } - const Component = props.formattingToolbar || MobileFormattingToolbar; - - return createPortal( -
{ - if (!isSafari()) { - e.preventDefault(); - } - }} + const Component = props.formattingToolbar || FormattingToolbar; + + return ( + - {/* Inner element owns the horizontal scroll. The outer toolbar must NOT - clip, or upward-opening dropdowns (rendered above it) get cut off. The - dropdowns escape this scroller because their containing block is the - positioned outer toolbar, not this element. */} -
+
-
, - container, +
); }; diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts new file mode 100644 index 0000000000..773040181d --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts @@ -0,0 +1,17 @@ +import { createContext } from "react"; + +/** + * Holds the mobile formatting toolbar controller's root element — the + * non-scrolling wrapper that sits *outside* the toolbar's horizontally + * scrolling container. + * + * Formatting toolbar buttons read this and portal their menus/popovers into it, + * so the dropdowns escape the toolbar's `overflow-x: auto` clip (which mobile + * WebKit/Blink apply to DOM descendants regardless of their containing block) + * while staying inside BlockNote's themed DOM subtree. + * + * `null` when not inside the mobile toolbar (e.g. the desktop toolbar), in which + * case buttons render their dropdowns inline as usual. + */ +export const ExperimentalMobileFormattingToolbarPortalContext = + createContext(null); diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx index eb22d52e4a..afb382a563 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.tsx @@ -27,19 +27,12 @@ import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; export const getFormattingToolbarItems = ( blockTypeSelectItems?: BlockTypeSelectItem[], - // Which way the toolbar's dropdowns/popovers open. The mobile toolbar passes - // `"up"` so its menus open above the on-screen keyboard rather than behind it. - direction?: "up" | "down", ): JSX.Element[] => [ - , + , , - , - , - , + , + , + , , , , @@ -53,10 +46,10 @@ export const getFormattingToolbarItems = ( , , , - , + , , , - , + , , , ]; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx index 25ad639abc..d483408b91 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx @@ -8,10 +8,10 @@ import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; * A formatting toolbar tailored for mobile — where it sits just above the * on-screen keyboard (see `ExperimentalMobileFormattingToolbarController`). * - * For now it renders the same items as the regular `FormattingToolbar`, but its - * dropdowns/popovers open *upward* (`direction="up"`) so they appear above the - * toolbar instead of opening downward behind the keyboard. Over time this can - * diverge from the desktop toolbar with mobile-specific items/behavior. + * For now it renders the same items as the regular `FormattingToolbar` — their + * dropdowns/popovers open above the keyboard automatically via floating-ui's + * `flip` middleware. Over time this can diverge from the desktop toolbar with + * mobile-specific items/behavior. */ export const MobileFormattingToolbar = ( props: FormattingToolbarProps & { children?: ReactNode }, @@ -22,8 +22,7 @@ export const MobileFormattingToolbar = ( - {props.children || - getFormattingToolbarItems(props.blockTypeSelectItems, "up")} + {props.children || getFormattingToolbarItems(props.blockTypeSelectItems)} ); }; diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 6b8f89bf3f..8da3ef20e4 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -48,6 +48,7 @@ type ToolbarSelectType = { }[]; isDisabled?: boolean; direction?: "up" | "down"; + portalRoot?: HTMLElement | null; }; type MenuButtonType = { @@ -334,6 +335,7 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; + portalRoot?: HTMLElement | null; children?: ReactNode; }; Divider: { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ecec9308c9..970d948e23 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -38,6 +38,7 @@ export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbar.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js"; +export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; export * from "./components/LinkToolbar/DefaultButtons/DeleteLinkButton.js"; diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 44ed6b99ea..c0ea588b81 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,12 +1,17 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; -import { forwardRef, useMemo } from "react"; +import { createContext, forwardRef, useContext, useMemo } from "react"; +import { createPortal } from "react-dom"; import type { DropdownMenuTrigger as ShadCNDropdownMenuTrigger } from "../components/ui/dropdown-menu.js"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; +const PortalRootContext = createContext( + undefined, +); + // hacky HoC to change DropdownMenuTrigger to open a menu on PointerUp instead of PointerDown // Needed to fix this issue: https://github.com/radix-ui/primitives/issues/2867 const MenuTriggerWithPointerUp = (Comp: typeof ShadCNDropdownMenuTrigger) => @@ -39,6 +44,7 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { children, onOpenChange, position: _position, // Unused + portalRoot, sub, ...rest } = props; @@ -52,7 +58,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - {children} + + {children} + ); } else { @@ -61,7 +69,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - {children} + + {children} + ); } @@ -108,6 +118,7 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; + const portalRoot = useContext(PortalRootContext); if (sub) { return ( @@ -118,16 +129,22 @@ export const MenuDropdown = forwardRef< {children} ); - } else { - return ( - - {children} - - ); } + + const content = ( + + {children} + + ); + + if (portalRoot) { + return createPortal(content, portalRoot); + } + + return content; }); export const MenuItem = forwardRef< diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx index baed06608f..0868096078 100644 --- a/packages/shadcn/src/toolbar/Toolbar.tsx +++ b/packages/shadcn/src/toolbar/Toolbar.tsx @@ -1,6 +1,7 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { forwardRef } from "react"; +import { createPortal } from "react-dom"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; @@ -132,6 +133,7 @@ export const ToolbarSelect = forwardRef< items, isDisabled, direction: _direction, + portalRoot, ...rest } = props; @@ -153,6 +155,20 @@ export const ToolbarSelect = forwardRef< return null; } + const content = ( + + {items.map((item) => ( + + + + ))} + + ); + return ( - - {items.map((item) => ( - - - - ))} - + {portalRoot ? createPortal(content, portalRoot) : content} ); }); From af1264680499324a00eb595bddf0e1dabad35dd9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 7 Aug 2026 13:22:08 +0200 Subject: [PATCH 4/4] - Fixed link button popover close dismissing virtual keyboard - Made mobile toolbar no longer experimental & part of default UI - Updated example --- .../README.md | 10 -- .../.bnexample.json | 0 .../14-mobile-formatting-toolbar/README.md | 8 + .../index.html | 2 +- .../main.tsx | 0 .../package.json | 2 +- .../src/App.tsx | 20 ++- .../src/DummyUI.tsx | 0 .../src/style.css | 8 +- .../src/vite-env.d.ts | 0 .../tsconfig.json | 0 .../vite-env.d.ts | 0 .../vite.config.ts | 0 .../DefaultButtons/ColorStyleButton.tsx | 6 +- .../DefaultButtons/CreateLinkButton.tsx | 23 ++- .../DefaultButtons/FileCaptionButton.tsx | 6 +- .../DefaultButtons/FileRenameButton.tsx | 6 +- .../DefaultButtons/FileReplaceButton.tsx | 6 +- .../DefaultSelects/BlockTypeSelect.tsx | 6 +- ...entalMobileFormattingToolbarController.tsx | 155 ------------------ .../MobileFormattingToolbar.tsx | 2 +- .../MobileFormattingToolbarController.tsx | 58 +++++++ ...> MobileFormattingToolbarPortalContext.ts} | 2 +- .../FormattingToolbar/useVisualViewport.ts | 125 ++++++++++++++ .../react/src/editor/BlockNoteDefaultUI.tsx | 10 +- packages/react/src/editor/styles.css | 4 +- packages/react/src/hooks/useIsMobile.ts | 21 +++ packages/react/src/index.ts | 6 +- playground/src/examples.gen.tsx | 11 +- pnpm-lock.yaml | 7 +- 30 files changed, 280 insertions(+), 224 deletions(-) delete mode 100644 examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/.bnexample.json (100%) create mode 100644 examples/03-ui-components/14-mobile-formatting-toolbar/README.md rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/index.html (85%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/main.tsx (100%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/package.json (89%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/src/App.tsx (61%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/src/DummyUI.tsx (100%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/src/style.css (88%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/src/vite-env.d.ts (100%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/tsconfig.json (100%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/vite-env.d.ts (100%) rename examples/03-ui-components/{14-experimental-mobile-formatting-toolbar => 14-mobile-formatting-toolbar}/vite.config.ts (100%) delete mode 100644 packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx create mode 100644 packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx rename packages/react/src/components/FormattingToolbar/{ExperimentalMobileFormattingToolbarPortalContext.ts => MobileFormattingToolbarPortalContext.ts} (91%) create mode 100644 packages/react/src/components/FormattingToolbar/useVisualViewport.ts create mode 100644 packages/react/src/hooks/useIsMobile.ts diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md deleted file mode 100644 index 02eaf7673f..0000000000 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Experimental Mobile Formatting Toolbar - -This example shows how to use the experimental mobile formatting toolbar, which uses [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) to position the toolbar right above the virtual keyboard on mobile devices. - -Controller is currently marked **experimental** due to the flickering issue with positioning (caused by delays of the Visual Viewport API) - -**Relevant Docs:** - -- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar) -- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json b/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json rename to examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md new file mode 100644 index 0000000000..1e4dcd2e3e --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md @@ -0,0 +1,8 @@ +# Mobile Formatting Toolbar + +On mobile, BlockNote's default UI automatically shows a formatting toolbar pinned above the virtual keyboard - no setup needed. This example demos the opt-in `useVisualViewport` hook, which locks document scroll so the toolbar stays smoothly pinned while scrolling. + +**Relevant Docs:** + +- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html b/examples/03-ui-components/14-mobile-formatting-toolbar/index.html similarity index 85% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html rename to examples/03-ui-components/14-mobile-formatting-toolbar/index.html index 69b3583594..edd82eaea0 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/index.html @@ -2,7 +2,7 @@ - Experimental Mobile Formatting Toolbar + Mobile Formatting Toolbar diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/main.tsx similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx rename to examples/03-ui-components/14-mobile-formatting-toolbar/main.tsx diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json b/examples/03-ui-components/14-mobile-formatting-toolbar/package.json similarity index 89% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json rename to examples/03-ui-components/14-mobile-formatting-toolbar/package.json index 688153f794..96dcc9866c 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/package.json @@ -1,5 +1,5 @@ { - "name": "@blocknote/example-ui-components-experimental-mobile-formatting-toolbar", + "name": "@blocknote/example-ui-components-mobile-formatting-toolbar", "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", "type": "module", "private": true, diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx similarity index 61% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx rename to examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx index b5acb31f21..b6b7fcbee5 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx @@ -1,8 +1,5 @@ import "@blocknote/core/fonts/inter.css"; -import { - ExperimentalMobileFormattingToolbarController, - useCreateBlockNote, -} from "@blocknote/react"; +import { useCreateBlockNote, useVisualViewport } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; @@ -29,16 +26,21 @@ const initialContent = [ export default function App() { const editor = useCreateBlockNote({ initialContent }); + // Opt into the "non-scrolling document" behavior: locks document scroll so the + // toolbar stays smoothly pinned above the keyboard during scroll, and publishes + // the `--bn-vv-*` variables this `.scroll-host` is sized against. + useVisualViewport(); + return ( - // The document itself doesn't scroll — the controller locks it and pins this - // `.scroll-host` to the visual viewport via the `--bn-vv-*` variables. + // The document itself doesn't scroll — `useVisualViewport` locks it and pins + // this `.scroll-host` to the visual viewport via the `--bn-vv-*` variables.
- - - + {/* On mobile, the default UI automatically shows the mobile formatting + toolbar above the keyboard - no extra setup needed. */} +
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/DummyUI.tsx rename to examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css similarity index 88% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css rename to examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css index 678ff5bc68..6e02b39a94 100644 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css @@ -104,10 +104,10 @@ body { } /* A top-level wrapper div is the scroll container (the document itself doesn't - scroll — the controller locks it), pinned to the visual viewport rectangle via - the `--bn-vv-*` variables the controller publishes, so it sits directly above - the keyboard on iOS — where the layout viewport doesn't resize and can be left - with a nonzero `offsetTop`. */ + scroll — `useVisualViewport` locks it), pinned to the visual viewport + rectangle via the `--bn-vv-*` variables it publishes, so it sits directly + above the keyboard on iOS — where the layout viewport doesn't resize and can + be left with a nonzero `offsetTop`. */ .scroll-host { position: fixed; top: var(--bn-vv-top, 0px); diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/vite-env.d.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/src/vite-env.d.ts similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/vite-env.d.ts rename to examples/03-ui-components/14-mobile-formatting-toolbar/src/vite-env.d.ts diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json b/examples/03-ui-components/14-mobile-formatting-toolbar/tsconfig.json similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json rename to examples/03-ui-components/14-mobile-formatting-toolbar/tsconfig.json diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite-env.d.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite-env.d.ts similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite-env.d.ts rename to examples/03-ui-components/14-mobile-formatting-toolbar/vite-env.d.ts diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts similarity index 100% rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts rename to examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index 60469e616e..686d8787c1 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -7,7 +7,7 @@ import { import { useCallback, useContext } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; +import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -44,9 +44,7 @@ function checkColorInSchema( export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 5243422a6d..7136d04813 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { RiLink } from "react-icons/ri"; import { @@ -20,7 +20,7 @@ import { useEditorState } from "../../../hooks/useEditorState.js"; import { useExtension } from "../../../hooks/useExtension.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { EditLinkMenuItems } from "../../LinkToolbar/EditLinkMenuItems.js"; -import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; +import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; function checkLinkInSchema( editor: BlockNoteEditor, @@ -46,9 +46,7 @@ export const CreateLinkButton = () => { const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -60,6 +58,17 @@ export const CreateLinkButton = () => { return () => showSelection(false, "createLinkButton"); }, [showPopover, showSelection]); + // Return focus to editor on close. + const setPopoverOpen = useCallback( + (open: boolean) => { + if (!open) { + editor.focus(); + } + setShowPopover(open); + }, + [editor], + ); + const state = useEditorState({ editor, selector: ({ editor }) => { @@ -118,7 +127,7 @@ export const CreateLinkButton = () => { return ( @@ -133,7 +142,7 @@ export const CreateLinkButton = () => { dict.generic.ctrl_shortcut, )} icon={} - onClick={() => setShowPopover((open) => !open)} + onClick={() => setPopoverOpen(!showPopover)} /> { const dict = useDictionary(); const Components = useComponentsContext()!; - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 46eb4c0dac..9e425a0644 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -18,14 +18,12 @@ import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; -import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; +import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; export const FileRenameButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 845a001799..721c4a185a 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -12,14 +12,12 @@ import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { FilePanel } from "../../FilePanel/FilePanel.js"; -import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; +import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 71325b411a..633056ba47 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -28,7 +28,7 @@ import { } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; -import { ExperimentalMobileFormattingToolbarPortalContext } from "../ExperimentalMobileFormattingToolbarPortalContext.js"; +import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; export type BlockTypeSelectItem = { name: string; @@ -130,9 +130,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; // Set inside the mobile formatting toolbar, so the dropdown portals out of the // toolbar's scroll container instead of being clipped by it. - const portalRoot = useContext( - ExperimentalMobileFormattingToolbarPortalContext, - ); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx deleted file mode 100644 index 1c7e81f994..0000000000 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { FC, useEffect, useState } from "react"; - -import { ExperimentalMobileFormattingToolbarPortalContext } from "./ExperimentalMobileFormattingToolbarPortalContext.js"; -import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; -import { FormattingToolbar } from "./FormattingToolbar.js"; - -type VisualViewportRect = { - top: number; - left: number; - width: number; - height: number; - scale: number; -}; - -function readVisualViewport(): VisualViewportRect { - const vp = window.visualViewport; - return { - top: vp?.offsetTop ?? 0, - left: vp?.offsetLeft ?? 0, - width: vp?.width ?? window.innerWidth, - height: vp?.height ?? window.innerHeight, - scale: vp?.scale ?? 1, - }; -} - -/** - * Owns the viewport setup the mobile toolbar relies on: - * - * - Locks the document so it never scrolls. The host app is expected to put its - * scrollable content in an element sized to the visual viewport (via the - * `--bn-vv-*` variables below). With a non-scrolling document, content - * scrolling is an element scroll that never moves the visual viewport, so the - * toolbar stays pinned above the keyboard during scroll with no per-frame - * work — and, on iOS, so browser chrome doesn't shift things mid-scroll. - * - Tracks the viewport rectangle + pinch-zoom scale, publishing it as CSS - * custom properties on the root (`--bn-vv-top/left/width/height/scale`) so the - * toolbar (and the app's scroll container) position themselves off the - * viewport without a React re-render, and as the returned object for JS. - */ -function useVisualViewport(): VisualViewportRect { - const [rect, setRect] = useState(readVisualViewport); - - useEffect(() => { - const html = document.documentElement; - const body = document.body; - - // Saved only so they can be restored when the controller unmounts. - const prevHtmlOverflow = html.style.overflow; - const prevBodyOverflow = body.style.overflow; - html.style.overflow = "hidden"; - body.style.overflow = "hidden"; - - const vp = window.visualViewport; - const update = () => { - const next = readVisualViewport(); - setRect(next); - html.style.setProperty("--bn-vv-top", `${next.top}px`); - html.style.setProperty("--bn-vv-left", `${next.left}px`); - html.style.setProperty("--bn-vv-width", `${next.width}px`); - html.style.setProperty("--bn-vv-height", `${next.height}px`); - html.style.setProperty("--bn-vv-scale", `${next.scale}`); - }; - update(); - - // These fire on keyboard open/close and zoom/pan — never on (element) - // content scroll, since the document itself can't scroll. - vp?.addEventListener("resize", update); - vp?.addEventListener("scroll", update); - window.addEventListener("resize", update); - - return () => { - html.style.overflow = prevHtmlOverflow; - body.style.overflow = prevBodyOverflow; - html.style.removeProperty("--bn-vv-top"); - html.style.removeProperty("--bn-vv-left"); - html.style.removeProperty("--bn-vv-width"); - html.style.removeProperty("--bn-vv-height"); - html.style.removeProperty("--bn-vv-scale"); - vp?.removeEventListener("resize", update); - vp?.removeEventListener("scroll", update); - window.removeEventListener("resize", update); - }; - }, []); - - return rect; -} - -// The tallest layout-equivalent viewport height seen so far — our stand-in for -// "keyboard closed". Module scope so it survives re-renders; it only ever grows, -// so refreshing it from a render pass is safe. -let maxLayoutViewportHeight = 0; - -/** - * Whether the on-screen keyboard is open, from a visual-viewport snapshot. We - * compare `height * scale` — the zoom-invariant layout-equivalent height, so - * pinch-zoom (which also shrinks `height`) doesn't count — against the tallest - * value seen, treating a drop of more than 150px as open: comfortably above - * URL-bar show/hide (~60-100px) and below any real keyboard (~250px+). - */ -function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { - const layoutHeight = viewport.height * viewport.scale; - maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); - return maxLayoutViewportHeight - layoutHeight > 150; -} - -/** - * Experimental mobile formatting toolbar controller. - * - * Pins the formatting toolbar to the bottom of the visual viewport — just above - * the on-screen keyboard — using the "non-scrolling document" approach (see - * {@link useVisualViewport}): the document is locked so the visual viewport - * never moves during content scroll, and the toolbar positions itself purely - * from the `--bn-vv-*` CSS variables (see `.bn-mobile-formatting-toolbar` in the - * styles). So it stays put during scroll with no per-frame work, and needs no - * re-render to follow the viewport. - * - * The host app must place its scrollable content in an element sized to the - * visual viewport via those same variables (an element scroll, since the - * document itself can no longer scroll). - * - * The toolbar itself scrolls horizontally (`overflow-x: auto`), which clips any - * inline dropdown on mobile. So the outer `.bn-mobile-formatting-toolbar` - * wrapper — outside that scroll container — is published via - * {@link ExperimentalMobileFormattingToolbarPortalContext}, and buttons portal - * their menus/popovers into it (see e.g. `ColorStyleButton`). - * - * Shown while the virtual keyboard is open. - */ -export const ExperimentalMobileFormattingToolbarController = (props: { - formattingToolbar?: FC; -}) => { - const viewport = useVisualViewport(); - // The non-scrolling wrapper, published so buttons can portal their dropdowns - // out of the horizontally scrolling toolbar. A callback ref into state so the - // context updates once the element mounts. - const [toolbarElement, setToolbarElement] = useState( - null, - ); - - if (!isVirtualKeyboardOpen(viewport)) { - return null; - } - - const Component = props.formattingToolbar || FormattingToolbar; - - return ( - -
- -
-
- ); -}; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx index d483408b91..03984294ec 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx @@ -6,7 +6,7 @@ import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; /** * A formatting toolbar tailored for mobile — where it sits just above the - * on-screen keyboard (see `ExperimentalMobileFormattingToolbarController`). + * on-screen keyboard (see `MobileFormattingToolbarController`). * * For now it renders the same items as the regular `FormattingToolbar` — their * dropdowns/popovers open above the keyboard automatically via floating-ui's diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx new file mode 100644 index 0000000000..6e8875470c --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -0,0 +1,58 @@ +import { FC, useState } from "react"; + +import { MobileFormattingToolbarPortalContext } from "./MobileFormattingToolbarPortalContext.js"; +import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; +import { FormattingToolbar } from "./FormattingToolbar.js"; +import { + isVirtualKeyboardOpen, + useVisualViewportRect, +} from "./useVisualViewport.js"; + +/** + * Mobile formatting toolbar controller. + * + * Pins the formatting toolbar to the bottom of the visual viewport — just above + * the on-screen keyboard — positioning itself purely from the `--bn-vv-*` CSS + * variables published by {@link useVisualViewportRect} (see + * `.bn-mobile-formatting-toolbar` in the styles), so it needs no re-render to + * follow the viewport. + * + * By default it does not lock document scroll. For the smoother + * "non-scrolling document" behavior (the toolbar staying pinned during scroll + * with no per-frame work), the host app opts in by calling + * {@link useVisualViewport} and sizing its scroll container to the visual + * viewport via the same `--bn-vv-*` variables. + * + * The toolbar itself scrolls horizontally (`overflow-x: auto`), which clips any + * inline dropdown on mobile. So the outer `.bn-mobile-formatting-toolbar` + * wrapper — outside that scroll container — is published via + * {@link MobileFormattingToolbarPortalContext}, and buttons portal + * their menus/popovers into it (see e.g. `ColorStyleButton`). + * + * Shown while the virtual keyboard is open. + */ +export const MobileFormattingToolbarController = (props: { + formattingToolbar?: FC; +}) => { + const viewport = useVisualViewportRect(); + // The non-scrolling wrapper, published so buttons can portal their dropdowns + // out of the horizontally scrolling toolbar. A callback ref into state so the + // context updates once the element mounts. + const [toolbarElement, setToolbarElement] = useState( + null, + ); + + if (!isVirtualKeyboardOpen(viewport)) { + return null; + } + + const Component = props.formattingToolbar || FormattingToolbar; + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts similarity index 91% rename from packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts rename to packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts index 773040181d..d06966ec7f 100644 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.ts +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts @@ -13,5 +13,5 @@ import { createContext } from "react"; * `null` when not inside the mobile toolbar (e.g. the desktop toolbar), in which * case buttons render their dropdowns inline as usual. */ -export const ExperimentalMobileFormattingToolbarPortalContext = +export const MobileFormattingToolbarPortalContext = createContext(null); diff --git a/packages/react/src/components/FormattingToolbar/useVisualViewport.ts b/packages/react/src/components/FormattingToolbar/useVisualViewport.ts new file mode 100644 index 0000000000..604632658c --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/useVisualViewport.ts @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; + +export type VisualViewportRect = { + top: number; + left: number; + width: number; + height: number; + scale: number; +}; + +function readVisualViewport(): VisualViewportRect { + const vp = window.visualViewport; + return { + top: vp?.offsetTop ?? 0, + left: vp?.offsetLeft ?? 0, + width: vp?.width ?? window.innerWidth, + height: vp?.height ?? window.innerHeight, + scale: vp?.scale ?? 1, + }; +} + +/** + * Tracks the visual viewport rectangle + pinch-zoom scale, publishing it as CSS + * custom properties on the root (`--bn-vv-top/left/width/height/scale`) so the + * mobile toolbar (and the app's scroll container) can position themselves off + * the viewport without a React re-render, and returning it as an object for JS. + * + * Does not lock document scroll — that's the opt-in part, see + * {@link useVisualViewport}. This is what + * {@link MobileFormattingToolbarController} relies on for positioning and + * keyboard detection. + */ +export function useVisualViewportRect(): VisualViewportRect { + const [rect, setRect] = useState(readVisualViewport); + + useEffect(() => { + const html = document.documentElement; + + const vp = window.visualViewport; + const update = () => { + const next = readVisualViewport(); + setRect(next); + html.style.setProperty("--bn-vv-top", `${next.top}px`); + html.style.setProperty("--bn-vv-left", `${next.left}px`); + html.style.setProperty("--bn-vv-width", `${next.width}px`); + html.style.setProperty("--bn-vv-height", `${next.height}px`); + html.style.setProperty("--bn-vv-scale", `${next.scale}`); + }; + update(); + + // Fire on keyboard open/close, zoom/pan, and (unless the document is locked + // via `useVisualViewport`) content scroll. + vp?.addEventListener("resize", update); + vp?.addEventListener("scroll", update); + window.addEventListener("resize", update); + + return () => { + html.style.removeProperty("--bn-vv-top"); + html.style.removeProperty("--bn-vv-left"); + html.style.removeProperty("--bn-vv-width"); + html.style.removeProperty("--bn-vv-height"); + html.style.removeProperty("--bn-vv-scale"); + vp?.removeEventListener("resize", update); + vp?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, []); + + return rect; +} + +// The tallest layout-equivalent viewport height seen so far — our stand-in for +// "keyboard closed". Module scope so it survives re-renders; it only ever grows, +// so refreshing it from a render pass is safe. +let maxLayoutViewportHeight = 0; + +/** + * Whether the on-screen keyboard is open, from a visual-viewport snapshot. We + * compare `height * scale` — the zoom-invariant layout-equivalent height, so + * pinch-zoom (which also shrinks `height`) doesn't count — against the tallest + * value seen, treating a drop of more than 150px as open: comfortably above + * URL-bar show/hide (~60-100px) and below any real keyboard (~250px+). + */ +export function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean { + const layoutHeight = viewport.height * viewport.scale; + maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); + return maxLayoutViewportHeight - layoutHeight > 150; +} + +/** + * Opt-in smooth-scrolling setup for the mobile formatting toolbar. + * + * On top of tracking the visual viewport (see {@link useVisualViewportRect}), it + * locks the document so it never scrolls: with a non-scrolling document, content + * scroll becomes an element scroll that never moves the visual viewport, so the + * toolbar stays pinned above the keyboard during scroll with no per-frame work + * (and, on iOS, browser chrome doesn't shift things mid-scroll). + * + * The cost is that the document itself can no longer scroll — the host app must + * put its scrollable content in an element sized to the visual viewport (via the + * same `--bn-vv-*` variables this publishes). Call this from your app only if + * you want that behavior; `MobileFormattingToolbarController` works without it, + * just without the non-scrolling-document smoothness. + */ +export function useVisualViewport(): VisualViewportRect { + useEffect(() => { + const html = document.documentElement; + const body = document.body; + + // Saved only so they can be restored when the hook unmounts. + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + + return () => { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + }; + }, []); + + // Publishing the CSS vars here too is idempotent with the controller's own + // tracking (same values written to the same properties). + return useVisualViewportRect(); +} diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 75d618dc71..d5a668ace4 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -11,6 +11,7 @@ import { lazy, Suspense } from "react"; import { FilePanelController } from "../components/FilePanel/FilePanelController.js"; import { FormattingToolbarController } from "../components/FormattingToolbar/FormattingToolbarController.js"; +import { MobileFormattingToolbarController } from "../components/FormattingToolbar/MobileFormattingToolbarController.js"; import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController.js"; import { SideMenuController } from "../components/SideMenu/SideMenuController.js"; import { AttributionTooltipController } from "../components/AttributionTooltip/AttributionTooltipController.js"; @@ -18,6 +19,7 @@ import { GridSuggestionMenuController } from "../components/SuggestionMenu/GridS import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController.js"; import { TableHandlesController } from "../components/TableHandles/TableHandlesController.js"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; import { PortalElementsMap, resolvePortalTarget } from "./portalElements.js"; // Lazily load the comments components to avoid pulling in the comments extensions into the main bundle @@ -98,6 +100,7 @@ export type BlockNoteDefaultUIProps = { export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { const editor = useBlockNoteEditor(); + const isMobile = useIsMobile(); if (!editor) { throw new Error( @@ -119,11 +122,14 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { return ( <> {editor.getExtension(FormattingToolbarExtension) && - props.formattingToolbar !== false && ( + props.formattingToolbar !== false && + (isMobile ? ( + + ) : ( - )} + ))} {editor.getExtension(LinkToolbarExtension) && props.linkToolbar !== false && ( diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 89c6583ba8..3058b3f890 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -536,8 +536,8 @@ inline styles, it is added to the base z-index. */ } /* Mobile formatting toolbar positioning. Pinned to the bottom of the visual - viewport from the `--bn-vv-*` variables published by - ExperimentalMobileFormattingToolbarController: `translateY(-100%)` puts the + viewport from the `--bn-vv-*` variables published by `useVisualViewportRect` + (used by MobileFormattingToolbarController): `translateY(-100%)` puts the toolbar's bottom edge on the viewport bottom without measuring its height, and `scale(1 / --bn-vv-scale)` around that anchored corner cancels pinch-zoom so it keeps its on-screen size. */ diff --git a/packages/react/src/hooks/useIsMobile.ts b/packages/react/src/hooks/useIsMobile.ts new file mode 100644 index 0000000000..a38fcae5a0 --- /dev/null +++ b/packages/react/src/hooks/useIsMobile.ts @@ -0,0 +1,21 @@ +import { isTouchDevice } from "@blocknote/core"; +import { useEffect, useState } from "react"; + +/** + * Whether the editor is being used on a mobile (touch) device. Used to decide + * between the desktop and mobile formatting toolbar controllers. + * + * The check runs after mount rather than during render so it's SSR-safe: the + * server (and the first client render) assume desktop, then switch to mobile on + * the client if it's a touch device - avoiding a hydration mismatch. Touch + * capability doesn't change during a session, so a one-off check is enough. + */ +export const useIsMobile = () => { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + setIsMobile(isTouchDevice()); + }, []); + + return isMobile; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 970d948e23..db5986314a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -37,8 +37,9 @@ export * from "./components/FormattingToolbar/DefaultSelects/BlockTypeSelect.js" export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbar.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; -export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js"; -export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarPortalContext.js"; +export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; +export * from "./components/FormattingToolbar/MobileFormattingToolbarPortalContext.js"; +export * from "./components/FormattingToolbar/useVisualViewport.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; export * from "./components/LinkToolbar/DefaultButtons/DeleteLinkButton.js"; @@ -130,6 +131,7 @@ export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; export * from "./hooks/useFocusWithin.js"; +export * from "./hooks/useIsMobile.js"; export * from "./hooks/useOnUploadEnd.js"; export * from "./hooks/useOnUploadStart.js"; export * from "./hooks/usePrefersColorScheme.js"; diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 54431d30e2..47a8064a23 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -734,10 +734,9 @@ export const examples = { "In this example, we implement a basic editor interface using components from Material UI. We replace the Formatting Toolbar, Slash Menu, and Block Side Menu while disabling the other default elements. Additionally, the Formatting Toolbar is made static and always visible above the editor.\n\n**Relevant Docs:**\n\n- [Formatting Toolbar](/docs/react/components/formatting-toolbar)\n- [Manipulating Inline Content](/docs/reference/editor/manipulating-content)\n- [Slash Menu](/docs/react/components/suggestion-menus)\n- [Side Menu](/docs/react/components/side-menu)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, { - projectSlug: "experimental-mobile-formatting-toolbar", - fullSlug: "ui-components/experimental-mobile-formatting-toolbar", - pathFromRoot: - "examples/03-ui-components/14-experimental-mobile-formatting-toolbar", + projectSlug: "mobile-formatting-toolbar", + fullSlug: "ui-components/mobile-formatting-toolbar", + pathFromRoot: "examples/03-ui-components/14-mobile-formatting-toolbar", config: { playground: true, docs: true, @@ -749,13 +748,13 @@ export const examples = { "Appearance & Styling", ], }, - title: "Experimental Mobile Formatting Toolbar", + title: "Mobile Formatting Toolbar", group: { pathFromRoot: "examples/03-ui-components", slug: "ui-components", }, readme: - "This example shows how to use the experimental mobile formatting toolbar, which uses [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) to position the toolbar right above the virtual keyboard on mobile devices.\n\nController is currently marked **experimental** due to the flickering issue with positioning (caused by delays of the Visual Viewport API)\n\n**Relevant Docs:**\n\n- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar)\n- [Editor Setup](/docs/getting-started/editor-setup)", + "This example shows how to use the mobile formatting toolbar, which uses the [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) to position the toolbar right above the virtual keyboard on mobile devices.\n\n**Relevant Docs:**\n\n- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, { projectSlug: "advanced-tables", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 090c217f93..cc7450d36b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1957,7 +1957,7 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) - examples/03-ui-components/14-experimental-mobile-formatting-toolbar: + examples/03-ui-components/14-mobile-formatting-toolbar: dependencies: '@blocknote/ariakit': specifier: latest @@ -11553,6 +11553,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} @@ -27212,7 +27213,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 + tinyexec: 1.2.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.8(@types/node@20.19.37)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) @@ -27242,7 +27243,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 + tinyexec: 1.2.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)