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..7268410feb --- /dev/null +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css @@ -0,0 +1,126 @@ +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: 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 — `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..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..bcff489fd3 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,7 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, portalRoot, ...rest } = props; assertEmpty(rest); @@ -27,7 +27,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 accb33f62a..53d4d7603c 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..efdacc2be3 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -4,7 +4,7 @@ import { Menu as MantineMenu, } from "@mantine/core"; -import { assertEmpty, isSafari } from "@blocknote/core"; +import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { forwardRef } from "react"; import { HiChevronDown } from "react-icons/hi"; @@ -14,7 +14,7 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, portalRoot, ...rest } = props; assertEmpty(rest); @@ -26,18 +26,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/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..a57f7583ab --- /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 "./useVisualViewportRect.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 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 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/useVisualViewportRect.ts b/packages/react/src/components/FormattingToolbar/useVisualViewportRect.ts new file mode 100644 index 0000000000..9909e04e2d --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/useVisualViewportRect.ts @@ -0,0 +1,88 @@ +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. 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 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 CSS) 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; +} 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..5d71bc58dc 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -47,6 +47,7 @@ type ToolbarSelectType = { isDisabled?: boolean; }[]; isDisabled?: boolean; + portalRoot?: HTMLElement | null; }; type MenuButtonType = { @@ -333,6 +334,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 507f2cd46f..b7c9be2044 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -509,19 +509,50 @@ SideMenuController offsets its position to keep it centered on the line. */ 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; + transition: transform 0.2s cubic-bezier(0.5, 1, 0.89, 1); + padding-bottom: env(safe-area-inset-bottom, 0); + /* No `overflow` here: dropdowns flip to 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. */ +} + +@media (prefers-reduced-motion: reduce) { + .bn-mobile-formatting-toolbar { + transition: none; + } +} + +/* 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 2de5361e99..a16667149d 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/useVisualViewportRect.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 1e5eb6ea54..b61158f688 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -2,15 +2,20 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; import { forwardRef, ReactElement } from "react"; - +import { createContext, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; +const PortalRootContext = createContext( + undefined, +); + export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { children, onOpenChange, position: _position, // Unused + portalRoot, sub, ...rest } = props; @@ -24,7 +29,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - {children} + + {children} + ); } else { @@ -33,7 +40,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - {children} + + {children} + ); } @@ -72,6 +81,7 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; + const portalRoot = useContext(PortalRootContext); // 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. @@ -82,7 +92,7 @@ export const MenuDropdown = forwardRef< return ( {children} @@ -92,7 +102,7 @@ export const MenuDropdown = forwardRef< return ( {children} diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx index 6ac937ee7c..d12b4f4670 100644 --- a/packages/shadcn/src/toolbar/Toolbar.tsx +++ b/packages/shadcn/src/toolbar/Toolbar.tsx @@ -126,7 +126,7 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, ...rest } = props; + const { className, items, isDisabled, portalRoot, ...rest } = props; assertEmpty(rest); @@ -163,7 +163,7 @@ export const ToolbarSelect = forwardRef<