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; }