Experimental Mobile Formatting Toolbar
This example shows how to use the experimental mobile formatting toolbar, which uses 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:
import "@blocknote/core/fonts/inter.css";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( <div className="viewport-bar">Virtual Keyboard Toolbar</div>, document.body, );}export default function App() { const editor = useCreateBlockNote({ initialContent }); const viewport = useVisualViewport(); return ( <div className="scroll-host"> <NavBar /> <main className="app-main"> <StaticText /> <BlockNoteView editor={editor} formattingToolbar={false}> {isVirtualKeyboardOpen(viewport) && <VirtualKeyboardToolbar />} </BlockNoteView> <StaticText /> </main> </div> );}import { useState } from "react";function HamburgerMenu() { const [open, setOpen] = useState(false); return ( <div className="hamburger"> <button type="button" className="hamburger-button" aria-label="Menu" aria-expanded={open} onClick={() => setOpen((v) => !v)} > <span /> <span /> <span /> </button> {open && ( <nav className="hamburger-menu"> <a href="#">Home</a> <a href="#">Documents</a> <a href="#">Shared with me</a> <a href="#">Settings</a> </nav> )} </div> );}export function NavBar() { return ( <header className="top-nav"> <HamburgerMenu /> <span className="top-nav-title">Lorem Ipsum</span> </header> );}/** A block of static page text, to sit around the editor. */export function StaticText() { return ( <section className="prose"> <h2>Lorem Ipsum</h2> <p> 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. </p> <p> 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. </p> <p> 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. </p> <p> 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. </p> </section> );}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; justify-content: space-between; width: 22px; height: 16px; padding: 0; background: none; border: none; cursor: pointer;}.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;}/// <reference types="vite-plus/client" />