diff --git a/docs/content/docs/react/components/formatting-toolbar.mdx b/docs/content/docs/react/components/formatting-toolbar.mdx index 962035ba57..8c9bc0a04d 100644 --- a/docs/content/docs/react/components/formatting-toolbar.mdx +++ b/docs/content/docs/react/components/formatting-toolbar.mdx @@ -38,3 +38,40 @@ The first element in the default Formatting Toolbar is the Block Type Select, an Here, we use the `FormattingToolbar` component but keep the default buttons (we don't pass any children). Instead, we pass our customized Block Type Select items using the `blockTypeSelectItems` prop. + +## Mobile Formatting Toolbar + +On mobile, BlockNote's default UI automatically shows a dedicated formatting toolbar pinned just above the on-screen keyboard - no setup needed. It renders the same items as the regular Formatting Toolbar, but stays anchored to the keyboard so it's always reachable while editing on a touch device. Try it in any of the previous examples to see it in action! + +Due to browser limitations, scrolling the page can cause the mobile Formatting Toolbar to appear laggy or jittery. BlockNote offers a workaround for these limitations, which you can see below. + + + +Here, the lag/jitter is eliminated, at the cost of `` and its ancestors no longer being scrollable. Instead, all scrollable page content must be in a scrollable container that's a descendant of ``. + +To set this up, first lock scrolling on the document itself. This prevents the browser from scrolling ``/``, which is what causes the toolbar to jitter: + +```css +html, +body { + margin: 0; + overflow: hidden; +} +``` + +Then, make your scroll container (`.scroll-host` in the demo) the element that actually scrolls. It's pinned to the visual viewport using the `--bn-vv-*` CSS variables that BlockNote publishes on the root element (`--bn-vv-top`, `--bn-vv-left`, `--bn-vv-width`, and `--bn-vv-height`), so it always lines up with the visible area above the keyboard: + +```css +.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; + overscroll-behavior: contain; +} +``` + +BlockNote keeps the `--bn-vv-*` variables up to date as the keyboard opens/closes and the user zooms or pans, so both the toolbar and your scroll container stay aligned with the visual viewport without any JavaScript on your end. 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..8a68305c00 --- /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, CSS-only "non-scrolling document" setup (locking `html`/`body` scroll and sizing a `.scroll-host` to the visual viewport), which keeps the toolbar 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 c0843c027a..79453826e2 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..04cfba0f9a --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx @@ -0,0 +1,44 @@ +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 { 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 }); + + return ( + // To make the formatting toolbar scrolling smoother, we lock the `document.body` scrolling + // using CSS so we can use `position: fixed` on the toolbar. Therefore, we need to use a + // descendant element for scrolling. +
+ +
+ + {/* 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..4f210a9ea7 --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css @@ -0,0 +1,133 @@ +html, +body { + margin: 0; + overflow: hidden; +} + +/* 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: center; + align-items: center; + gap: 5px; + width: 44px; + height: 44px; + margin: -10px; + padding: 0; + background: none; + border: none; + cursor: pointer; +} + +.hamburger-button span { + display: block; + width: 22px; + 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 { + display: flex; + align-items: center; + min-height: 44px; + 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 — `html`/`body` are locked with `overflow: hidden` above), pinned to + the visual viewport rectangle via the `--bn-vv-*` variables the mobile + toolbar 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(--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/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/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/src/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/src/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..05411197b4 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -10,8 +10,8 @@ import { } from "@ariakit/react"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, PortalContext } from "@blocknote/react"; +import { forwardRef, useContext } from "react"; export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -48,10 +48,15 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); + // The DOM node the menu portals into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + return ( {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index df8e01128b..9b0f9f131e 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -5,12 +5,8 @@ import { } from "@ariakit/react"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { createContext, forwardRef, useContext } from "react"; - -const PortalRootContext = createContext( - undefined, -); +import { ComponentProps, PortalContext } from "@blocknote/react"; +import { forwardRef, useContext } from "react"; export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -31,7 +27,9 @@ export const PopoverContent = forwardRef< assertEmpty(rest); - const portalRoot = useContext(PortalRootContext); + // The DOM node the popover portals into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); return ( { - const { children, open, onOpenChange, position, portalRoot, ...rest } = props; + const { children, open, onOpenChange, position, ...rest } = props; assertEmpty(rest); @@ -61,9 +59,7 @@ export const Popover = ( setOpen={onOpenChange} placement={position} > - - {children} - + {children} ); }; diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index f596cbbae6..9a4c62914e 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -9,8 +9,8 @@ import { } from "@ariakit/react"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, PortalContext } from "@blocknote/react"; +import { forwardRef, useContext } from "react"; export const ToolbarSelect = forwardRef< HTMLDivElement, @@ -20,6 +20,10 @@ export const ToolbarSelect = forwardRef< assertEmpty(rest); + // The DOM node the dropdown portals into, e.g. the mobile formatting + // toolbar's non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + const selectedItem = props.items.filter((p) => p.isSelected)[0]; const setValue = (value: string) => { @@ -27,7 +31,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 && + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches; diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index accb33f62a..e23396b40e 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, @@ -206,6 +203,16 @@ color: var(--bn-colors-disabled-text); } +.bn-mobile-formatting-toolbar .bn-toolbar .mantine-Button-root { + height: 40px; + padding-inline: 12px; +} + +.bn-mobile-formatting-toolbar .bn-toolbar .mantine-ActionIcon-root { + width: 40px; + height: 40px; +} + .bn-toolbar .mantine-Menu-item { font-size: 12px; height: 30px; diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index c81ed870d7..d3ede40bf8 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -4,7 +4,7 @@ import { } from "@mantine/core"; import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; +import { ComponentProps, PortalContext } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; const SubMenuContext = createContext< @@ -20,12 +20,20 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { assertEmpty(rest); + // The DOM node menus portal into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + + // 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 +44,20 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { return ( {children} diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx index a96c1f284f..5373022044 100644 --- a/packages/mantine/src/popover/Popover.tsx +++ b/packages/mantine/src/popover/Popover.tsx @@ -5,16 +5,20 @@ import { } from "@mantine/core"; import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, PortalContext } from "@blocknote/react"; +import { forwardRef, useContext } from "react"; export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + const { open, onOpenChange, position, children, ...rest } = props; assertEmpty(rest); + // The DOM node the popover portals into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + return ( ( { + onPointerDown={(event) => { + // Prevents focus shift on mobile. + if (isTouchDevice()) { + event.preventDefault(); + return; + } + + // Needed as Safari doesn't focus button elements on mouse down + // unlike other browsers. if (isSafari()) { - (e.currentTarget as HTMLButtonElement).focus(); + (event.currentTarget as HTMLButtonElement).focus(); } }} onClick={(event) => { @@ -90,11 +96,17 @@ export const ToolbarButton = forwardRef( { + onPointerDown={(event) => { + // Prevents focus shift on mobile. + if (isTouchDevice()) { + event.preventDefault(); + return; + } + + // Needed as Safari doesn't focus button elements on mouse down + // unlike other browsers. if (isSafari()) { - (e.currentTarget as HTMLButtonElement).focus(); + (event.currentTarget as HTMLButtonElement).focus(); } }} onClick={(event) => { diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 21cee2a1fd..b4e1375e82 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -4,9 +4,9 @@ import { Menu as MantineMenu, } from "@mantine/core"; -import { assertEmpty, isSafari } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core"; +import { ComponentProps, PortalContext } from "@blocknote/react"; +import { forwardRef, useContext } from "react"; import { HiChevronDown } from "react-icons/hi"; // TODO: Turn into select? @@ -18,6 +18,10 @@ export const ToolbarSelect = forwardRef< assertEmpty(rest); + // The DOM node the dropdown portals into, e.g. the mobile formatting + // toolbar's non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + const selectedItem = items.filter((p) => p.isSelected)[0]; if (!selectedItem) { @@ -26,18 +30,38 @@ 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/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index db078703f2..c0a88088f3 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -2,6 +2,7 @@ import { ReactNode, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { PortalContext } from "../../editor/PortalContext.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -20,45 +21,49 @@ export const EmojiPicker = (props: { } return ( - - -
{ - // Needed as the Picker component's onClickOutside handler - // fires immediately after otherwise, preventing the popover - // from opening. - event.preventDefault(); - event.stopPropagation(); - setOpen(!open); - props.onOpenChange?.(!open); - }} - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} + // Portal into the editor's portal element (which carries the color-scheme + // class) so the picker inherits light/dark mode instead of the body's. + + + +
{ + // Needed as the Picker component's onClickOutside handler + // fires immediately after otherwise, preventing the popover + // from opening. + event.preventDefault(); + event.stopPropagation(); + setOpen(!open); + props.onOpenChange?.(!open); + }} + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > + {props.children} +
+
+ - {props.children} -
-
- - { - setOpen(false); - props.onOpenChange?.(false); - }} - onEmojiSelect={(emoji: { native: string }) => { - props.onEmojiSelect(emoji); - setOpen(false); - props.onOpenChange?.(false); - }} - theme={blockNoteContext?.colorSchemePreference} - /> - -
+ { + setOpen(false); + props.onOpenChange?.(false); + }} + onEmojiSelect={(emoji: { native: string }) => { + props.onEmojiSelect(emoji); + setOpen(false); + props.onOpenChange?.(false); + }} + theme={blockNoteContext?.colorSchemePreference} + /> + + + ); }; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index a03eab06ce..2586ca33cd 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { RiLink } from "react-icons/ri"; import { @@ -56,6 +56,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 +125,7 @@ export const CreateLinkButton = () => { return ( {/* TODO: hide tooltip on click */} @@ -128,7 +139,7 @@ export const CreateLinkButton = () => { dict.generic.ctrl_shortcut, )} icon={} - onClick={() => setShowPopover((open) => !open)} + onClick={() => setPopoverOpen(!showPopover)} /> { } return ( - + ; -}) => { - 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/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx new file mode 100644 index 0000000000..7f631f970a --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -0,0 +1,55 @@ +import { FC, useState } from "react"; + +import { PortalContext } from "../../editor/PortalContext.js"; +import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; +import { FormattingToolbar } from "./FormattingToolbar.js"; +import { useVirtualKeyboard } from "./useVirtualKeyboard.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 useVirtualKeyboard} (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 via CSS: locking document + * scroll (`overflow: hidden` on `html`/`body`) 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 PortalContext}, which the generic UI adapters read to portal their + * menus/popovers into it automatically. + * + * Shown while the virtual keyboard is open. + */ +export const MobileFormattingToolbarController = (props: { + formattingToolbar?: FC; +}) => { + const keyboardOpen = useVirtualKeyboard(); + // 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 (!keyboardOpen) { + return null; + } + + const Component = props.formattingToolbar || FormattingToolbar; + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts new file mode 100644 index 0000000000..389345e11b --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts @@ -0,0 +1,93 @@ +import { useLayoutEffect, useState } from "react"; + +// The tallest layout-equivalent viewport height seen so far — our stand-in for +// "keyboard closed" — and the layout width it was measured at. Module scope so +// they survive re-renders; the height only ever grows within a given width, so +// refreshing it from a render pass is safe. +let maxLayoutViewportHeight = 0; +let baselineLayoutWidth = 0; + +/** + * Whether the on-screen keyboard is open, from the current visual viewport. 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+). + * + * The keyboard never changes the viewport width, but an orientation change + * does — so when the (zoom-invariant) width changes we reset the baseline, + * otherwise a shorter landscape viewport would be mistaken for an open keyboard. + */ +function isVirtualKeyboardOpen(): boolean { + if (typeof window === "undefined") { + return false; + } + + const vp = window.visualViewport; + const scale = vp?.scale ?? 1; + const layoutHeight = (vp?.height ?? window.innerHeight) * scale; + const layoutWidth = (vp?.width ?? window.innerWidth) * scale; + + if (layoutWidth !== baselineLayoutWidth) { + baselineLayoutWidth = layoutWidth; + maxLayoutViewportHeight = 0; + } + + maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight); + return maxLayoutViewportHeight - layoutHeight > 150; +} + +/** + * Tracks the visual viewport, publishing the rectangle + pinch-zoom scale 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 whether the on-screen + * keyboard is open. + * + * Since it only returns a boolean, the consumer re-renders when the keyboard + * opens/closes, not on every viewport change (zoom/pan/scroll) — those keep the + * CSS properties up to date without a re-render. + * + * Does not lock document scroll. For the smoother "non-scrolling document" + * behavior, the host app opts in with CSS (see + * {@link MobileFormattingToolbarController}). This is what that controller + * relies on for positioning and keyboard detection. + */ +export function useVirtualKeyboard(): boolean { + const [open, setOpen] = useState(isVirtualKeyboardOpen); + + useLayoutEffect(() => { + const html = document.documentElement; + + const vp = window.visualViewport; + const update = () => { + setOpen(isVirtualKeyboardOpen()); + html.style.setProperty("--bn-vv-top", `${vp?.offsetTop ?? 0}px`); + html.style.setProperty("--bn-vv-left", `${vp?.offsetLeft ?? 0}px`); + html.style.setProperty( + "--bn-vv-width", + `${vp?.width ?? window.innerWidth}px`, + ); + html.style.setProperty( + "--bn-vv-height", + `${vp?.height ?? window.innerHeight}px`, + ); + html.style.setProperty("--bn-vv-scale", `${vp?.scale ?? 1}`); + }; + update(); + + // Fire on keyboard open/close, zoom/pan, and (unless the document is locked + // via CSS) content scroll. + vp?.addEventListener("resize", update); + vp?.addEventListener("scroll", update); + window.addEventListener("resize", update); + + return () => { + vp?.removeEventListener("resize", update); + vp?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, []); + + return open; +} diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 75d618dc71..d7459f3669 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,3 +1,4 @@ +import { isTouchDevice } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; import { FilePanelExtension, @@ -11,6 +12,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"; @@ -119,11 +121,14 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { return ( <> {editor.getExtension(FormattingToolbarExtension) && - props.formattingToolbar !== false && ( + props.formattingToolbar !== false && + (isTouchDevice() ? ( + + ) : ( - )} + ))} {editor.getExtension(LinkToolbarExtension) && props.linkToolbar !== false && ( diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 35d8a1ee3c..4b60483d43 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -372,7 +372,6 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; - portalRoot?: HTMLElement | null; children?: ReactNode; }; Content: { diff --git a/packages/react/src/editor/PortalContext.ts b/packages/react/src/editor/PortalContext.ts new file mode 100644 index 0000000000..e17b615ef6 --- /dev/null +++ b/packages/react/src/editor/PortalContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; + +/** + * Context to override the portal root of dropdowns/popovers in `ComponentsContext`. If not present + * or left undefined, the default render location of the dropdown/popover will be used, which is + * library-specific. + */ +export const PortalContext = createContext(null); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 507f2cd46f..a33733b29f 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -509,21 +509,30 @@ SideMenuController offsets its position to keep it centered on the line. */ gap: 4px; } -/* Mobile formatting toolbar positioning */ .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; - touch-action: pan-x; - -webkit-overflow-scrolling: touch; - overflow-x: auto; + 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; + transition: transform 0.2s cubic-bezier(0.5, 1, 0.89, 1); padding-bottom: env(safe-area-inset-bottom, 0); } +@media (prefers-reduced-motion: reduce) { + .bn-mobile-formatting-toolbar { + transition: none; + } +} + /* Emoji Picker styling */ .bn-root em-emoji-picker { max-height: 100%; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0553f8a30d..4992b38f16 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -43,7 +43,9 @@ export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton.js" export * from "./components/FormattingToolbar/DefaultSelects/BlockTypeSelect.js"; export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; -export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js"; +export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; +export * from "./editor/PortalContext.js"; +export * from "./components/FormattingToolbar/useVirtualKeyboard.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 1e5eb6ea54..51f3de3d7a 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,8 +1,11 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { + ComponentProps, + PortalContext, + useBlockNoteEditor, +} from "@blocknote/react"; import { ChevronRight } from "lucide-react"; -import { forwardRef, ReactElement } from "react"; - +import { forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; @@ -73,8 +76,13 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - // Portal into the editor's portal element (which carries the color-scheme - // class) so the menu inherits light/dark mode instead of the document body's. + // The DOM node the menu portals into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + + // Otherwise default to the editor's portal element (which carries the + // color-scheme class) so the menu inherits light/dark mode instead of the + // document body's. const editor = useBlockNoteEditor(); const container = editor.portalElement; @@ -82,7 +90,7 @@ export const MenuDropdown = forwardRef< return ( {children} @@ -92,7 +100,7 @@ export const MenuDropdown = forwardRef< return ( {children} diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 76c822dba2..94e3dd62ea 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,14 +1,14 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; -import { createContext, forwardRef, ReactElement, useContext } from "react"; +import { + ComponentProps, + PortalContext, + useBlockNoteEditor, +} from "@blocknote/react"; +import { forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); - export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { @@ -17,7 +17,6 @@ export const Popover = ( open, onOpenChange, position: _position, // unused - portalRoot, ...rest } = props; @@ -27,9 +26,7 @@ export const Popover = ( return ( - - {children} - + {children} ); }; @@ -60,11 +57,14 @@ export const PopoverContent = forwardRef< assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); - // Default to the editor's portal element (which carries the color-scheme - // class) so popovers inherit light/dark mode instead of the document body's, - // even when the caller doesn't pass an explicit portalRoot. + // The DOM node the popover portals into, e.g. the mobile formatting toolbar's + // non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + + // Otherwise default to the editor's portal element (which carries the + // color-scheme class) so popovers inherit light/dark mode instead of the + // document body's. const editor = useBlockNoteEditor(); return ( diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx index 6ac937ee7c..3970ca72ff 100644 --- a/packages/shadcn/src/toolbar/Toolbar.tsx +++ b/packages/shadcn/src/toolbar/Toolbar.tsx @@ -1,6 +1,10 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; -import { forwardRef } from "react"; +import { + ComponentProps, + PortalContext, + useBlockNoteEditor, +} from "@blocknote/react"; +import { forwardRef, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; @@ -132,8 +136,13 @@ export const ToolbarSelect = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - // Portal into the editor's portal element (which carries the color-scheme - // class) so the dropdown inherits light/dark mode instead of the body's. + // The DOM node the dropdown portals into, e.g. the mobile formatting + // toolbar's non-scrolling wrapper. `null` when there's no such target. + const portalRoot = useContext(PortalContext); + + // Otherwise default to the editor's portal element (which carries the + // color-scheme class) so the dropdown inherits light/dark mode instead of the + // body's. const editor = useBlockNoteEditor(); // TODO? @@ -163,7 +172,7 @@ export const ToolbarSelect = forwardRef<