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/src/App.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx deleted file mode 100644 index 47d59e453c..0000000000 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import "@blocknote/core/fonts/inter.css"; -import { - ExperimentalMobileFormattingToolbarController, - useCreateBlockNote, -} from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; - -import "./style.css"; - -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. - 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. - - - - ); -} 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 deleted file mode 100644 index 98e93611cd..0000000000 --- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css +++ /dev/null @@ -1,9 +0,0 @@ -.bn-container { - display: flex; - flex-direction: column-reverse; - gap: 8px; -} - -.bn-formatting-toolbar { - margin-inline: auto; -} 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-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx new file mode 100644 index 0000000000..b6b7fcbee5 --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx @@ -0,0 +1,48 @@ +import "@blocknote/core/fonts/inter.css"; +import { useCreateBlockNote, useVisualViewport } from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import "./style.css"; +import { StaticText, NavBar } from "./DummyUI"; + +// 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 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 toolbar sit above it.", + })), +]; + +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 — `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-mobile-formatting-toolbar/src/DummyUI.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx new file mode 100644 index 0000000000..a778e4f26f --- /dev/null +++ b/examples/03-ui-components/14-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-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css new file mode 100644 index 0000000000..6e02b39a94 --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css @@ -0,0 +1,124 @@ +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; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +.bn-editor { + height: 100%; + overflow: auto; +} + +/* --- 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 — `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); + 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 + 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; +} 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/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 f596cbbae6..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, ...rest } = props; + const { className, items, isDisabled, direction, portalRoot, ...rest } = + props; assertEmpty(rest); @@ -27,7 +28,11 @@ export const ToolbarSelect = forwardRef< }; return ( - + {items.map((option) => ( /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +export const isTouchDevice = () => + typeof navigator !== "undefined" && navigator.maxTouchPoints > 0; diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index 8a3c4da1f1..c54114f2f7 100644 --- a/packages/mantine/src/blocknoteStyles.css +++ b/packages/mantine/src/blocknoteStyles.css @@ -155,10 +155,6 @@ overflow: auto; } -.bn-mantine .mantine-Button-root[aria-controls*="dropdown"] { - min-width: fit-content; -} - /* Toolbar styling */ .bn-mantine .bn-toolbar { background-color: var(--bn-colors-menu-background); @@ -183,6 +179,7 @@ border: none; border-radius: var(--bn-border-radius-small); color: var(--bn-colors-menu-text); + flex-shrink: 0; } .bn-toolbar .mantine-Button-root:hover, diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index c81ed870d7..9d3d84b4ce 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -16,16 +16,20 @@ const SubMenuContext = createContext< >(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); + // 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 ( @@ -36,11 +40,18 @@ 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..e49b48e52d 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"; @@ -57,9 +57,15 @@ export const ToolbarButton = forwardRef( { + 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(); } @@ -93,10 +99,14 @@ 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(); } }} + 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 21cee2a1fd..09f03e919c 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,8 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, direction, portalRoot, ...rest } = + props; assertEmpty(rest); @@ -26,18 +27,41 @@ export const ToolbarSelect = forwardRef< return ( { + 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 d0e98c5c8f..686d8787c1 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 { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -43,6 +44,7 @@ function checkColorInSchema( export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -136,7 +138,7 @@ export const ColorStyleButton = () => { } return ( - + , @@ -45,6 +46,7 @@ export const CreateLinkButton = () => { const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); + 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 @@ -56,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 }) => { @@ -114,7 +127,8 @@ export const CreateLinkButton = () => { return ( {/* TODO: hide tooltip on click */} @@ -128,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(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, @@ -88,6 +96,7 @@ export const FileCaptionButton = () => { { const dict = useDictionary(); const Components = useComponentsContext()!; + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, @@ -88,6 +96,7 @@ export const FileRenameButton = () => { { const dict = useDictionary(); const Components = useComponentsContext()!; + const portalRoot = useContext(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, @@ -56,7 +59,7 @@ export const FileReplaceButton = () => { } return ( - + { 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(MobileFormattingToolbarPortalContext); const editor = useBlockNoteEditor< BlockSchema, @@ -212,6 +216,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 deleted file mode 100644 index a729bb4433..0000000000 --- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; -import { FormattingToolbarExtension } from "@blocknote/core/extensions"; -import { FC, useRef, useEffect } from "react"; - -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useExtensionState } from "../../hooks/useExtension.js"; -import { FormattingToolbar } from "./FormattingToolbar.js"; -import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; - -/** - * 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. - * - * 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. - */ -export const ExperimentalMobileFormattingToolbarController = (props: { - formattingToolbar?: FC; -}) => { - const divRef = useRef(null); - const editor = useBlockNoteEditor< - BlockSchema, - InlineContentSchema, - StyleSchema - >(); - - const show = useExtensionState(FormattingToolbarExtension, { - editor, - }); - - useEffect(() => { - const el = divRef.current; - if (!el) { - return; - } - - const setOffset = (px: number) => { - el.style.setProperty( - "--bn-mobile-keyboard-offset", - px > 0 ? `${px}px` : "0px", - ); - }; - - let scrollTimer: ReturnType; - - 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", - }); - } - }; - - // 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; - } - - let lastKnownKeyboardHeight = 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); - }; - - const onFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if ( - target.isContentEditable || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" - ) { - if (lastKnownKeyboardHeight > 0) { - setOffset(lastKnownKeyboardHeight); - } - } - }; - - const onFocusOut = () => { - setOffset(0); - }; - - const onSelectionChange = () => scrollSelectionIntoView(); - - 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); - }; - }, []); - - if (!show && divRef.current) { - return ( -
- ); - } - - const Component = props.formattingToolbar || FormattingToolbar; - - return ( -
- -
- ); -}; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx new file mode 100644 index 0000000000..03984294ec --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx @@ -0,0 +1,28 @@ +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 `MobileFormattingToolbarController`). + * + * 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 }, +) => { + const Components = useComponentsContext()!; + + return ( + + {props.children || getFormattingToolbarItems(props.blockTypeSelectItems)} + + ); +}; 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/MobileFormattingToolbarPortalContext.ts b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts new file mode 100644 index 0000000000..d06966ec7f --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.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 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/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 35d8a1ee3c..8da3ef20e4 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -47,6 +47,8 @@ type ToolbarSelectType = { isDisabled?: boolean; }[]; isDisabled?: boolean; + direction?: "up" | "down"; + portalRoot?: HTMLElement | null; }; type MenuButtonType = { @@ -333,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/editor/styles.css b/packages/react/src/editor/styles.css index c83123d24b..3058b3f890 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 `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. */ .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/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 bae514dc94..db5986314a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -35,8 +35,11 @@ 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/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"; @@ -128,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/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 5ea063892e..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"; @@ -125,7 +126,16 @@ 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, + portalRoot, + ...rest + } = props; assertEmpty(rest); @@ -145,6 +155,20 @@ export const ToolbarSelect = forwardRef< return null; } + const content = ( + + {items.map((item) => ( + + + + ))} + + ); + return ( - - {items.map((item) => ( - - - - ))} - + {portalRoot ? createPortal(content, portalRoot) : content} ); }); 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)