From e0e3e9093bf8738dff8449a8ecab94ad6609c223 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 2 Sep 2026 20:01:01 +0200 Subject: [PATCH 01/21] Added `PortalContext` --- .../src/components/Comments/EmojiPicker.tsx | 3 +- .../DefaultButtons/ColorStyleButton.tsx | 12 +++- .../DefaultButtons/CreateLinkButton.tsx | 12 +++- .../DefaultButtons/FileCaptionButton.tsx | 12 +++- .../DefaultButtons/FileRenameButton.tsx | 12 +++- .../DefaultButtons/FileReplaceButton.tsx | 12 +++- .../DefaultSelects/BlockTypeSelect.tsx | 12 +++- .../MobileFormattingToolbarController.tsx | 72 +++++-------------- .../components/Popovers/GenericPopover.tsx | 21 ++++-- packages/react/src/editor/BlockNoteView.tsx | 32 ++++++--- .../src/editor/MobileToolbarPortalContext.ts | 26 ------- packages/react/src/editor/PortalContext.ts | 25 +++++++ packages/react/src/editor/UIModeContext.ts | 21 ++++++ packages/react/src/index.ts | 3 +- 14 files changed, 158 insertions(+), 117 deletions(-) delete mode 100644 packages/react/src/editor/MobileToolbarPortalContext.ts create mode 100644 packages/react/src/editor/PortalContext.ts create mode 100644 packages/react/src/editor/UIModeContext.ts diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index db078703f2..6e39a933cd 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 { usePortalContext } from "../../editor/PortalContext.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -13,7 +14,7 @@ export const EmojiPicker = (props: { const Components = useComponentsContext()!; const blockNoteContext = useBlockNoteContext()!; - const portalRoot = blockNoteContext.editor?.portalElement; + const portalRoot = usePortalContext(); if (!portalRoot) { throw new Error("Portal root not found"); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index 25e0e429e7..7dc764da41 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -7,7 +7,8 @@ import { import { useCallback } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { useMobileToolbarPortal } from "../../../editor/MobileToolbarPortalContext.js"; +import { usePortalContext } from "../../../editor/PortalContext.js"; +import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -44,7 +45,12 @@ function checkColorInSchema( export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -146,7 +152,7 @@ export const ColorStyleButton = () => { // dropdown, which would blur the editor and dismiss the on-screen // keyboard. On desktop it's `undefined`, keeping the default inline // rendering. - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > { const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -136,7 +142,7 @@ export const CreateLinkButton = () => { // staying styled. A set `portalRoot` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 03de3f0857..0c2f9f79ea 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -9,7 +9,8 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { useMobileToolbarPortal } from "../../../editor/MobileToolbarPortalContext.js"; +import { usePortalContext } from "../../../editor/PortalContext.js"; +import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -17,7 +18,12 @@ import { useDictionary } from "../../../i18n/dictionary.js"; export const FileCaptionButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,7 +118,7 @@ export const FileCaptionButton = () => { // staying styled. A set `portalRoot` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,7 +118,7 @@ export const FileRenameButton = () => { // staying styled. A set `portalRoot` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -67,7 +73,7 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > { const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalContext = usePortalContext(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (portalContext ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -214,7 +220,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index dd33ce607e..e38c6ee95c 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,24 +1,13 @@ -import { FC, useCallback, useEffect, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { MobileToolbarPortalContext } from "../../editor/MobileToolbarPortalContext.js"; +import { PortalContext } from "../../editor/PortalContext.js"; +import { UIModeContext } from "../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; -// Theme-carrying attributes copied from `editor.portalElement` onto the mobile -// toolbar's body-level container: the classes (`bn-root`, the UI-library class -// like `bn-mantine`, the color-scheme class) that existing CSS keys off, the -// color-scheme data attributes, and any inline theme CSS variables (set for -// custom object themes). -const THEME_ATTRIBUTES = [ - "class", - "style", - "data-color-scheme", - "data-mantine-color-scheme", -]; - /** * Mobile formatting toolbar controller. * @@ -33,17 +22,12 @@ const THEME_ATTRIBUTES = [ * a scrolling/pinned container (e.g. the `bn-scroll-container` layout), and on * iOS that container's `-webkit-overflow-scrolling` stacking context paints the * `position: fixed` toolbar behind page content like footers; rendering at the - * body level avoids that. Its dropdown buttons portal their menus into the same - * container (via {@link MobileToolbarPortalContext}) so they escape the editor - * container's overflow instead of being clipped. A set `portalRoot` also tells - * the UI adapters not to move focus into the dropdown, which would blur the - * editor and dismiss the keyboard. - * - * Because the container lives outside the editor's themed subtree, it mirrors - * the theme attributes from `editor.portalElement` ({@link THEME_ATTRIBUTES}) so - * the toolbar and dropdowns stay styled. React context (editor, components, - * theme provider) still flows through the portal, so only the DOM-inherited - * styling needs recreating. + * body level avoids that. It provides a `"mobile"` {@link UIModeContext} so its + * buttons know to portal their dropdowns (into the body-level + * {@link PortalContext} target, escaping the editor container's overflow) and to + * suppress moving focus into them, which would blur the editor and dismiss the + * keyboard. React context (editor, components, theme provider) still flows + * through the portal. * * Shown while the virtual keyboard is open and this editor holds focus. The * focus check is essential when multiple editors share a page: the virtual @@ -104,47 +88,23 @@ export const MobileFormattingToolbarController = (props: { }; /** - * The visible part of the mobile toolbar, split out so it can own the state for - * its themed body-level container. See the controller docstring. + * The visible part of the mobile toolbar, rendered at the body level. Marks its + * subtree as a `"mobile"` UI surface and exposes the body-level portal target so + * its buttons' dropdowns portal alongside it. See the controller docstring. */ function MobileFormattingToolbar(props: { formattingToolbar: FC; }) { - const editor = useBlockNoteEditor(); - - // The themed container the toolbar and its dropdowns render into. Tracked in - // state so it can be provided to the dropdown buttons once mounted. - const [container, setContainer] = useState(null); - const containerRef = useCallback( - (node: HTMLDivElement | null) => { - if (node) { - // Mirror the editor portal's theme attributes so the container matches - // the editor's `.bn-root`/UI-library theming despite living outside its - // subtree. Recreating the styling in the DOM, not React context (which - // the portal preserves). - const portal = editor.portalElement; - for (const name of THEME_ATTRIBUTES) { - const value = portal.getAttribute(name); - if (value !== null) { - node.setAttribute(name, value); - } - } - } - setContainer(node); - }, - [editor], - ); - const Component = props.formattingToolbar; return createPortal( - -
+ +
-
-
, + + , document.body, ); } diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index e185e36618..16046b8bca 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,6 +14,7 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; +import { PortalContext, usePortalContext } from "../../editor/PortalContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -117,19 +118,23 @@ export const GenericPopover = ( reference?: GenericPopoverReference; children: ReactNode; /** - * Override the DOM node this popover portals into. If omitted, falls back - * to `editor.portalElement`. + * Override the DOM node this popover portals into. Falls back to the + * ambient `PortalContext` (whose default is `editor.portalElement`) when + * omitted; `null` means `document.body`. */ portalElement?: HTMLElement | null; }, ) => { const editor = useBlockNoteEditor(); + const contextPortal = usePortalContext(); + // An explicit `portalElement` prop overrides the ambient `PortalContext`; + // `null` means `document.body`. const portalRoot = props.portalElement === null ? typeof document !== "undefined" ? document.body : undefined - : (props.portalElement ?? editor?.portalElement); + : (props.portalElement ?? contextPortal); if (!portalRoot) { throw new Error("Portal element not found"); } @@ -267,7 +272,10 @@ export const GenericPopover = (
- {props.children} + {/* Cascade the resolved target so nested floating UI portals here too. */} + + {props.children} +
@@ -277,7 +285,10 @@ export const GenericPopover = ( return (
- {props.children} + {/* Cascade the resolved target so nested floating UI portals here too. */} + + {props.children} +
); diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index d6e6f85b8e..ed2eb72130 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -27,6 +27,7 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; +import { PortalContext } from "./PortalContext.js"; import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, @@ -140,6 +141,15 @@ function BlockNoteViewComponent< [portalElements?.default], ); + // The default portal target for all floating UI — the editor's own portal + // element. Per-element `portalElements` and the mobile toolbar override this + // for their subtrees via `PortalContext`. Guarded for SSR, where accessing + // `editor.portalElement` (which needs `document`) would throw. + const defaultPortalRoot = useMemo( + () => (typeof document !== "undefined" ? editor.portalElement : null), + [editor], + ); + // Used so other components (suggestion menu) can set // aria related props to the contenteditable div const [contentEditableProps, setContentEditableProps] = @@ -231,16 +241,18 @@ function BlockNoteViewComponent< return ( - - - {children} - + + + + {children} + + ); diff --git a/packages/react/src/editor/MobileToolbarPortalContext.ts b/packages/react/src/editor/MobileToolbarPortalContext.ts deleted file mode 100644 index 5630409344..0000000000 --- a/packages/react/src/editor/MobileToolbarPortalContext.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createContext, useContext } from "react"; - -/** - * The DOM node the mobile formatting toolbar's dropdowns should portal into, or - * `null` when not in the mobile toolbar (e.g. on desktop). This doubles as the - * "is this the mobile toolbar?" signal for toolbar buttons: it's non-null only - * while they're rendered inside the mobile toolbar. - * - * `MobileFormattingToolbarController` renders the toolbar into a themed - * container mounted on `document.body` (so it escapes the editor's scroll - * container, whose overflow would otherwise clip the dropdowns and whose iOS - * stacking context would trap the toolbar), and provides that container here. - * The dropdowns portal into it rather than bare `document.body` so they land - * inside its theme classes/variables and stay styled — Mantine and the other - * adapters append popovers as direct children of the portal target, so - * targeting `document.body` would drop them outside the theme scope. A set - * `portalRoot` also tells the UI adapters not to move focus into the dropdown, - * which would blur the editor and dismiss the on-screen keyboard. - */ -export const MobileToolbarPortalContext = createContext( - null, -); - -export function useMobileToolbarPortal(): HTMLElement | null { - return useContext(MobileToolbarPortalContext); -} diff --git a/packages/react/src/editor/PortalContext.ts b/packages/react/src/editor/PortalContext.ts new file mode 100644 index 0000000000..3cfe0dcbad --- /dev/null +++ b/packages/react/src/editor/PortalContext.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; + +/** + * The default DOM node that the editor's floating UI (toolbars, menus, + * popovers, table handles, etc.) portals into — used instead of reaching for + * `editor.portalElement` directly. + * + * Provided at two levels: + * 1. `BlockNoteView` provides `editor.portalElement` as the default. + * 2. The mobile formatting toolbar overrides it for its own subtree (its + * body-level container) so its dropdowns portal alongside it. + * + * Per-element (`portalElements` map) and manual overrides instead flow through + * the `portalElement` prop on the controllers / popovers, which `GenericPopover` + * resolves against this context (`portalElement ?? context`) and re-provides to + * its subtree. + * + * `null` means "no portal target available" (e.g. during SSR); consumers that + * require one should throw. + */ +export const PortalContext = createContext(null); + +export function usePortalContext(): HTMLElement | null { + return useContext(PortalContext); +} diff --git a/packages/react/src/editor/UIModeContext.ts b/packages/react/src/editor/UIModeContext.ts new file mode 100644 index 0000000000..15f1fe2ff1 --- /dev/null +++ b/packages/react/src/editor/UIModeContext.ts @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; + +/** + * Describes the kind of UI surface the editor's floating elements (menus, + * popovers, dropdowns from `ComponentsContext`) are being rendered into. + * + * `"desktop"` is the default. `"mobile"` is provided by + * `MobileFormattingToolbarController`, whose toolbar is pinned above the + * on-screen keyboard and lives outside the editor's DOM subtree. Toolbar + * buttons read this to decide whether to portal their dropdowns (into the + * {@link PortalContext} target) and to suppress moving focus into them — which + * on desktop would break keyboard nav, and on mobile would blur the editor's + * contentEditable and dismiss the keyboard. + */ +export type UIMode = "desktop" | "mobile"; + +export const UIModeContext = createContext("desktop"); + +export function useUIMode(): UIMode { + return useContext(UIModeContext); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bb1811a729..8c5d281379 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,7 +45,8 @@ export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/DesktopFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; -export * from "./editor/MobileToolbarPortalContext.js"; +export * from "./editor/PortalContext.js"; +export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; From 3773025d90c2a1ffa75b56549978c2362ec2d659 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 3 Sep 2026 13:56:07 +0200 Subject: [PATCH 02/21] Refactored `PortalContext` to `PortalTarget` --- packages/core/src/editor/BlockNoteEditor.ts | 53 ++++++- packages/mantine/src/BlockNoteTheme.ts | 116 ++-------------- packages/mantine/src/BlockNoteView.tsx | 60 +++----- .../AttributionTooltipController.tsx | 13 +- .../src/components/Comments/EmojiPicker.tsx | 2 +- .../Comments/FloatingComposerController.tsx | 13 +- .../Comments/FloatingThreadController.tsx | 28 ++-- .../FilePanel/FilePanelController.tsx | 13 +- .../DefaultButtons/ColorStyleButton.tsx | 2 +- .../DefaultButtons/CreateLinkButton.tsx | 2 +- .../DefaultButtons/FileCaptionButton.tsx | 2 +- .../DefaultButtons/FileRenameButton.tsx | 2 +- .../DefaultButtons/FileReplaceButton.tsx | 2 +- .../DefaultSelects/BlockTypeSelect.tsx | 2 +- .../DesktopFormattingToolbarController.tsx | 13 +- .../MobileFormattingToolbarController.tsx | 21 +-- .../LinkToolbar/LinkToolbarController.tsx | 29 ++-- .../src/components/Popovers/BlockPopover.tsx | 9 +- .../components/Popovers/GenericPopover.tsx | 38 ++--- .../components/Popovers/PositionPopover.tsx | 9 +- .../SideMenu/SideMenuController.tsx | 16 ++- .../GridSuggestionMenuController.tsx | 39 +++--- .../SuggestionMenuController.tsx | 35 +++-- .../TableHandles/TableHandlesController.tsx | 10 +- packages/react/src/editor/BlockNoteView.tsx | 130 ++++++++++++------ .../react/src/editor/BlockNoteViewContext.ts | 28 +++- packages/react/src/editor/PortalContext.ts | 25 ---- packages/react/src/editor/PortalTarget.tsx | 125 +++++++++++++++++ packages/react/src/editor/portalElements.ts | 24 ++-- packages/react/src/index.ts | 5 +- packages/shadcn/src/badge/Badge.tsx | 11 +- packages/shadcn/src/menu/Menu.tsx | 10 +- packages/shadcn/src/popover/popover.tsx | 12 +- packages/shadcn/src/toolbar/Toolbar.tsx | 19 +-- 34 files changed, 501 insertions(+), 417 deletions(-) delete mode 100644 packages/react/src/editor/PortalContext.ts create mode 100644 packages/react/src/editor/PortalTarget.tsx diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..809913929c 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -793,16 +793,59 @@ export class BlockNoteEditor< return this._portalElement; } + // Portal roots registered by the view layer, with reference counts so + // multiple UI elements can share a root (e.g. several popovers portalling + // into the same custom target). + private _portalRoots = new Map(); + /** - * Checks whether a DOM element belongs to this editor — either inside the - * editor's DOM tree or inside its portal container (used for floating UI - * elements like menus and toolbars). + * Registers an element as a portal root for this editor's floating UI, so + * {@link isWithinEditor} treats its contents as part of the editor. The view + * layer calls this for each portal target it designates (see + * `usePortalTarget` in `@blocknote/react`) — without it, UI portalled to a + * custom `portalElements` target would be considered outside the editor. + * Returns a function that releases the registration. + */ + public registerPortalRoot = (element: HTMLElement): (() => void) => { + this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1); + + let released = false; + return () => { + if (released) { + return; + } + released = true; + + const count = this._portalRoots.get(element) ?? 0; + if (count <= 1) { + this._portalRoots.delete(element); + } else { + this._portalRoots.set(element, count - 1); + } + }; + }; + + /** + * Checks whether a DOM element belongs to this editor — inside the editor's + * DOM tree, its default portal container, or any portal root registered via + * {@link registerPortalRoot} (used for floating UI elements like menus and + * toolbars, which may portal outside the editor's DOM tree). */ public isWithinEditor = (element: Element): boolean => { - return !!( + if ( this.domElement?.parentElement?.contains(element) || this.portalElement?.contains(element) - ); + ) { + return true; + } + + for (const root of this._portalRoots.keys()) { + if (root.contains(element)) { + return true; + } + } + + return false; }; public isFocused() { diff --git a/packages/mantine/src/BlockNoteTheme.ts b/packages/mantine/src/BlockNoteTheme.ts index 503c4729cd..1a13169538 100644 --- a/packages/mantine/src/BlockNoteTheme.ts +++ b/packages/mantine/src/BlockNoteTheme.ts @@ -34,12 +34,13 @@ export type Theme = Partial<{ type NestedObject = { [key: string]: number | string | NestedObject }; -const cssVariablesHelper = ( - theme: Theme, - editorDOM: HTMLElement, - unset = false, -) => { - const result: string[] = []; +/** + * Converts a {@link Theme} into a map of `--bn-*` CSS custom properties, for + * passing declaratively via a `style` prop (e.g. to `BlockNoteViewRaw`, which + * also forwards it to portal roots). + */ +export function themeToCSSVariables(theme: Theme): Record { + const variables: Record = {}; function traverse(current: NestedObject, currentKey = "--bn") { for (const key in current) { @@ -47,107 +48,18 @@ const cssVariablesHelper = ( .replace(/([a-z])([A-Z])/g, "$1-$2") .toLowerCase(); const fullKey = `${currentKey}-${kebabCaseKey}`; + const value = current[key]; - if (typeof current[key] !== "object") { - // Convert numbers to px - if (typeof current[key] === "number") { - current[key] = `${current[key]}px`; - } - - if (unset) { - editorDOM.style.removeProperty(fullKey); - } else { - editorDOM.style.setProperty(fullKey, current[key].toString()); - } + if (typeof value === "object") { + traverse(value, fullKey); } else { - traverse(current[key] as NestedObject, fullKey); + // Convert numbers to px + variables[fullKey] = typeof value === "number" ? `${value}px` : value; } } } traverse(theme); - return result; -}; - -export const applyBlockNoteCSSVariablesFromTheme = ( - theme: Theme, - editorDOM: HTMLElement, -) => cssVariablesHelper(theme, editorDOM); - -// We don't need a theme to remove the CSS variables, but having access to a -// theme object allows us to use the same logic to set/unset them, so this -// placeholder theme is used. -const placeholderTheme: Theme = { - colors: { - editor: { - text: undefined as any, - background: undefined as any, - }, - menu: { - text: undefined as any, - background: undefined as any, - }, - tooltip: { - text: undefined as any, - background: undefined as any, - }, - hovered: { - text: undefined as any, - background: undefined as any, - }, - selected: { - text: undefined as any, - background: undefined as any, - }, - disabled: { - text: undefined as any, - background: undefined as any, - }, - shadow: undefined as any, - border: undefined as any, - sideMenu: undefined as any, - highlights: { - gray: { - text: undefined as any, - background: undefined as any, - }, - brown: { - text: undefined as any, - background: undefined as any, - }, - red: { - text: undefined as any, - background: undefined as any, - }, - orange: { - text: undefined as any, - background: undefined as any, - }, - yellow: { - text: undefined as any, - background: undefined as any, - }, - green: { - text: undefined as any, - background: undefined as any, - }, - blue: { - text: undefined as any, - background: undefined as any, - }, - purple: { - text: undefined as any, - background: undefined as any, - }, - pink: { - text: undefined as any, - background: undefined as any, - }, - }, - }, - borderRadius: undefined as any, - fontFamily: undefined as any, -}; -export const removeBlockNoteCSSVariables = (editorDOM: HTMLElement) => - cssVariablesHelper(placeholderTheme, editorDOM, true); + return variables; +} diff --git a/packages/mantine/src/BlockNoteView.tsx b/packages/mantine/src/BlockNoteView.tsx index 2b714326ce..9b82e89787 100644 --- a/packages/mantine/src/BlockNoteView.tsx +++ b/packages/mantine/src/BlockNoteView.tsx @@ -11,12 +11,8 @@ import { usePrefersColorScheme, } from "@blocknote/react"; import { MantineContext, MantineProvider } from "@mantine/core"; -import React, { useCallback, useContext, useEffect } from "react"; -import { - applyBlockNoteCSSVariablesFromTheme, - removeBlockNoteCSSVariables, - Theme, -} from "./BlockNoteTheme.js"; +import React, { useContext, useMemo } from "react"; +import { Theme, themeToCSSVariables } from "./BlockNoteTheme.js"; import { components } from "./components.js"; export const BlockNoteView = < @@ -52,49 +48,37 @@ export const BlockNoteView = < ? defaultColorScheme : "light"; - const applyThemeVariables = useCallback( - (node: HTMLElement | null) => { - if (!node) { - return; - } - - removeBlockNoteCSSVariables(node); - - if (typeof theme === "object") { - if ("light" in theme && "dark" in theme) { - applyBlockNoteCSSVariablesFromTheme( - theme[defaultColorScheme === "dark" ? "dark" : "light"], - node, - ); - return; - } - - applyBlockNoteCSSVariablesFromTheme(theme, node); - return; - } - }, - [defaultColorScheme, theme], - ); + // Mantine's theming for BlockNote's themed root elements (the editor + // container, `editor.portalElement`, portal roots): the color-scheme + // attribute the stylesheet keys off, plus CSS variables for custom object + // themes. `BlockNoteViewRaw` applies these to every root, so they all update + // in the same commit. + const themedRootProps = useMemo(() => { + const themeCSSVariables = + typeof theme !== "object" + ? undefined + : "light" in theme && "dark" in theme + ? themeToCSSVariables( + theme[defaultColorScheme === "dark" ? "dark" : "light"], + ) + : themeToCSSVariables(theme); - useEffect(() => { - if (!editor.portalElement) { - throw new Error("Portal element not found"); - } - editor.portalElement.setAttribute("data-mantine-color-scheme", finalTheme); - applyThemeVariables(editor.portalElement); - }, [editor, applyThemeVariables, finalTheme]); + return { + "data-mantine-color-scheme": finalTheme, + style: themeCSSVariables, + }; + }, [defaultColorScheme, theme, finalTheme]); const mantineContext = useContext(MantineContext); const view = ( ); diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx index 5d070c0c99..13016f2d07 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx @@ -3,6 +3,7 @@ import { flip, offset, shift, inline } from "@floating-ui/react"; import { FC, useMemo } from "react"; import { useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -114,12 +115,10 @@ export const AttributionTooltipController = (props: { ); return ( - - {tooltipProps && } - + + + {tooltipProps && } + + ); }; diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 6e39a933cd..1468f46e2d 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -2,7 +2,7 @@ import { ReactNode, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../editor/PortalContext.js"; +import { usePortalContext } from "../../editor/PortalTarget.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 9d2feba38d..6f09e834b7 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -14,6 +14,7 @@ import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useEditorState } from "../../hooks/useEditorState.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -131,12 +132,10 @@ export default function FloatingComposerController< const Component = props.floatingComposer || FloatingComposer; return ( - - - + + + + + ); } diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index a1f082d2e3..ae363e3962 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -5,6 +5,7 @@ import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -128,18 +129,19 @@ export default function FloatingThreadController(props: { const Component = props.floatingThread || Thread; return ( - - {thread && ( - - )} - + + + {thread && ( + + )} + + ); } diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index b9da146874..6a9d111e7d 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -4,6 +4,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { FilePanel } from "./FilePanel.js"; @@ -60,12 +61,10 @@ export const FilePanelController = (props: { const Component = props.filePanel || FilePanel; return ( - - {blockId && } - + + + {blockId && } + + ); }; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index 7dc764da41..f69bec6bc9 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -7,7 +7,7 @@ import { import { useCallback } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index b771d48b28..1356d0e2df 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -14,7 +14,7 @@ import { } from "@blocknote/core/extensions"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 0c2f9f79ea..9732b6f7e3 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -9,7 +9,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index c8d1f4c63c..3f5101508c 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -9,7 +9,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiFontFamily } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index e4502fb622..05f315d459 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -7,7 +7,7 @@ import { import { RiImageEditFill } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index e121ddf581..99cf3afe32 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -26,7 +26,7 @@ import { ComponentProps, useComponentsContext, } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalContext.js"; +import { usePortalContext } from "../../../editor/PortalTarget.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index 5ba258dfca..685cbb304a 100644 --- a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx @@ -13,6 +13,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../hooks/useEditorState.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; @@ -118,12 +119,10 @@ export const DesktopFormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( - - {show && } - + + + {show && } + + ); }; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index e38c6ee95c..27e49e5e19 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,7 +1,6 @@ import { FC, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { PortalContext } from "../../editor/PortalContext.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { UIModeContext } from "../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; @@ -89,22 +88,24 @@ export const MobileFormattingToolbarController = (props: { /** * The visible part of the mobile toolbar, rendered at the body level. Marks its - * subtree as a `"mobile"` UI surface and exposes the body-level portal target so - * its buttons' dropdowns portal alongside it. See the controller docstring. + * subtree as a `"mobile"` UI surface. Because `document.body` is outside the + * editor's themed subtree, {@link PortalTarget} renders a themed `.bn-root` div + * inside it (registered with the editor) that the toolbar renders into, and + * provides it via `PortalContext` so the toolbar's dropdowns portal alongside + * it. See the controller docstring. */ function MobileFormattingToolbar(props: { formattingToolbar: FC; }) { const Component = props.formattingToolbar; - return createPortal( - - + return ( + +
-
-
, - document.body, + + ); } diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index fbe789a544..bad4659825 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -6,6 +6,7 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -184,20 +185,18 @@ export const LinkToolbarController = (props: { const Component = props.linkToolbar || LinkToolbar; return ( - - {link && ( - - )} - + + + {link && ( + + )} + + ); }; diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..7bca85a434 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -9,10 +9,9 @@ export const BlockPopover = ( props: FloatingUIOptions & { blockId: string | undefined; children: ReactNode; - portalElement?: HTMLElement | null; }, ) => { - const { blockId, children, portalElement, ...floatingUIOptions } = props; + const { blockId, children, ...floatingUIOptions } = props; const editor = useBlockNoteEditor(); @@ -44,11 +43,7 @@ export const BlockPopover = ( ); return ( - + {blockId !== undefined && children} ); diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 16046b8bca..d1f9755e27 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,7 +14,7 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; -import { PortalContext, usePortalContext } from "../../editor/PortalContext.js"; +import { usePortalContext } from "../../editor/PortalTarget.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -117,27 +117,15 @@ export const GenericPopover = ( props: FloatingUIOptions & { reference?: GenericPopoverReference; children: ReactNode; - /** - * Override the DOM node this popover portals into. Falls back to the - * ambient `PortalContext` (whose default is `editor.portalElement`) when - * omitted; `null` means `document.body`. - */ - portalElement?: HTMLElement | null; }, ) => { const editor = useBlockNoteEditor(); - const contextPortal = usePortalContext(); - // An explicit `portalElement` prop overrides the ambient `PortalContext`; - // `null` means `document.body`. - const portalRoot = - props.portalElement === null - ? typeof document !== "undefined" - ? document.body - : undefined - : (props.portalElement ?? contextPortal); - if (!portalRoot) { - throw new Error("Portal element not found"); - } + // The ambient portal root — always a resolved, themed, registered root, as + // `PortalContext` is only ever provided by `PortalTarget` (the default from + // `BlockNoteView`, or a controller's / the mobile toolbar's override). + // `null` during SSR and for the frame before resolution — handled after the + // hooks below. + const portalRoot = usePortalContext(); const { whileElementsMounted: _whileElementsMounted, middleware, @@ -228,7 +216,7 @@ export const GenericPopover = ( [status, props.reference, props.children], ); - if (!isMounted) { + if (!isMounted || !portalRoot) { return false; } @@ -272,10 +260,7 @@ export const GenericPopover = (
- {/* Cascade the resolved target so nested floating UI portals here too. */} - - {props.children} - + {props.children}
@@ -285,10 +270,7 @@ export const GenericPopover = ( return (
- {/* Cascade the resolved target so nested floating UI portals here too. */} - - {props.children} - + {props.children}
); diff --git a/packages/react/src/components/Popovers/PositionPopover.tsx b/packages/react/src/components/Popovers/PositionPopover.tsx index f59b458900..93ef837f61 100644 --- a/packages/react/src/components/Popovers/PositionPopover.tsx +++ b/packages/react/src/components/Popovers/PositionPopover.tsx @@ -10,10 +10,9 @@ export const PositionPopover = ( props: FloatingUIOptions & { position: { from: number; to?: number } | undefined; children: ReactNode; - portalElement?: HTMLElement | null; }, ) => { - const { position, children, portalElement, ...floatingUIOptions } = props; + const { position, children, ...floatingUIOptions } = props; const { from, to } = position || {}; const editor = useBlockNoteEditor(); @@ -35,11 +34,7 @@ export const PositionPopover = ( }, [editor, editorDOMElement, from, to]); return ( - + {position !== undefined && children} ); diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index b83a7af977..b46d125bb3 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -5,6 +5,7 @@ import { FC, useCallback, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; @@ -149,12 +150,13 @@ export const SideMenuController = (props: { const Component = props.sideMenu || SideMenu; return ( - - {block?.id && } - + + + {block?.id && } + + ); }; diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 1fe667635b..14351c0971 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -12,6 +12,7 @@ import { useExtension, useExtensionState, } from "../../../hooks/useExtension.js"; +import { PortalTarget } from "../../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -184,25 +185,23 @@ export function GridSuggestionMenuController< } return ( - - {triggerCharacter && ( - > - } - onItemClick={onItemClickOrDefault} - /> - )} - + + + {triggerCharacter && ( + > + } + onItemClick={onItemClickOrDefault} + /> + )} + + ); } diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 39dc36f743..dbaf8232e7 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -10,6 +10,7 @@ import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -177,23 +178,21 @@ export function SuggestionMenuController< } return ( - - {triggerCharacter && ( - > - } - onItemClick={onItemClickOrDefault} - /> - )} - + + + {triggerCharacter && ( + > + } + onItemClick={onItemClickOrDefault} + /> + )} + + ); } diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 80f89be83a..1dcb85a3d9 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -12,6 +12,7 @@ import { FC, useCallback, useMemo, useState } from "react"; import { autoUpdate, offset, ReferenceElement, size } from "@floating-ui/react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; +import { PortalTarget } from "../../editor/PortalTarget.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -315,10 +316,9 @@ export const TableHandlesController = < const TableCellHandleComponent = props.tableCellHandle || TableCellButton; return ( - <> + {state.show && @@ -334,7 +334,6 @@ export const TableHandlesController = < {state.show && @@ -350,7 +349,6 @@ export const TableHandlesController = < {state.show && @@ -366,7 +364,6 @@ export const TableHandlesController = < {state.show && @@ -382,7 +379,6 @@ export const TableHandlesController = < {state.show && @@ -396,6 +392,6 @@ export const TableHandlesController = < /> )} - + ); }; diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index ed2eb72130..210be2ec94 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -10,7 +10,6 @@ import React, { ReactNode, Ref, useCallback, - useEffect, useMemo, useState, } from "react"; @@ -27,10 +26,11 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { PortalContext } from "./PortalContext.js"; +import { PortalContext, PortalTarget } from "./PortalTarget.js"; import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, + ThemedRootProps, useBlockNoteViewContext, } from "./BlockNoteViewContext.js"; import { useComponentsContext } from "./ComponentsContext.js"; @@ -92,12 +92,21 @@ export type BlockNoteViewProps< */ children?: ReactNode; + /** + * Attributes to apply to every themed BlockNote root element — the editor + * container, `editor.portalElement`, and any portal roots created by + * `PortalTarget`. UI-library wrappers use this to carry their theming + * (color-scheme data attributes, theme CSS variables) to floating UI + * portalled outside the editor's DOM. + */ + themedRootProps?: ThemedRootProps; + ref?: Ref | undefined; // only here to get types working with the generics. Regular form doesn't work } & BlockNoteDefaultUIProps; // `portalElements` is part of `BlockNoteDefaultUIProps`, but we re-export the // types here for convenience so consumers can import them from `@blocknote/react`. -export type { PortalElementsMap, PortalTarget } from "./portalElements.js"; +export type { PortalElement, PortalElementsMap } from "./portalElements.js"; function BlockNoteViewComponent< BSchema extends BlockSchema, @@ -128,6 +137,7 @@ function BlockNoteViewComponent< tableHandles, comments, portalElements, + themedRootProps, autoFocus, renderEditor = true, ...rest @@ -141,14 +151,15 @@ function BlockNoteViewComponent< [portalElements?.default], ); - // The default portal target for all floating UI — the editor's own portal - // element. Per-element `portalElements` and the mobile toolbar override this - // for their subtrees via `PortalContext`. Guarded for SSR, where accessing - // `editor.portalElement` (which needs `document`) would throw. - const defaultPortalRoot = useMemo( - () => (typeof document !== "undefined" ? editor.portalElement : null), - [editor], - ); + // The default portal root for all floating UI: `editor.portalElement`, run + // through `PortalTarget` (a childless instance below reports it via + // `onResolve`) so that `PortalContext` always holds a resolved, themed, + // registered root — consumers use it without further checks. Per-element + // `portalElements` and the mobile toolbar override it for their subtrees + // with their own `PortalTarget`s. `null` during SSR and for the first + // frame, before resolution. + const [defaultPortalRoot, setDefaultPortalRoot] = + useState(null); // Used so other components (suggestion menu) can set // aria related props to the contenteditable div @@ -201,17 +212,23 @@ function BlockNoteViewComponent< [editor], ); - useEffect(() => { - if (!editor.portalElement) { - throw new Error("Portal element not found"); - } - editor.portalElement.className = mergeCSSClasses( - "bn-root", - editorColorScheme, - className || "", - ); - editor.portalElement.setAttribute("data-color-scheme", editorColorScheme); - }, [editor, editorColorScheme, className]); + // Props that turn an element into a themed `.bn-root`. Rendered onto the + // container and provided via `BlockNoteViewContext` so `PortalTarget` can + // render portal roots with the same theming — all from the same data, + // updating in the same commit. UI-library extras come in via the + // `themedRootProps` prop, so no library-specific knowledge is needed here. + // Note that `editor.portalElement` itself stays bare: it's a location + // anchor, and floating UI portalled into it is themed either by a `.bn-root` + // ancestor (the default `bn-container` mount) or by a `PortalTarget`-created + // root inside it. + const portalRootProps = useMemo( + () => ({ + ...themedRootProps, + className: mergeCSSClasses("bn-root", editorColorScheme, className || ""), + "data-color-scheme": editorColorScheme, + }), + [themedRootProps, editorColorScheme, className], + ); // The BlockNoteContext makes sure the editor and some helper methods // are always available to nesteed compoenents @@ -235,18 +252,33 @@ function BlockNoteViewComponent< portalTarget, }, defaultUIProps, + portalRootProps, }; - }, [autoFocus, contentEditableProps, editable, defaultUIProps, portalTarget]); + }, [ + autoFocus, + contentEditableProps, + editable, + defaultUIProps, + portalTarget, + portalRootProps, + ]); return ( + @@ -267,30 +299,46 @@ const BlockNoteViewContainer = React.forwardRef< { renderEditor: boolean; editorColorScheme: "light" | "dark"; + themedRootProps?: ThemedRootProps; children: ReactNode; } & Omit< HTMLAttributes, "onChange" | "onSelectionChange" | "children" > ->(({ className, renderEditor, editorColorScheme, children, ...rest }, ref) => ( -
( + ( + { className, - )} - data-color-scheme={editorColorScheme} - {...rest} - ref={ref} - > - {renderEditor ? ( - {children} - ) : ( - children - )} -
-)); + renderEditor, + editorColorScheme, + themedRootProps, + children, + style, + ...rest + }, + ref, + ) => ( +
+ {renderEditor ? ( + {children} + ) : ( + children + )} +
+ ), +); // https://fettblog.eu/typescript-react-generic-forward-refs/ export const BlockNoteViewRaw = React.forwardRef(BlockNoteViewComponent) as < diff --git a/packages/react/src/editor/BlockNoteViewContext.ts b/packages/react/src/editor/BlockNoteViewContext.ts index 2b5b7413c8..e4ef5f929d 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -1,6 +1,21 @@ -import { createContext, useContext } from "react"; +import { createContext, CSSProperties, useContext } from "react"; import { BlockNoteDefaultUIProps } from "./BlockNoteDefaultUI.js"; +/** + * Attributes a UI-library wrapper needs on every themed BlockNote root + * element, beyond what the base layer applies: its color-scheme data + * attributes and any theme CSS variables. Passed to `BlockNoteViewRaw` via the + * `themedRootProps` prop; the base layer merges them into + * {@link BlockNoteViewContextValue.portalRootProps} without knowing which + * attributes each library uses. + */ +export type ThemedRootProps = { + /** Intended for theme CSS variables (custom properties). */ + style?: CSSProperties; +} & { + [attribute: `data-${string}`]: string | undefined; +}; + export type BlockNoteViewContextValue = { editorProps: { autoFocus?: boolean; @@ -15,6 +30,17 @@ export type BlockNoteViewContextValue = { portalTarget?: HTMLElement | null; }; defaultUIProps: BlockNoteDefaultUIProps; + /** + * Props that turn an element into a themed `.bn-root`: the classes and + * color-scheme attribute existing CSS keys off, plus the UI-library extras + * from {@link ThemedRootProps}. Rendered on the editor container, applied to + * `editor.portalElement`, and used by `PortalTarget` to theme the portal + * roots it creates — all from the same data. + */ + portalRootProps: ThemedRootProps & { + className: string; + "data-color-scheme": "light" | "dark"; + }; }; export const BlockNoteViewContext = createContext< diff --git a/packages/react/src/editor/PortalContext.ts b/packages/react/src/editor/PortalContext.ts deleted file mode 100644 index 3cfe0dcbad..0000000000 --- a/packages/react/src/editor/PortalContext.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createContext, useContext } from "react"; - -/** - * The default DOM node that the editor's floating UI (toolbars, menus, - * popovers, table handles, etc.) portals into — used instead of reaching for - * `editor.portalElement` directly. - * - * Provided at two levels: - * 1. `BlockNoteView` provides `editor.portalElement` as the default. - * 2. The mobile formatting toolbar overrides it for its own subtree (its - * body-level container) so its dropdowns portal alongside it. - * - * Per-element (`portalElements` map) and manual overrides instead flow through - * the `portalElement` prop on the controllers / popovers, which `GenericPopover` - * resolves against this context (`portalElement ?? context`) and re-provides to - * its subtree. - * - * `null` means "no portal target available" (e.g. during SSR); consumers that - * require one should throw. - */ -export const PortalContext = createContext(null); - -export function usePortalContext(): HTMLElement | null { - return useContext(PortalContext); -} diff --git a/packages/react/src/editor/PortalTarget.tsx b/packages/react/src/editor/PortalTarget.tsx new file mode 100644 index 0000000000..03bbf9080b --- /dev/null +++ b/packages/react/src/editor/PortalTarget.tsx @@ -0,0 +1,125 @@ +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; + +/** + * The DOM node that the editor's floating UI (toolbars, menus, popovers, table + * handles, etc.) portals into. + * + * Always holds a *resolved* root — themed, registered with the editor — + * because {@link PortalTarget} is the only provider (the raw context is not + * part of the public API): `BlockNoteView` provides the editor-wide default + * (resolved from `editor.portalElement`), and controllers with a + * `portalElement` prop or the mobile formatting toolbar override it for their + * subtrees. Consumers can use the value without further checks. + * + * `null` means "no portal target (yet)" — during SSR and for the frame before + * resolution; consumers should render nothing until it resolves. + */ +export const PortalContext = createContext(null); + +export function usePortalContext(): HTMLElement | null { + return useContext(PortalContext); +} + +/** + * Designates a portal target for BlockNote's floating UI, guaranteeing that + * anything portalled to it lands inside a themed `.bn-root` and is recognized + * as part of the editor. This is the only way a target enters the system: + * `PortalContext` (read via `usePortalContext`) always holds a root that + * passed through here, so consumers can use it without further checks. + * + * `target` semantics: + * - `undefined` — no override; children render as-is and inherit the ambient + * portal target. + * - `null` — explicit `document.body`. + * - `HTMLElement` — used as-is. + * + * For a set target, children portal into it. If the target already sits + * inside a `.bn-root` subtree (e.g. `editor.portalElement` in its default + * position inside `bn-container`), it's used directly and theming comes from + * the ancestor. Otherwise — for targets outside any themed subtree, e.g. + * `document.body` — a `.bn-root` div is rendered inside it and used instead, + * themed by the same `portalRootProps` descriptor as the editor container + * (see `BlockNoteViewContext`), so both update in the same commit. + * + * The resolved root is provided via {@link PortalContext}, reported through + * `onResolve` (for a parent that needs the value outside this subtree, like + * `BlockNoteView` providing the editor-wide default), and registered with the + * editor so `editor.isWithinEditor` counts the portalled UI as inside the + * editor. Nothing renders for the frame(s) before the target is classified + * and (when created) the root div is committed. + */ +export function PortalTarget(props: { + target?: HTMLElement | null; + children?: ReactNode; + onResolve?: (resolved: HTMLElement | null) => void; +}) { + const { target: targetProp, children, onResolve } = props; + + const editor = useBlockNoteEditor(); + const portalRootProps = useBlockNoteViewContext()?.portalRootProps; + + const target = + targetProp === null + ? typeof document !== "undefined" + ? document.body + : undefined + : targetProp; + + // Whether `target` needs a themed `.bn-root` div, classified in an effect + // rather than during render: on a first render the target may not be in the + // DOM yet (`editor.portalElement` is appended to `bn-container` during the + // mount commit), so `closest` would misclassify it. `null` = not yet + // classified. + const [needsRoot, setNeedsRoot] = useState(null); + useEffect(() => { + if (!target) { + setNeedsRoot(null); + return; + } + setNeedsRoot(!target.closest(".bn-root")); + }, [target]); + + const [root, setRoot] = useState(null); + const resolved = + !target || needsRoot === null ? null : needsRoot ? root : target; + + useEffect(() => { + onResolve?.(resolved); + }, [onResolve, resolved]); + + useEffect(() => { + if (!resolved) { + return; + } + return editor.registerPortalRoot(resolved); + }, [editor, resolved]); + + if (targetProp === undefined) { + return children; + } + + if (!target || needsRoot === null) { + return null; + } + + return createPortal( + needsRoot ? ( +
+ {children} +
+ ) : ( + {children} + ), + target, + ); +} diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index d4b4f93205..4a9f4c64c5 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -5,7 +5,7 @@ * - `string` — treated as a CSS selector and resolved via `document.querySelector`. * - `null` — explicit `document.body` (escape any ancestor stacking context). */ -export type PortalTarget = HTMLElement | string | null; +export type PortalElement = HTMLElement | string | null; /** * Per-element portal targets for BlockNote's floating UI. Keys mirror the @@ -17,22 +17,22 @@ export type PortalTarget = HTMLElement | string | null; * `bn-container` element is used. */ export type PortalElementsMap = { - default?: PortalTarget; - formattingToolbar?: PortalTarget; - linkToolbar?: PortalTarget; - slashMenu?: PortalTarget; - emojiPicker?: PortalTarget; - sideMenu?: PortalTarget; - filePanel?: PortalTarget; - tableHandles?: PortalTarget; - comments?: PortalTarget; - attributionTooltip?: PortalTarget; + default?: PortalElement; + formattingToolbar?: PortalElement; + linkToolbar?: PortalElement; + slashMenu?: PortalElement; + emojiPicker?: PortalElement; + sideMenu?: PortalElement; + filePanel?: PortalElement; + tableHandles?: PortalElement; + comments?: PortalElement; + attributionTooltip?: PortalElement; }; export type PortalElementKey = Exclude; export function resolvePortalTarget( - target: PortalTarget | undefined, + target: PortalElement | undefined, ): HTMLElement | undefined { if (target === undefined) { return undefined; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8c5d281379..6da7c57b18 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,7 +45,10 @@ export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/DesktopFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; -export * from "./editor/PortalContext.js"; +// Only the read side of `PortalContext` is public: `PortalTarget` is the sole +// way to provide a portal target, which guarantees that every provided value +// is a resolved (themed + registered) root. +export { PortalTarget, usePortalContext } from "./editor/PortalTarget.js"; export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index a6417a8b27..ffd7fa4ac0 100644 --- a/packages/shadcn/src/badge/Badge.tsx +++ b/packages/shadcn/src/badge/Badge.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps, usePortalContext } from "@blocknote/react"; import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; @@ -25,9 +25,10 @@ export const Badge = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - // Portal the tooltip into the editor's portal element so it inherits the - // editor's light/dark color scheme instead of the document body's. - const editor = useBlockNoteEditor(); + // Portal the tooltip into the ambient portal target (a themed `.bn-root`) + // so it inherits the editor's light/dark color scheme instead of the + // document body's. + const contextPortal = usePortalContext(); const badge = ( {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 47114a79c5..309e476a99 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps, usePortalContext } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -82,10 +82,10 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; const portalRoot = useContext(PortalRootContext); - // 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 = portalRoot ?? editor.portalElement; + // Default to the ambient portal target (a themed `.bn-root`) so the menu + // inherits light/dark mode instead of the document body's. + const contextPortal = usePortalContext(); + const container = portalRoot ?? contextPortal ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 1ccb01243f..f6f8aecd9d 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps, usePortalContext } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -62,15 +62,15 @@ export const PopoverContent = forwardRef< 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, - // and escape the mobile formatting toolbar's horizontal scroll clip. - const editor = useBlockNoteEditor(); + // Default to the ambient portal target (a themed `.bn-root`) so popovers + // inherit light/dark mode instead of the document body's, and escape the + // mobile formatting toolbar's horizontal scroll clip. + const contextPortal = usePortalContext(); return ( ( const ShadCNComponents = useShadCNComponentsContext()!; - // Portal the tooltip into the editor's portal element so it inherits the - // editor's light/dark color scheme instead of the document body's. - const editor = useBlockNoteEditor(); + // Portal the tooltip into the ambient portal target (a themed `.bn-root`) + // so it inherits the editor's light/dark color scheme instead of the + // document body's. + const contextPortal = usePortalContext(); const trigger = isSelected === undefined ? ( @@ -111,7 +112,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -132,9 +133,9 @@ export const ToolbarSelect = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - // 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(); + // Default to the ambient portal target (a themed `.bn-root`) so the dropdown + // inherits light/dark mode instead of the body's. + const contextPortal = usePortalContext(); // TODO? const SelectItemContent = (props: any) => ( @@ -163,7 +164,7 @@ export const ToolbarSelect = forwardRef< Date: Thu, 3 Sep 2026 23:59:28 +0200 Subject: [PATCH 03/21] Refactored `PortalContext` to `PortalTarget` --- docs/content/docs/react/components/index.mdx | 4 +- packages/core/src/editor/BlockNoteEditor.ts | 92 ++++------ packages/mantine/src/BlockNoteView.tsx | 8 +- .../AttributionTooltipController.tsx | 2 +- .../src/components/Comments/EmojiPicker.tsx | 9 +- .../Comments/FloatingComposerController.tsx | 2 +- .../Comments/FloatingThreadController.tsx | 2 +- .../FilePanel/FilePanelController.tsx | 2 +- .../DesktopFormattingToolbarController.tsx | 2 +- .../FormattingToolbarController.tsx | 2 +- .../MobileFormattingToolbarController.tsx | 48 +++--- .../LinkToolbar/LinkToolbarController.tsx | 2 +- .../SideMenu/SideMenuController.tsx | 2 +- .../GridSuggestionMenuController.tsx | 2 +- .../SuggestionMenuController.tsx | 2 +- .../TableHandles/TableHandlesController.tsx | 2 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 5 +- packages/react/src/editor/BlockNoteView.tsx | 99 ++++------- .../react/src/editor/BlockNoteViewContext.ts | 13 +- packages/react/src/editor/PortalTarget.tsx | 161 ++++++++---------- packages/react/src/editor/UIModeContext.ts | 4 +- packages/react/src/editor/portalElements.ts | 7 +- packages/react/src/index.ts | 3 - 23 files changed, 196 insertions(+), 279 deletions(-) diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index 1b6e18d3c0..153e79b43a 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -31,6 +31,6 @@ By default, all floating UI elements (toolbars, menus, table handles, etc.) port /> ``` -Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu`, `emojiPicker`, `sideMenu`, `filePanel`, `tableHandles`, `comments`). Manually-mounted Controllers also accept a `portalElement` prop that takes precedence over the map. See the [Portal Targets example](/examples/ui-components/portal-elements). +Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu`, `emojiPicker`, `sideMenu`, `filePanel`, `tableHandles`, `comments`). Manually-mounted Controllers also accept a `portalElement` prop that takes precedence over the map. All keys, including `default`, update reactively. See the [Portal Targets example](/examples/ui-components/portal-elements). -Note: changing `portalElements.default` after mount requires remounting the editor (`editor.mount()` consults it once); per-element keys update reactively. +When a target sits outside the editor's DOM (like `document.body`), BlockNote automatically renders a themed wrapper element inside it, so floating UI keeps the editor's styling and theming wherever it's portalled. diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 809913929c..9ee4668dad 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -719,26 +719,25 @@ export class BlockNoteEditor< * Mount the editor to a DOM element. * * @param element The DOM element to mount the editor's contenteditable into. - * @param options.portalTarget Where to mount `editor.portalElement` — the - * container that floating UI (toolbars, menus, etc) portals into. When - * omitted, defaults to `element.parentElement` (which is the editor's - * `bn-container` in typical React usage), or to `document.body` / - * the surrounding shadow root when no parent is available. + * @param options.portalTarget An element to register as a portal root — a + * convenience for {@link registerPortalRoot}, for non-React setups that + * render the editor's floating UI outside the editor's DOM tree, so + * {@link isWithinEditor} recognizes it. An ordinary registration like any + * other: release it with {@link unregisterPortalRoot} if ever needed. Not + * needed for UI rendered next to the contenteditable (the mount element's + * parent already counts as within the editor). Prefer a dedicated + * container over e.g. `document.body`, which would make the whole page + * count. * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ public mount = ( element: HTMLElement, - options?: { portalTarget?: HTMLElement | null }, + options?: { portalTarget?: HTMLElement }, ) => { - const root = element.getRootNode(); - const isInShadowRoot = - typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot; - const target = - options?.portalTarget ?? - element.parentElement ?? - (isInShadowRoot ? (root as ShadowRoot) : document.body); - target.appendChild(this.portalElement); + if (options?.portalTarget) { + this.registerPortalRoot(options.portalTarget); + } this._tiptapEditor.mount({ mount: element }); }; @@ -746,7 +745,6 @@ export class BlockNoteEditor< * Unmount the editor from the DOM element it is bound to */ public unmount = () => { - this.portalElement?.remove(); this._tiptapEditor.unmount(); }; @@ -774,25 +772,6 @@ export class BlockNoteEditor< return this.prosemirrorView?.dom as HTMLDivElement | undefined; } - private _portalElement: HTMLElement | undefined; - - /** - * The portal container element at `document.body` used by floating UI - * elements (menus, toolbars) to escape overflow:hidden ancestors. - * Set by BlockNoteView; undefined in headless mode. - */ - public get portalElement() { - if (typeof document === "undefined") { - throw new Error( - "Portal element accessed, but not available in headless mode", - ); - } - if (!this._portalElement) { - this._portalElement = document.createElement("div"); - } - return this._portalElement; - } - // Portal roots registered by the view layer, with reference counts so // multiple UI elements can share a root (e.g. several popovers portalling // into the same custom target). @@ -802,40 +781,41 @@ export class BlockNoteEditor< * Registers an element as a portal root for this editor's floating UI, so * {@link isWithinEditor} treats its contents as part of the editor. The view * layer calls this for each portal target it designates (see - * `usePortalTarget` in `@blocknote/react`) — without it, UI portalled to a - * custom `portalElements` target would be considered outside the editor. - * Returns a function that releases the registration. + * `PortalTarget` in `@blocknote/react`) — without it, UI portalled outside + * the editor's DOM tree would be considered outside the editor. + * Registrations are reference-counted; release with + * {@link unregisterPortalRoot}. */ - public registerPortalRoot = (element: HTMLElement): (() => void) => { + public registerPortalRoot = (element: HTMLElement) => { this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1); + }; - let released = false; - return () => { - if (released) { - return; - } - released = true; + /** + * Releases a registration made with {@link registerPortalRoot}. The element + * stops counting as part of the editor once every registration for it has + * been released. + */ + public unregisterPortalRoot = (element: HTMLElement) => { + const count = this._portalRoots.get(element); + if (count === undefined) { + return; + } - const count = this._portalRoots.get(element) ?? 0; - if (count <= 1) { - this._portalRoots.delete(element); - } else { - this._portalRoots.set(element, count - 1); - } - }; + if (count <= 1) { + this._portalRoots.delete(element); + } else { + this._portalRoots.set(element, count - 1); + } }; /** * Checks whether a DOM element belongs to this editor — inside the editor's - * DOM tree, its default portal container, or any portal root registered via + * DOM tree, or inside any portal root registered via * {@link registerPortalRoot} (used for floating UI elements like menus and * toolbars, which may portal outside the editor's DOM tree). */ public isWithinEditor = (element: Element): boolean => { - if ( - this.domElement?.parentElement?.contains(element) || - this.portalElement?.contains(element) - ) { + if (this.domElement?.parentElement?.contains(element)) { return true; } diff --git a/packages/mantine/src/BlockNoteView.tsx b/packages/mantine/src/BlockNoteView.tsx index 9b82e89787..a6b57bb3c4 100644 --- a/packages/mantine/src/BlockNoteView.tsx +++ b/packages/mantine/src/BlockNoteView.tsx @@ -49,10 +49,10 @@ export const BlockNoteView = < : "light"; // Mantine's theming for BlockNote's themed root elements (the editor - // container, `editor.portalElement`, portal roots): the color-scheme - // attribute the stylesheet keys off, plus CSS variables for custom object - // themes. `BlockNoteViewRaw` applies these to every root, so they all update - // in the same commit. + // container and any portal roots): the color-scheme attribute the + // stylesheet keys off, plus CSS variables for custom object themes. + // `BlockNoteViewRaw` applies these to every root, so they all update in the + // same commit. const themedRootProps = useMemo(() => { const themeCSSVariables = typeof theme !== "object" diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx index 13016f2d07..71ec3ccf9c 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx @@ -32,7 +32,7 @@ export const AttributionTooltipController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` when omitted. + * the ambient portal target when omitted. */ portalElement?: HTMLElement | null; }) => { diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 1468f46e2d..320f175bd0 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -16,12 +16,11 @@ export const EmojiPicker = (props: { const blockNoteContext = useBlockNoteContext()!; const portalRoot = usePortalContext(); - if (!portalRoot) { - throw new Error("Portal root not found"); - } - return ( - +
{ diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 6f09e834b7..7eb3bbf711 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -31,7 +31,7 @@ export default function FloatingComposerController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index ae363e3962..b67d0b2e22 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -23,7 +23,7 @@ export default function FloatingThreadController(props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index 6a9d111e7d..fb2e4e5831 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -15,7 +15,7 @@ export const FilePanelController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index 685cbb304a..f92a2b115d 100644 --- a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx @@ -39,7 +39,7 @@ export const DesktopFormattingToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index 1045043e14..6266ed998f 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -12,7 +12,7 @@ export const FormattingToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index 27e49e5e19..dd70a5de3f 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,6 +1,7 @@ import { FC, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { PortalTarget, usePortalContext } from "../../editor/PortalTarget.js"; import { UIModeContext } from "../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; @@ -22,11 +23,10 @@ import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; * iOS that container's `-webkit-overflow-scrolling` stacking context paints the * `position: fixed` toolbar behind page content like footers; rendering at the * body level avoids that. It provides a `"mobile"` {@link UIModeContext} so its - * buttons know to portal their dropdowns (into the body-level - * {@link PortalContext} target, escaping the editor container's overflow) and to - * suppress moving focus into them, which would blur the editor and dismiss the - * keyboard. React context (editor, components, theme provider) still flows - * through the portal. + * buttons know to portal their dropdowns (into the body-level portal target, + * escaping the editor container's overflow) and to suppress moving focus into + * them, which would blur the editor and dismiss the keyboard. React context + * (editor, components, theme provider) still flows through the portal. * * Shown while the virtual keyboard is open and this editor holds focus. The * focus check is essential when multiple editors share a page: the virtual @@ -80,32 +80,30 @@ export const MobileFormattingToolbarController = (props: { } return ( - + + + + + ); }; -/** - * The visible part of the mobile toolbar, rendered at the body level. Marks its - * subtree as a `"mobile"` UI surface. Because `document.body` is outside the - * editor's themed subtree, {@link PortalTarget} renders a themed `.bn-root` div - * inside it (registered with the editor) that the toolbar renders into, and - * provides it via `PortalContext` so the toolbar's dropdowns portal alongside - * it. See the controller docstring. - */ function MobileFormattingToolbar(props: { formattingToolbar: FC; }) { + const root = usePortalContext(); const Component = props.formattingToolbar; - return ( - - -
- -
-
-
+ if (!root) { + return null; + } + + return createPortal( +
+ +
, + root, ); } diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index bad4659825..507ddce580 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -20,7 +20,7 @@ export const LinkToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index b46d125bb3..a6782328c5 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -62,7 +62,7 @@ export const SideMenuController = (props: { floatingUIOptions?: Partial; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 14351c0971..b3555fce64 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -47,7 +47,7 @@ export function GridSuggestionMenuController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index dbaf8232e7..1a8bb424bd 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -41,7 +41,7 @@ export function SuggestionMenuController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 1dcb85a3d9..6fbc2e94ea 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -34,7 +34,7 @@ export const TableHandlesController = < extendButton?: FC; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ portalElement?: HTMLElement | null; diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 75d618dc71..472904b052 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -87,11 +87,8 @@ export type BlockNoteDefaultUIProps = { * Per-element portal targets for floating UI. Each key corresponds to one * of the default UI elements; values can be an `HTMLElement`, a CSS * selector string, or `null` (= `document.body`). The optional `default` - * key controls where `editor.portalElement` itself is mounted; when + * key sets the target for every element without its own entry; when * omitted, the editor's `bn-container` element is used. - * - * Per-element keys override `default` for that one element. Unspecified - * elements fall back to `default` via `editor.portalElement`. */ portalElements?: PortalElementsMap; }; diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 210be2ec94..cb8f00fb17 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -15,6 +15,7 @@ import React, { } from "react"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; import { useEditorChange } from "../hooks/useEditorChange.js"; +import { useEditorDOMElement } from "../hooks/useEditorDomElement.js"; import { useEditorSelectionChange } from "../hooks/useEditorSelectionChange.js"; import { usePrefersColorScheme } from "../hooks/usePrefersColorScheme.js"; import { @@ -26,7 +27,7 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { PortalContext, PortalTarget } from "./PortalTarget.js"; +import { PortalTarget } from "./PortalTarget.js"; import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, @@ -93,11 +94,9 @@ export type BlockNoteViewProps< children?: ReactNode; /** - * Attributes to apply to every themed BlockNote root element — the editor - * container, `editor.portalElement`, and any portal roots created by - * `PortalTarget`. UI-library wrappers use this to carry their theming - * (color-scheme data attributes, theme CSS variables) to floating UI - * portalled outside the editor's DOM. + * Attributes to apply to every themed BlockNote root element. UI-library + * wrappers use this to carry their theming (color-scheme data attributes, + * theme CSS variables) to floating UI portalled outside the editor's DOM. */ themedRootProps?: ThemedRootProps; @@ -143,23 +142,14 @@ function BlockNoteViewComponent< ...rest } = props; - // Resolved once and handed to `editor.mount()` via context. When omitted, - // `mount()` falls back to `element.parentElement` (i.e. `bn-container`). - // Changing this prop requires remounting the editor (use a `key`). - const portalTarget = useMemo( - () => resolvePortalTarget(portalElements?.default) ?? null, - [portalElements?.default], - ); - - // The default portal root for all floating UI: `editor.portalElement`, run - // through `PortalTarget` (a childless instance below reports it via - // `onResolve`) so that `PortalContext` always holds a resolved, themed, - // registered root — consumers use it without further checks. Per-element - // `portalElements` and the mobile toolbar override it for their subtrees - // with their own `PortalTarget`s. `null` during SSR and for the first - // frame, before resolution. - const [defaultPortalRoot, setDefaultPortalRoot] = - useState(null); + const editorDOMElement = useEditorDOMElement(editor); + const portalTarget = + useMemo( + () => resolvePortalTarget(portalElements?.default), + [portalElements?.default], + ) ?? + editorDOMElement?.parentElement ?? + undefined; // Used so other components (suggestion menu) can set // aria related props to the contenteditable div @@ -212,15 +202,6 @@ function BlockNoteViewComponent< [editor], ); - // Props that turn an element into a themed `.bn-root`. Rendered onto the - // container and provided via `BlockNoteViewContext` so `PortalTarget` can - // render portal roots with the same theming — all from the same data, - // updating in the same commit. UI-library extras come in via the - // `themedRootProps` prop, so no library-specific knowledge is needed here. - // Note that `editor.portalElement` itself stays bare: it's a location - // anchor, and floating UI portalled into it is themed either by a `.bn-root` - // ancestor (the default `bn-container` mount) or by a `PortalTarget`-created - // root inside it. const portalRootProps = useMemo( () => ({ ...themedRootProps, @@ -249,7 +230,6 @@ function BlockNoteViewComponent< autoFocus, contentEditableProps, editable, - portalTarget, }, defaultUIProps, portalRootProps, @@ -259,32 +239,24 @@ function BlockNoteViewComponent< contentEditableProps, editable, defaultUIProps, - portalTarget, portalRootProps, ]); return ( - - - - - {children} - - + + + {children} + ); @@ -300,6 +272,7 @@ const BlockNoteViewContainer = React.forwardRef< renderEditor: boolean; editorColorScheme: "light" | "dark"; themedRootProps?: ThemedRootProps; + portalTarget?: HTMLElement; children: ReactNode; } & Omit< HTMLAttributes, @@ -312,6 +285,7 @@ const BlockNoteViewContainer = React.forwardRef< renderEditor, editorColorScheme, themedRootProps, + portalTarget, children, style, ...rest @@ -331,11 +305,13 @@ const BlockNoteViewContainer = React.forwardRef< {...rest} ref={ref} > - {renderEditor ? ( - {children} - ) : ( - children - )} + + {renderEditor ? ( + {children} + ) : ( + children + )} +
), ); @@ -366,8 +342,6 @@ export const BlockNoteViewEditor = (props: { children?: ReactNode }) => { return getContentComponent(); }, []); - const portalTarget = ctx.editorProps.portalTarget; - const mount = useCallback( (element: HTMLElement | null) => { // Set editable state of the actual editor. @@ -380,12 +354,12 @@ export const BlockNoteViewEditor = (props: { children?: ReactNode }) => { // This is a simple replacement for the state management that Tiptap does internally editor._tiptapEditor.contentComponent = portalManager; if (element) { - editor.mount(element, { portalTarget }); + editor.mount(element); } else { editor.unmount(); } }, - [ctx.editorProps.editable, editor, portalManager, portalTarget], + [ctx.editorProps.editable, editor, portalManager], ); return ( @@ -407,7 +381,6 @@ const ContentEditableElement = (props: { autoFocus?: boolean; mount: (element: HTMLElement | null) => void; contentEditableProps?: Record; - portalTarget?: HTMLElement | null; }) => { const { autoFocus, mount, contentEditableProps } = props; return ( diff --git a/packages/react/src/editor/BlockNoteViewContext.ts b/packages/react/src/editor/BlockNoteViewContext.ts index e4ef5f929d..003fd2722e 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -21,21 +21,14 @@ export type BlockNoteViewContextValue = { autoFocus?: boolean; contentEditableProps?: Record; editable?: boolean; - /** - * Resolved portal target for `editor.portalElement` — passed to - * `editor.mount()`. Comes from `portalElements.default` on - * `BlockNoteView`. `undefined` lets `mount()` use its default - * (`element.parentElement`, i.e. `bn-container`). - */ - portalTarget?: HTMLElement | null; }; defaultUIProps: BlockNoteDefaultUIProps; /** * Props that turn an element into a themed `.bn-root`: the classes and * color-scheme attribute existing CSS keys off, plus the UI-library extras - * from {@link ThemedRootProps}. Rendered on the editor container, applied to - * `editor.portalElement`, and used by `PortalTarget` to theme the portal - * roots it creates — all from the same data. + * from {@link ThemedRootProps}. Rendered on the editor container and used + * by `PortalTarget` to theme the portal roots it creates — all from the + * same data. */ portalRootProps: ThemedRootProps & { className: string; diff --git a/packages/react/src/editor/PortalTarget.tsx b/packages/react/src/editor/PortalTarget.tsx index 03bbf9080b..01b8022fa4 100644 --- a/packages/react/src/editor/PortalTarget.tsx +++ b/packages/react/src/editor/PortalTarget.tsx @@ -10,116 +10,97 @@ import { createPortal } from "react-dom"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; -/** - * The DOM node that the editor's floating UI (toolbars, menus, popovers, table - * handles, etc.) portals into. - * - * Always holds a *resolved* root — themed, registered with the editor — - * because {@link PortalTarget} is the only provider (the raw context is not - * part of the public API): `BlockNoteView` provides the editor-wide default - * (resolved from `editor.portalElement`), and controllers with a - * `portalElement` prop or the mobile formatting toolbar override it for their - * subtrees. Consumers can use the value without further checks. - * - * `null` means "no portal target (yet)" — during SSR and for the frame before - * resolution; consumers should render nothing until it resolves. - */ -export const PortalContext = createContext(null); +const PortalContext = createContext(null); export function usePortalContext(): HTMLElement | null { return useContext(PortalContext); } -/** - * Designates a portal target for BlockNote's floating UI, guaranteeing that - * anything portalled to it lands inside a themed `.bn-root` and is recognized - * as part of the editor. This is the only way a target enters the system: - * `PortalContext` (read via `usePortalContext`) always holds a root that - * passed through here, so consumers can use it without further checks. - * - * `target` semantics: - * - `undefined` — no override; children render as-is and inherit the ambient - * portal target. - * - `null` — explicit `document.body`. - * - `HTMLElement` — used as-is. - * - * For a set target, children portal into it. If the target already sits - * inside a `.bn-root` subtree (e.g. `editor.portalElement` in its default - * position inside `bn-container`), it's used directly and theming comes from - * the ancestor. Otherwise — for targets outside any themed subtree, e.g. - * `document.body` — a `.bn-root` div is rendered inside it and used instead, - * themed by the same `portalRootProps` descriptor as the editor container - * (see `BlockNoteViewContext`), so both update in the same commit. - * - * The resolved root is provided via {@link PortalContext}, reported through - * `onResolve` (for a parent that needs the value outside this subtree, like - * `BlockNoteView` providing the editor-wide default), and registered with the - * editor so `editor.isWithinEditor` counts the portalled UI as inside the - * editor. Nothing renders for the frame(s) before the target is classified - * and (when created) the root div is committed. - */ -export function PortalTarget(props: { - target?: HTMLElement | null; - children?: ReactNode; - onResolve?: (resolved: HTMLElement | null) => void; -}) { - const { target: targetProp, children, onResolve } = props; - +// Registers a portal root on mount and deregisters it on unmount. +function useRegisterPortalRoot(root: HTMLElement | null) { const editor = useBlockNoteEditor(); - const portalRootProps = useBlockNoteViewContext()?.portalRootProps; - const target = - targetProp === null - ? typeof document !== "undefined" - ? document.body - : undefined - : targetProp; - - // Whether `target` needs a themed `.bn-root` div, classified in an effect - // rather than during render: on a first render the target may not be in the - // DOM yet (`editor.portalElement` is appended to `bn-container` during the - // mount commit), so `closest` would misclassify it. `null` = not yet - // classified. - const [needsRoot, setNeedsRoot] = useState(null); useEffect(() => { - if (!target) { - setNeedsRoot(null); + if (!root) { return; } - setNeedsRoot(!target.closest(".bn-root")); - }, [target]); - const [root, setRoot] = useState(null); - const resolved = - !target || needsRoot === null ? null : needsRoot ? root : target; + editor.registerPortalRoot(root); + return () => { + editor.unregisterPortalRoot(root); + }; + }, [editor, root]); +} + +// Given a target element, checks whether a `.bn-root` element is somewhere up the DOM tree, as +// one is necessary to apply correct theming & styling. If one doesn't exist, creates one and +// returns it, both as a React node and HTML element. Otherwise, just returns the target element or +// null if the target is undefined. +function usePortalRoot(target: HTMLElement | undefined): { + root: HTMLElement | null; + themingContainer: ReactNode; +} { + const rootProps = useBlockNoteViewContext()?.portalRootProps; - useEffect(() => { - onResolve?.(resolved); - }, [onResolve, resolved]); + const [needsContainer, setNeedsContainer] = useState<{ + target: HTMLElement; + value: boolean; + }>(); + const [containerElement, setContainerElement] = useState( + null, + ); useEffect(() => { - if (!resolved) { - return; + if (target) { + setNeedsContainer({ target, value: !target.closest(".bn-root") }); } - return editor.registerPortalRoot(resolved); - }, [editor, resolved]); + }, [target]); - if (targetProp === undefined) { - return children; + if (!target || needsContainer?.target !== target) { + return { root: null, themingContainer: null }; } - if (!target || needsRoot === null) { - return null; + if (!needsContainer.value) { + return { root: target, themingContainer: null }; } - return createPortal( - needsRoot ? ( -
- {children} -
- ) : ( - {children} + return { + root: containerElement, + themingContainer: createPortal( +
, + target, ), - target, + }; +} + +// Exposes a target portal element for consumers of `PortalContext` to consume. If the target +// element has no `.bn-root` element in its ancestors, so that styles & theming are properly +// applied to the element's descendants, one is created. +export function PortalTarget(props: { + target?: HTMLElement | null; + children?: ReactNode; +}) { + const { target, children } = props; + + const resolvedTarget = + target === null + ? typeof document !== "undefined" + ? document.body + : undefined + : target; + + const { root, themingContainer } = usePortalRoot(resolvedTarget); + + useRegisterPortalRoot(root); + + if (target === undefined) { + return children; + } + + return ( + <> + {children} + {themingContainer} + ); } diff --git a/packages/react/src/editor/UIModeContext.ts b/packages/react/src/editor/UIModeContext.ts index 15f1fe2ff1..d64f31c61a 100644 --- a/packages/react/src/editor/UIModeContext.ts +++ b/packages/react/src/editor/UIModeContext.ts @@ -8,8 +8,8 @@ import { createContext, useContext } from "react"; * `MobileFormattingToolbarController`, whose toolbar is pinned above the * on-screen keyboard and lives outside the editor's DOM subtree. Toolbar * buttons read this to decide whether to portal their dropdowns (into the - * {@link PortalContext} target) and to suppress moving focus into them — which - * on desktop would break keyboard nav, and on mobile would blur the editor's + * ambient portal target) and to suppress moving focus into them — which on + * desktop would break keyboard nav, and on mobile would blur the editor's * contentEditable and dismiss the keyboard. */ export type UIMode = "desktop" | "mobile"; diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index 4a9f4c64c5..464c34dc98 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -11,10 +11,9 @@ export type PortalElement = HTMLElement | string | null; * Per-element portal targets for BlockNote's floating UI. Keys mirror the * default UI element flags on `BlockNoteView`. * - * `default` is the fallback used for any element whose key is omitted, and is - * also where `editor.portalElement` itself is mounted. Elements that omit a - * specific entry inherit `default`; if `default` is also omitted, the editor's - * `bn-container` element is used. + * `default` is the fallback used for any element whose key is omitted. If + * `default` is also omitted, floating UI portals into the editor's + * `bn-container` element. */ export type PortalElementsMap = { default?: PortalElement; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6da7c57b18..a92dc2d005 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,9 +45,6 @@ export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/DesktopFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; -// Only the read side of `PortalContext` is public: `PortalTarget` is the sole -// way to provide a portal target, which guarantees that every provided value -// is a resolved (themed + registered) root. export { PortalTarget, usePortalContext } from "./editor/PortalTarget.js"; export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; From 3c9c0b68870a8805582c6b6fdd36b54529db6cf9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 4 Sep 2026 10:51:13 +0200 Subject: [PATCH 04/21] Fixed build --- .../11-uppy-file-panel/src/FileReplaceButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index d7604cbdbe..0f5df1a919 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - useMobileToolbarPortal, + usePortalContext, useSelectedBlocks, } from "@blocknote/react"; import { useCallback, useEffect, useState } from "react"; @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const mobileToolbarPortal = useMobileToolbarPortal(); + const mobileToolbarPortal = usePortalContext(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={mobileToolbarPortal} > Date: Fri, 4 Sep 2026 12:45:40 +0200 Subject: [PATCH 05/21] Updated naming --- packages/core/src/editor/BlockNoteEditor.ts | 2 +- .../AttributionTooltipController.tsx | 6 +++--- .../src/components/Comments/EmojiPicker.tsx | 6 +++--- .../Comments/FloatingComposerController.tsx | 6 +++--- .../Comments/FloatingThreadController.tsx | 6 +++--- .../FilePanel/FilePanelController.tsx | 6 +++--- .../DefaultButtons/ColorStyleButton.tsx | 6 +++--- .../DefaultButtons/CreateLinkButton.tsx | 6 +++--- .../DefaultButtons/FileCaptionButton.tsx | 6 +++--- .../DefaultButtons/FileRenameButton.tsx | 6 +++--- .../DefaultButtons/FileReplaceButton.tsx | 6 +++--- .../DefaultSelects/BlockTypeSelect.tsx | 6 +++--- .../DesktopFormattingToolbarController.tsx | 6 +++--- .../MobileFormattingToolbarController.tsx | 11 +++++++---- .../LinkToolbar/LinkToolbarController.tsx | 6 +++--- .../src/components/Popovers/GenericPopover.tsx | 14 +++++++------- .../components/SideMenu/SideMenuController.tsx | 6 +++--- .../GridSuggestionMenuController.tsx | 6 +++--- .../SuggestionMenuController.tsx | 6 +++--- .../TableHandles/TableHandlesController.tsx | 6 +++--- packages/react/src/editor/BlockNoteView.tsx | 14 +++++++------- .../react/src/editor/BlockNoteViewContext.ts | 2 +- ...rtalTarget.tsx => EditorPortalProvider.tsx} | 18 ++++++++++-------- packages/react/src/index.ts | 5 ++++- packages/shadcn/src/badge/Badge.tsx | 6 +++--- packages/shadcn/src/menu/Menu.tsx | 6 +++--- packages/shadcn/src/popover/popover.tsx | 6 +++--- packages/shadcn/src/toolbar/Toolbar.tsx | 10 +++++----- 28 files changed, 102 insertions(+), 94 deletions(-) rename packages/react/src/editor/{PortalTarget.tsx => EditorPortalProvider.tsx} (80%) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9ee4668dad..7afcf30498 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -781,7 +781,7 @@ export class BlockNoteEditor< * Registers an element as a portal root for this editor's floating UI, so * {@link isWithinEditor} treats its contents as part of the editor. The view * layer calls this for each portal target it designates (see - * `PortalTarget` in `@blocknote/react`) — without it, UI portalled outside + * `EditorPortalProvider` in `@blocknote/react`) — without it, UI portalled outside * the editor's DOM tree would be considered outside the editor. * Registrations are reference-counted; release with * {@link unregisterPortalRoot}. diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx index 71ec3ccf9c..ce5e0bf04d 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx @@ -3,7 +3,7 @@ import { flip, offset, shift, inline } from "@floating-ui/react"; import { FC, useMemo } from "react"; import { useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -115,10 +115,10 @@ export const AttributionTooltipController = (props: { ); return ( - + {tooltipProps && } - + ); }; diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 320f175bd0..641abdfe07 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -2,7 +2,7 @@ import { ReactNode, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -14,12 +14,12 @@ export const EmojiPicker = (props: { const Components = useComponentsContext()!; const blockNoteContext = useBlockNoteContext()!; - const portalRoot = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); return (
+ - + ); } diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index b67d0b2e22..15a2b2c457 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -5,7 +5,7 @@ import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -129,7 +129,7 @@ export default function FloatingThreadController(props: { const Component = props.floatingThread || Thread; return ( - + )} - + ); } diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index fb2e4e5831..c68062a965 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -4,7 +4,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { FilePanel } from "./FilePanel.js"; @@ -61,10 +61,10 @@ export const FilePanelController = (props: { const Component = props.filePanel || FilePanel; return ( - + {blockId && } - + ); }; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index f69bec6bc9..ed3aa38c11 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -7,7 +7,7 @@ import { import { useCallback } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -46,11 +46,11 @@ export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 1356d0e2df..76aa3656fa 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -14,7 +14,7 @@ import { } from "@blocknote/core/extensions"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; @@ -48,11 +48,11 @@ export const CreateLinkButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 9732b6f7e3..7c59fbaeff 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -9,7 +9,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -19,11 +19,11 @@ export const FileCaptionButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 3f5101508c..c4c54e2cc8 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -9,7 +9,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiFontFamily } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -19,11 +19,11 @@ export const FileRenameButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 05f315d459..29f3e566bc 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -7,7 +7,7 @@ import { import { RiImageEditFill } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -18,11 +18,11 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 99cf3afe32..508433f3a1 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -26,7 +26,7 @@ import { ComponentProps, useComponentsContext, } from "../../../editor/ComponentsContext.js"; -import { usePortalContext } from "../../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -130,11 +130,11 @@ export const blockTypeSelectItems = ( export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; const uiMode = useUIMode(); - const portalContext = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. const portalRoot = - uiMode === "mobile" ? (portalContext ?? undefined) : undefined; + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index f92a2b115d..cdb95fd59e 100644 --- a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx @@ -13,7 +13,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../hooks/useEditorState.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; @@ -119,10 +119,10 @@ export const DesktopFormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( - + {show && } - + ); }; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index dd70a5de3f..6b495a9b84 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,7 +1,10 @@ import { FC, useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { PortalTarget, usePortalContext } from "../../editor/PortalTarget.js"; +import { + EditorPortalProvider, + useEditorPortalElement, +} from "../../editor/EditorPortalProvider.js"; import { UIModeContext } from "../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; @@ -80,20 +83,20 @@ export const MobileFormattingToolbarController = (props: { } return ( - + - + ); }; function MobileFormattingToolbar(props: { formattingToolbar: FC; }) { - const root = usePortalContext(); + const root = useEditorPortalElement(); const Component = props.formattingToolbar; if (!root) { diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 507ddce580..c629307f5f 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -6,7 +6,7 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -185,7 +185,7 @@ export const LinkToolbarController = (props: { const Component = props.linkToolbar || LinkToolbar; return ( - + {link && ( )} - + ); }; diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index d1f9755e27..3a5da7c0f8 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,7 +14,7 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; -import { usePortalContext } from "../../editor/PortalTarget.js"; +import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -121,11 +121,11 @@ export const GenericPopover = ( ) => { const editor = useBlockNoteEditor(); // The ambient portal root — always a resolved, themed, registered root, as - // `PortalContext` is only ever provided by `PortalTarget` (the default from + // `EditorPortalContext` is only ever provided by `EditorPortalProvider` (the default from // `BlockNoteView`, or a controller's / the mobile toolbar's override). // `null` during SSR and for the frame before resolution — handled after the // hooks below. - const portalRoot = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); const { whileElementsMounted: _whileElementsMounted, middleware, @@ -216,7 +216,7 @@ export const GenericPopover = ( [status, props.reference, props.children], ); - if (!isMounted || !portalRoot) { + if (!isMounted || !editorPortalElement) { return false; } @@ -245,7 +245,7 @@ export const GenericPopover = ( // should be open. So without this fix, the popover just won't transition // out and will instead appear to hide instantly. return ( - +
+
{props.children} @@ -268,7 +268,7 @@ export const GenericPopover = ( } return ( - +
{props.children}
diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index a6782328c5..16ac66f36b 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -5,7 +5,7 @@ import { FC, useCallback, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; @@ -150,13 +150,13 @@ export const SideMenuController = (props: { const Component = props.sideMenu || SideMenu; return ( - + {block?.id && } - + ); }; diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index b3555fce64..be0d137814 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -12,7 +12,7 @@ import { useExtension, useExtensionState, } from "../../../hooks/useExtension.js"; -import { PortalTarget } from "../../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -185,7 +185,7 @@ export function GridSuggestionMenuController< } return ( - + {triggerCharacter && ( )} - + ); } diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 1a8bb424bd..59dda4e150 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -10,7 +10,7 @@ import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -178,7 +178,7 @@ export function SuggestionMenuController< } return ( - + {triggerCharacter && ( )} - + ); } diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 6fbc2e94ea..1bc60e7ea2 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -12,7 +12,7 @@ import { FC, useCallback, useMemo, useState } from "react"; import { autoUpdate, offset, ReferenceElement, size } from "@floating-ui/react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; -import { PortalTarget } from "../../editor/PortalTarget.js"; +import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -316,7 +316,7 @@ export const TableHandlesController = < const TableCellHandleComponent = props.tableCellHandle || TableCellButton; return ( - + )} - + ); }; diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index cb8f00fb17..1c5162c970 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -27,7 +27,7 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { PortalTarget } from "./PortalTarget.js"; +import { EditorPortalProvider } from "./EditorPortalProvider.js"; import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, @@ -143,7 +143,7 @@ function BlockNoteViewComponent< } = props; const editorDOMElement = useEditorDOMElement(editor); - const portalTarget = + const portalElement = useMemo( () => resolvePortalTarget(portalElements?.default), [portalElements?.default], @@ -251,7 +251,7 @@ function BlockNoteViewComponent< renderEditor={renderEditor} editorColorScheme={editorColorScheme} themedRootProps={themedRootProps} - portalTarget={portalTarget} + portalElement={portalElement} ref={ref} {...rest} > @@ -272,7 +272,7 @@ const BlockNoteViewContainer = React.forwardRef< renderEditor: boolean; editorColorScheme: "light" | "dark"; themedRootProps?: ThemedRootProps; - portalTarget?: HTMLElement; + portalElement?: HTMLElement; children: ReactNode; } & Omit< HTMLAttributes, @@ -285,7 +285,7 @@ const BlockNoteViewContainer = React.forwardRef< renderEditor, editorColorScheme, themedRootProps, - portalTarget, + portalElement, children, style, ...rest @@ -305,13 +305,13 @@ const BlockNoteViewContainer = React.forwardRef< {...rest} ref={ref} > - + {renderEditor ? ( {children} ) : ( children )} - +
), ); diff --git a/packages/react/src/editor/BlockNoteViewContext.ts b/packages/react/src/editor/BlockNoteViewContext.ts index 003fd2722e..699777d429 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -27,7 +27,7 @@ export type BlockNoteViewContextValue = { * Props that turn an element into a themed `.bn-root`: the classes and * color-scheme attribute existing CSS keys off, plus the UI-library extras * from {@link ThemedRootProps}. Rendered on the editor container and used - * by `PortalTarget` to theme the portal roots it creates — all from the + * by `EditorPortalProvider` to theme the portal roots it creates — all from the * same data. */ portalRootProps: ThemedRootProps & { diff --git a/packages/react/src/editor/PortalTarget.tsx b/packages/react/src/editor/EditorPortalProvider.tsx similarity index 80% rename from packages/react/src/editor/PortalTarget.tsx rename to packages/react/src/editor/EditorPortalProvider.tsx index 01b8022fa4..b8cb6c15e9 100644 --- a/packages/react/src/editor/PortalTarget.tsx +++ b/packages/react/src/editor/EditorPortalProvider.tsx @@ -10,10 +10,10 @@ import { createPortal } from "react-dom"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; -const PortalContext = createContext(null); +const EditorPortalContext = createContext(null); -export function usePortalContext(): HTMLElement | null { - return useContext(PortalContext); +export function useEditorPortalElement(): HTMLElement | null { + return useContext(EditorPortalContext); } // Registers a portal root on mount and deregisters it on unmount. @@ -36,7 +36,7 @@ function useRegisterPortalRoot(root: HTMLElement | null) { // one is necessary to apply correct theming & styling. If one doesn't exist, creates one and // returns it, both as a React node and HTML element. Otherwise, just returns the target element or // null if the target is undefined. -function usePortalRoot(target: HTMLElement | undefined): { +function useThemedPortalRoot(target: HTMLElement | undefined): { root: HTMLElement | null; themingContainer: ReactNode; } { @@ -73,10 +73,10 @@ function usePortalRoot(target: HTMLElement | undefined): { }; } -// Exposes a target portal element for consumers of `PortalContext` to consume. If the target +// Exposes a target portal element for consumers of `EditorPortalContext` to consume. If the target // element has no `.bn-root` element in its ancestors, so that styles & theming are properly // applied to the element's descendants, one is created. -export function PortalTarget(props: { +export function EditorPortalProvider(props: { target?: HTMLElement | null; children?: ReactNode; }) { @@ -89,7 +89,7 @@ export function PortalTarget(props: { : undefined : target; - const { root, themingContainer } = usePortalRoot(resolvedTarget); + const { root, themingContainer } = useThemedPortalRoot(resolvedTarget); useRegisterPortalRoot(root); @@ -99,7 +99,9 @@ export function PortalTarget(props: { return ( <> - {children} + + {children} + {themingContainer} ); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a92dc2d005..5d8be69c43 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,7 +45,10 @@ export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/DesktopFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; -export { PortalTarget, usePortalContext } from "./editor/PortalTarget.js"; +export { + EditorPortalProvider, + useEditorPortalElement, +} from "./editor/EditorPortalProvider.js"; export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index ffd7fa4ac0..98942b0f74 100644 --- a/packages/shadcn/src/badge/Badge.tsx +++ b/packages/shadcn/src/badge/Badge.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, usePortalContext } from "@blocknote/react"; +import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; @@ -28,7 +28,7 @@ export const Badge = forwardRef< // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. - const contextPortal = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); const badge = ( {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 309e476a99..abe101b9fa 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, usePortalContext } from "@blocknote/react"; +import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -84,8 +84,8 @@ export const MenuDropdown = forwardRef< const portalRoot = useContext(PortalRootContext); // Default to the ambient portal target (a themed `.bn-root`) so the menu // inherits light/dark mode instead of the document body's. - const contextPortal = usePortalContext(); - const container = portalRoot ?? contextPortal ?? undefined; + const editorPortalElement = useEditorPortalElement(); + const container = portalRoot ?? editorPortalElement ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index f6f8aecd9d..793530ce3b 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, usePortalContext } from "@blocknote/react"; +import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -65,12 +65,12 @@ export const PopoverContent = forwardRef< // Default to the ambient portal target (a themed `.bn-root`) so popovers // inherit light/dark mode instead of the document body's, and escape the // mobile formatting toolbar's horizontal scroll clip. - const contextPortal = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); return ( ( // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. - const contextPortal = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); const trigger = isSelected === undefined ? ( @@ -112,7 +112,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -135,7 +135,7 @@ export const ToolbarSelect = forwardRef< // Default to the ambient portal target (a themed `.bn-root`) so the dropdown // inherits light/dark mode instead of the body's. - const contextPortal = usePortalContext(); + const editorPortalElement = useEditorPortalElement(); // TODO? const SelectItemContent = (props: any) => ( @@ -164,7 +164,7 @@ export const ToolbarSelect = forwardRef< Date: Fri, 4 Sep 2026 13:00:11 +0200 Subject: [PATCH 06/21] Fixed build --- .../11-uppy-file-panel/src/FileReplaceButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index 0f5df1a919..6e1770b199 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - usePortalContext, + useEditorPortalElement, useSelectedBlocks, } from "@blocknote/react"; import { useCallback, useEffect, useState } from "react"; @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const mobileToolbarPortal = usePortalContext(); + const mobileToolbarPortal = useEditorPortalElement(); const editor = useBlockNoteEditor< BlockSchema, From 543523d0191a60bbd5bb6c04fe2b0e56f7029e6c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 4 Sep 2026 13:28:41 +0200 Subject: [PATCH 07/21] Fixed naming omissions --- .../11-uppy-file-panel/src/FileReplaceButton.tsx | 4 ++-- .../FormattingToolbar/MobileFormattingToolbarController.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index 6e1770b199..a86d8c9f01 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const mobileToolbarPortal = useEditorPortalElement(); + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={mobileToolbarPortal} + portalRoot={editorPortalElement} > ; }) { - const root = useEditorPortalElement(); + const editorPortalElement = useEditorPortalElement(); const Component = props.formattingToolbar; - if (!root) { + if (!editorPortalElement) { return null; } @@ -107,6 +107,6 @@ function MobileFormattingToolbar(props: {
, - root, + editorPortalElement, ); } From f69f5c9b52dbd220ca553ae6505e0e5748897379 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 4 Sep 2026 18:40:13 +0200 Subject: [PATCH 08/21] Always pass `editorPortalElement` to `portalRoot` --- packages/ariakit/src/menu/Menu.tsx | 3 +++ packages/ariakit/src/popover/Popover.tsx | 10 ++++++++- .../ariakit/src/toolbar/ToolbarSelect.tsx | 9 +++++++- packages/mantine/src/menu/Menu.tsx | 16 ++++++++++---- packages/mantine/src/popover/Popover.tsx | 16 ++++++++++---- .../mantine/src/toolbar/ToolbarSelect.tsx | 15 +++++++++---- .../react/src/components/Comments/Comment.tsx | 7 ++++++- .../DefaultButtons/ColorStyleButton.tsx | 20 +++++++----------- .../DefaultButtons/CreateLinkButton.tsx | 19 +++++++---------- .../DefaultButtons/FileCaptionButton.tsx | 19 +++++++---------- .../DefaultButtons/FileRenameButton.tsx | 19 +++++++---------- .../DefaultButtons/FileReplaceButton.tsx | 13 +++++++----- .../DefaultSelects/BlockTypeSelect.tsx | 10 ++++----- .../DefaultButtons/EditLinkButton.tsx | 3 +++ .../DefaultButtons/DragHandleButton.tsx | 3 +++ .../DefaultItems/BlockColorsItem.tsx | 8 ++++++- .../TableHandles/TableCellButton.tsx | 3 +++ .../DefaultButtons/ColorPicker.tsx | 8 ++++++- .../components/TableHandles/TableHandle.tsx | 3 +++ .../DefaultButtons/ColorPicker.tsx | 8 ++++++- .../components/Versioning/CurrentSnapshot.tsx | 7 ++++++- .../src/components/Versioning/Snapshot.tsx | 7 ++++++- .../react/src/editor/ComponentsContext.tsx | 21 +++++++++++++++++++ packages/shadcn/src/menu/Menu.tsx | 13 ++++++------ packages/shadcn/src/popover/popover.tsx | 16 +++++++------- packages/shadcn/src/toolbar/Toolbar.tsx | 17 +++++++++------ 26 files changed, 199 insertions(+), 94 deletions(-) diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 177dc37f73..94ec44ecab 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -25,6 +25,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { onOpenChange, position, portalRoot, + // ariakit's `virtualFocus` keeps DOM focus on the editor (roving via + // `aria-activedescendant`), so there is no focus to suppress here. + preventFocusOnOpen: _preventFocusOnOpen, sub: _sub, // unused ...rest } = props; diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index df8e01128b..d0e557998c 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -51,7 +51,15 @@ export const PopoverContent = forwardRef< export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { children, open, onOpenChange, position, portalRoot, ...rest } = props; + const { + children, + open, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 26d817976e..4b28092eb3 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,14 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index 4a04322152..e28f568175 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -16,7 +16,15 @@ const SubMenuContext = createContext< >(undefined); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - const { children, onOpenChange, position, portalRoot, sub, ...rest } = props; + const { + children, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen, + sub, + ...rest + } = props; assertEmpty(rest); @@ -38,9 +46,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { { - const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + const { + open, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen, + children, + ...rest + } = props; assertEmpty(rest); @@ -20,9 +28,9 @@ export const Popover = ( middlewares={{ size: { padding: 20 } }} withinPortal={!!portalRoot} portalProps={portalRoot ? { target: portalRoot } : undefined} - // Do not move focus to the dropdown on mobile, as it blurs the editor's - // contentEditable and dismisses the on-screen keyboard. - trapFocus={portalRoot ? false : undefined} + // Do not move focus to the dropdown when requested (mobile), as it blurs + // the editor's contentEditable and dismisses the on-screen keyboard. + trapFocus={preventFocusOnOpen ? false : undefined} opened={open} onChange={onOpenChange} position={position} diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 16f7023c16..567d723270 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -14,7 +14,14 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); @@ -32,9 +39,9 @@ export const ToolbarSelect = forwardRef< exitDuration: 0, }} disabled={isDisabled} - // Do not move focus to the dropdown on mobile, as it blurs the editor's - // contentEditable and dismisses the on-screen keyboard. - trapFocus={portalRoot ? false : undefined} + // Do not move focus to the dropdown when requested (mobile), as it blurs + // the editor's contentEditable and dismisses the on-screen keyboard. + trapFocus={preventFocusOnOpen ? false : undefined} middlewares={{ flip: true, shift: true, diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index d4a6df40d6..edd45a84b4 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -18,6 +18,7 @@ import { Components, useComponentsContext, } from "../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; @@ -161,6 +162,7 @@ export const Comment = ({ }); const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const [isEditing, setEditing] = useState(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); @@ -289,7 +291,10 @@ export const Comment = ({ ))} {(canDeleteComment || canEditComment) && ( - + { const dict = useDictionary(); const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -145,14 +141,14 @@ export const ColorStyleButton = () => { return ( { const dict = useDictionary(); const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -136,13 +132,14 @@ export const CreateLinkButton = () => { {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 7c59fbaeff..bfca8ca31b 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -20,10 +20,6 @@ export const FileCaptionButton = () => { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,13 +108,14 @@ export const FileCaptionButton = () => { { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,13 +108,14 @@ export const FileRenameButton = () => { { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -73,7 +69,14 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={portalRoot} + // Portal the popover into the editor's themed portal target so it + // inherits styling and escapes any scroll-container overflow clipping. + // On mobile that target is the toolbar's body-level container (see + // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops + // focus moving into the popover, which would blur the editor and dismiss + // the on-screen keyboard. + portalRoot={editorPortalElement ?? undefined} + preventFocusOnOpen={uiMode === "mobile"} > { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -220,7 +216,11 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index 52cef731f4..41b6e03fc2 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -1,4 +1,5 @@ import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { EditLinkMenuItems } from "../EditLinkMenuItems.js"; import { LinkToolbarProps } from "../LinkToolbarProps.js"; @@ -10,11 +11,13 @@ export const EditLinkButton = ( >, ) => { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); return ( { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const sideMenu = useExtension(SideMenuExtension); @@ -39,6 +41,7 @@ export const DragHandleButton = ( } }} position={"left"} + portalRoot={editorPortalElement ?? undefined} > { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor(); @@ -30,7 +32,11 @@ export const BlockColorsItem = (props: { children: ReactNode }) => { } return ( - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor(); @@ -45,6 +47,7 @@ export const TableCellButton = ( } }} position={"right"} + portalRoot={editorPortalElement ?? undefined} > diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index 36e1ecca2f..fc68457d1a 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -3,6 +3,7 @@ import { TableHandlesExtension } from "@blocknote/core/extensions"; import { ReactNode } from "react"; import { useComponentsContext } from "../../../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../../../editor/EditorPortalProvider.js"; import { useBlockNoteEditor } from "../../../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../../../hooks/useExtension.js"; import { useDictionary } from "../../../../i18n/dictionary.js"; @@ -10,6 +11,7 @@ import { ColorPicker } from "../../../ColorPicker/ColorPicker.js"; export const ColorPickerButton = (props: { children?: ReactNode }) => { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor(); @@ -74,7 +76,11 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { } return ( - + { const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const [isDragging, setIsDragging] = useState(false); @@ -66,6 +68,7 @@ export const TableHandle = ( } }} position={"right"} + portalRoot={editorPortalElement ?? undefined} > { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor< { table: DefaultBlockSchema["table"] }, @@ -104,7 +106,11 @@ export const ColorPickerButton = < const firstCell = mapTableCell(currentCells[0].cell); return ( - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const { canPreviewCurrent, previewCurrentVersion, exitPreview } = useExtension(VersioningExtension); const selected = useExtensionState(VersioningExtension, { @@ -72,7 +74,10 @@ export const CurrentSnapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const { canRestore, restore, @@ -110,7 +112,10 @@ export const Snapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + { onOpenChange, position: _position, // Unused portalRoot, + // base-ui manages menu focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, sub, ...rest } = props; @@ -81,11 +84,9 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); - // Default to the ambient portal target (a themed `.bn-root`) so the menu - // inherits light/dark mode instead of the document body's. - const editorPortalElement = useEditorPortalElement(); - const container = portalRoot ?? editorPortalElement ?? undefined; + // The `portalRoot` supplied at the call site is a themed `.bn-root`, so the + // menu inherits light/dark mode instead of the document body's. + const container = useContext(PortalRootContext) ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 793530ce3b..a6735dfd75 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -18,6 +18,9 @@ export const Popover = ( onOpenChange, position: _position, // unused portalRoot, + // base-ui manages popover focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, ...rest } = props; @@ -61,16 +64,15 @@ export const PopoverContent = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); - // Default to the ambient portal target (a themed `.bn-root`) so popovers - // inherit light/dark mode instead of the document body's, and escape the - // mobile formatting toolbar's horizontal scroll clip. - const editorPortalElement = useEditorPortalElement(); + // The `portalRoot` supplied at the call site is a themed `.bn-root`, so + // popovers inherit light/dark mode instead of the document body's, and escape + // the mobile formatting toolbar's horizontal scroll clip. + const container = useContext(PortalRootContext) ?? undefined; return ( ((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + // base-ui manages select focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; - // Default to the ambient portal target (a themed `.bn-root`) so the dropdown - // inherits light/dark mode instead of the body's. - const editorPortalElement = useEditorPortalElement(); - // TODO? const SelectItemContent = (props: any) => (
@@ -164,7 +169,7 @@ export const ToolbarSelect = forwardRef< Date: Fri, 4 Sep 2026 18:48:03 +0200 Subject: [PATCH 09/21] refactor(react): one vocabulary and simpler plumbing for portal elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the portal consolidation from #3046, keeping its model — portal elements registered on the editor, themed roots for foreign targets — and changing how it is named and wired. Naming: one stem, `portalElement`, from `editor.registerPortalElement` and `mount(el, { portalElement })` through `resolvePortalElement`, `usePortalElement` and `PortalElementOverride` to the `portalElement` prop on every popover, menu and select. `portalRoot`, `portalTarget`, `portalContext` and `editorPortal` are gone. Where a forwarded prop and the ambient element coexist they are `portalElementProp` and `portalElement`. Theming: `ThemedRootProps` is replaced by a single `applyThemedRoot(element)` on `BlockNoteViewContext`, composed from the base classes and whatever the UI library adds. The mantine wrapper keeps base's `applyThemeVariables` ref for the editor container and passes the same function down for portal roots, so `BlockNoteTheme.ts` is untouched. Plumbing: the default portal element is derived in `usePortalElement` from the editor's own container, so `BlockNoteViewContainer` needs no state, no merged refs and no provider wrapper. `PortalElementOverride` creates its themed root directly and mounts it in a layout effect, replacing a `createPortal` of an empty div, a ref/state round-trip and a `closest(".bn-root")` read during render. --- .../src/FileReplaceButton.tsx | 6 +- packages/ariakit/src/menu/Menu.tsx | 14 +-- packages/ariakit/src/popover/Popover.tsx | 13 +- .../ariakit/src/toolbar/ToolbarSelect.tsx | 4 +- packages/core/src/editor/BlockNoteEditor.ts | 53 ++++---- .../TableHandles/TableHandles.browser.test.ts | 2 +- packages/mantine/src/BlockNoteTheme.ts | 116 ++++++++++++++--- packages/mantine/src/BlockNoteView.tsx | 64 ++++++---- packages/mantine/src/menu/Menu.tsx | 9 +- packages/mantine/src/popover/Popover.tsx | 9 +- .../mantine/src/toolbar/ToolbarSelect.tsx | 8 +- .../AttributionTooltipController.tsx | 6 +- .../src/components/Comments/EmojiPicker.tsx | 6 +- .../Comments/FloatingComposerController.tsx | 6 +- .../Comments/FloatingThreadController.tsx | 6 +- .../FilePanel/FilePanelController.tsx | 6 +- .../DefaultButtons/ColorStyleButton.tsx | 12 +- .../DefaultButtons/CreateLinkButton.tsx | 12 +- .../DefaultButtons/FileCaptionButton.tsx | 12 +- .../DefaultButtons/FileRenameButton.tsx | 12 +- .../DefaultButtons/FileReplaceButton.tsx | 10 +- .../DefaultSelects/BlockTypeSelect.tsx | 10 +- .../DesktopFormattingToolbarController.tsx | 6 +- .../MobileFormattingToolbarController.tsx | 16 +-- .../LinkToolbar/LinkToolbarController.tsx | 6 +- .../components/Popovers/GenericPopover.tsx | 16 +-- .../SideMenu/SideMenuController.tsx | 6 +- .../GridSuggestionMenuController.tsx | 6 +- .../SuggestionMenuController.tsx | 6 +- .../TableHandles/TableHandlesController.tsx | 6 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 22 ++-- packages/react/src/editor/BlockNoteView.tsx | 72 ++++++----- .../react/src/editor/BlockNoteViewContext.ts | 35 ++---- .../react/src/editor/ComponentsContext.tsx | 6 +- .../react/src/editor/EditorPortalProvider.tsx | 108 ---------------- .../src/editor/PortalElementOverride.tsx | 117 ++++++++++++++++++ packages/react/src/editor/portalElements.ts | 2 +- packages/react/src/index.ts | 6 +- packages/shadcn/src/badge/Badge.tsx | 6 +- packages/shadcn/src/menu/Menu.tsx | 20 +-- packages/shadcn/src/popover/popover.tsx | 16 +-- packages/shadcn/src/toolbar/Toolbar.tsx | 18 ++- 42 files changed, 503 insertions(+), 389 deletions(-) delete mode 100644 packages/react/src/editor/EditorPortalProvider.tsx create mode 100644 packages/react/src/editor/PortalElementOverride.tsx diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index a86d8c9f01..2c27dcd6a3 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - useEditorPortalElement, + usePortalElement, useSelectedBlocks, } from "@blocknote/react"; import { useCallback, useEffect, useState } from "react"; @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const editorPortalElement = useEditorPortalElement(); + const editorPortalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={editorPortalElement} + portalElement={editorPortalElement} > ( +const PortalElementPropContext = createContext( undefined, ); @@ -24,7 +24,7 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { children, onOpenChange, position, - portalRoot, + portalElement, sub: _sub, // unused ...rest } = props; @@ -37,9 +37,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { setOpen={onOpenChange} virtualFocus={true} > - + {children} - + ); }; @@ -57,13 +57,13 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); - const portalRoot = useContext(PortalRootContext); + const portalElementProp = useContext(PortalElementPropContext); return ( {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index df8e01128b..a2952a0755 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -8,7 +8,7 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -const PortalRootContext = createContext( +const PortalElementPropContext = createContext( undefined, ); @@ -31,7 +31,7 @@ export const PopoverContent = forwardRef< assertEmpty(rest); - const portalRoot = useContext(PortalRootContext); + const portalElementProp = useContext(PortalElementPropContext); return ( {children} @@ -51,7 +51,8 @@ export const PopoverContent = forwardRef< export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { children, open, onOpenChange, position, portalRoot, ...rest } = props; + const { children, open, onOpenChange, position, portalElement, ...rest } = + props; assertEmpty(rest); @@ -61,9 +62,9 @@ export const Popover = ( setOpen={onOpenChange} placement={position} > - + {children} - + ); }; diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 26d817976e..bfd80881c3 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, portalRoot, ...rest } = props; + const { className, items, isDisabled, portalElement, ...rest } = props; assertEmpty(rest); @@ -40,7 +40,7 @@ export const ToolbarSelect = forwardRef< className={mergeCSSClasses("bn-ak-popover", className || "")} ref={ref} gutter={4} - portalElement={portalRoot ?? undefined} + portalElement={portalElement ?? undefined} > {items.map((option) => ( { - if (options?.portalTarget) { - this.registerPortalRoot(options.portalTarget); + if (options?.portalElement) { + this.registerPortalElement(options.portalElement); } this._tiptapEditor.mount({ mount: element }); }; @@ -772,46 +772,49 @@ export class BlockNoteEditor< return this.prosemirrorView?.dom as HTMLDivElement | undefined; } - // Portal roots registered by the view layer, with reference counts so - // multiple UI elements can share a root (e.g. several popovers portalling - // into the same custom target). - private _portalRoots = new Map(); + // Portal elements registered by the view layer, with reference counts so + // several UI elements can share one (e.g. multiple popovers portalling into + // the same custom element). + private _portalElements = new Map(); /** - * Registers an element as a portal root for this editor's floating UI, so + * Registers an element as a portal element for this editor's floating UI, so * {@link isWithinEditor} treats its contents as part of the editor. The view - * layer calls this for each portal target it designates (see - * `EditorPortalProvider` in `@blocknote/react`) — without it, UI portalled outside + * layer calls this for each portal element it designates (see + * `PortalElementOverride` in `@blocknote/react`) — without it, UI portalled outside * the editor's DOM tree would be considered outside the editor. * Registrations are reference-counted; release with - * {@link unregisterPortalRoot}. + * {@link unregisterPortalElement}. */ - public registerPortalRoot = (element: HTMLElement) => { - this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1); + public registerPortalElement = (element: HTMLElement) => { + this._portalElements.set( + element, + (this._portalElements.get(element) ?? 0) + 1, + ); }; /** - * Releases a registration made with {@link registerPortalRoot}. The element + * Releases a registration made with {@link registerPortalElement}. The element * stops counting as part of the editor once every registration for it has * been released. */ - public unregisterPortalRoot = (element: HTMLElement) => { - const count = this._portalRoots.get(element); + public unregisterPortalElement = (element: HTMLElement) => { + const count = this._portalElements.get(element); if (count === undefined) { return; } if (count <= 1) { - this._portalRoots.delete(element); + this._portalElements.delete(element); } else { - this._portalRoots.set(element, count - 1); + this._portalElements.set(element, count - 1); } }; /** * Checks whether a DOM element belongs to this editor — inside the editor's - * DOM tree, or inside any portal root registered via - * {@link registerPortalRoot} (used for floating UI elements like menus and + * DOM tree, or inside any portal element registered via + * {@link registerPortalElement} (used for floating UI elements like menus and * toolbars, which may portal outside the editor's DOM tree). */ public isWithinEditor = (element: Element): boolean => { @@ -819,8 +822,8 @@ export class BlockNoteEditor< return true; } - for (const root of this._portalRoots.keys()) { - if (root.contains(element)) { + for (const portalElement of this._portalElements.keys()) { + if (portalElement.contains(element)) { return true; } } diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts index 30c7549284..3b4b56a54e 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts @@ -60,7 +60,7 @@ const nestedEditorBlock = createBlockSpec( }, ], }); - nestedEditor.mount(dom, { portalTarget: document.body }); + nestedEditor.mount(dom, { portalElement: document.body }); return { dom, destroy: () => nestedEditor.unmount() }; }, diff --git a/packages/mantine/src/BlockNoteTheme.ts b/packages/mantine/src/BlockNoteTheme.ts index 1a13169538..503c4729cd 100644 --- a/packages/mantine/src/BlockNoteTheme.ts +++ b/packages/mantine/src/BlockNoteTheme.ts @@ -34,13 +34,12 @@ export type Theme = Partial<{ type NestedObject = { [key: string]: number | string | NestedObject }; -/** - * Converts a {@link Theme} into a map of `--bn-*` CSS custom properties, for - * passing declaratively via a `style` prop (e.g. to `BlockNoteViewRaw`, which - * also forwards it to portal roots). - */ -export function themeToCSSVariables(theme: Theme): Record { - const variables: Record = {}; +const cssVariablesHelper = ( + theme: Theme, + editorDOM: HTMLElement, + unset = false, +) => { + const result: string[] = []; function traverse(current: NestedObject, currentKey = "--bn") { for (const key in current) { @@ -48,18 +47,107 @@ export function themeToCSSVariables(theme: Theme): Record { .replace(/([a-z])([A-Z])/g, "$1-$2") .toLowerCase(); const fullKey = `${currentKey}-${kebabCaseKey}`; - const value = current[key]; - if (typeof value === "object") { - traverse(value, fullKey); - } else { + if (typeof current[key] !== "object") { // Convert numbers to px - variables[fullKey] = typeof value === "number" ? `${value}px` : value; + if (typeof current[key] === "number") { + current[key] = `${current[key]}px`; + } + + if (unset) { + editorDOM.style.removeProperty(fullKey); + } else { + editorDOM.style.setProperty(fullKey, current[key].toString()); + } + } else { + traverse(current[key] as NestedObject, fullKey); } } } traverse(theme); - return variables; -} + return result; +}; + +export const applyBlockNoteCSSVariablesFromTheme = ( + theme: Theme, + editorDOM: HTMLElement, +) => cssVariablesHelper(theme, editorDOM); + +// We don't need a theme to remove the CSS variables, but having access to a +// theme object allows us to use the same logic to set/unset them, so this +// placeholder theme is used. +const placeholderTheme: Theme = { + colors: { + editor: { + text: undefined as any, + background: undefined as any, + }, + menu: { + text: undefined as any, + background: undefined as any, + }, + tooltip: { + text: undefined as any, + background: undefined as any, + }, + hovered: { + text: undefined as any, + background: undefined as any, + }, + selected: { + text: undefined as any, + background: undefined as any, + }, + disabled: { + text: undefined as any, + background: undefined as any, + }, + shadow: undefined as any, + border: undefined as any, + sideMenu: undefined as any, + highlights: { + gray: { + text: undefined as any, + background: undefined as any, + }, + brown: { + text: undefined as any, + background: undefined as any, + }, + red: { + text: undefined as any, + background: undefined as any, + }, + orange: { + text: undefined as any, + background: undefined as any, + }, + yellow: { + text: undefined as any, + background: undefined as any, + }, + green: { + text: undefined as any, + background: undefined as any, + }, + blue: { + text: undefined as any, + background: undefined as any, + }, + purple: { + text: undefined as any, + background: undefined as any, + }, + pink: { + text: undefined as any, + background: undefined as any, + }, + }, + }, + borderRadius: undefined as any, + fontFamily: undefined as any, +}; +export const removeBlockNoteCSSVariables = (editorDOM: HTMLElement) => + cssVariablesHelper(placeholderTheme, editorDOM, true); diff --git a/packages/mantine/src/BlockNoteView.tsx b/packages/mantine/src/BlockNoteView.tsx index a6b57bb3c4..1a5954eb1a 100644 --- a/packages/mantine/src/BlockNoteView.tsx +++ b/packages/mantine/src/BlockNoteView.tsx @@ -11,8 +11,12 @@ import { usePrefersColorScheme, } from "@blocknote/react"; import { MantineContext, MantineProvider } from "@mantine/core"; -import React, { useContext, useMemo } from "react"; -import { Theme, themeToCSSVariables } from "./BlockNoteTheme.js"; +import React, { useCallback, useContext } from "react"; +import { + applyBlockNoteCSSVariablesFromTheme, + removeBlockNoteCSSVariables, + Theme, +} from "./BlockNoteTheme.js"; import { components } from "./components.js"; export const BlockNoteView = < @@ -48,37 +52,53 @@ export const BlockNoteView = < ? defaultColorScheme : "light"; - // Mantine's theming for BlockNote's themed root elements (the editor - // container and any portal roots): the color-scheme attribute the - // stylesheet keys off, plus CSS variables for custom object themes. - // `BlockNoteViewRaw` applies these to every root, so they all update in the - // same commit. - const themedRootProps = useMemo(() => { - const themeCSSVariables = - typeof theme !== "object" - ? undefined - : "light" in theme && "dark" in theme - ? themeToCSSVariables( - theme[defaultColorScheme === "dark" ? "dark" : "light"], - ) - : themeToCSSVariables(theme); + const applyThemeVariables = useCallback( + (node: HTMLElement | null) => { + if (!node) { + return; + } + + removeBlockNoteCSSVariables(node); + + if (typeof theme === "object") { + if ("light" in theme && "dark" in theme) { + applyBlockNoteCSSVariablesFromTheme( + theme[defaultColorScheme === "dark" ? "dark" : "light"], + node, + ); + return; + } - return { - "data-mantine-color-scheme": finalTheme, - style: themeCSSVariables, - }; - }, [defaultColorScheme, theme, finalTheme]); + applyBlockNoteCSSVariablesFromTheme(theme, node); + return; + } + }, + [defaultColorScheme, theme], + ); + + // Themes an element BlockNote creates outside React's tree — the portal + // roots its floating UI mounts (see `PortalElementOverride`). The editor + // container gets the same treatment from the props and `ref` below. + const applyThemedRoot = useCallback( + (element: HTMLElement) => { + element.setAttribute("data-mantine-color-scheme", finalTheme); + applyThemeVariables(element); + }, + [applyThemeVariables, finalTheme], + ); const mantineContext = useContext(MantineContext); const view = ( ); diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index 4a04322152..cdfb5d2f2e 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -16,7 +16,8 @@ const SubMenuContext = createContext< >(undefined); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - const { children, onOpenChange, position, portalRoot, sub, ...rest } = props; + const { children, onOpenChange, position, portalElement, sub, ...rest } = + props; assertEmpty(rest); @@ -36,11 +37,11 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { return ( { - const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + const { open, onOpenChange, position, portalElement, children, ...rest } = + props; assertEmpty(rest); return ( ((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { className, items, isDisabled, portalElement, ...rest } = props; assertEmpty(rest); @@ -26,15 +26,15 @@ export const ToolbarSelect = forwardRef< return ( + {tooltipProps && } - + ); }; diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 641abdfe07..68107083f5 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -2,7 +2,7 @@ import { ReactNode, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; +import { usePortalElement } from "../../editor/PortalElementOverride.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -14,12 +14,12 @@ export const EmojiPicker = (props: { const Components = useComponentsContext()!; const blockNoteContext = useBlockNoteContext()!; - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); return (
+ - + ); } diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 15a2b2c457..6b72e3eab3 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -5,7 +5,7 @@ import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -129,7 +129,7 @@ export default function FloatingThreadController(props: { const Component = props.floatingThread || Thread; return ( - + )} - + ); } diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index c68062a965..e84e6a2d67 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -4,7 +4,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { FilePanel } from "./FilePanel.js"; @@ -61,10 +61,10 @@ export const FilePanelController = (props: { const Component = props.filePanel || FilePanel; return ( - + {blockId && } - + ); }; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index ed3aa38c11..d2d98931c6 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -7,7 +7,7 @@ import { import { useCallback } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; +import { usePortalElement } from "../../../editor/PortalElementOverride.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -46,11 +46,11 @@ export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -148,11 +148,11 @@ export const ColorStyleButton = () => { // On mobile, portal the dropdown into the toolbar's themed body-level // container (see `MobileFormattingToolbarController`) so it escapes the // editor's scroll container overflow instead of being clipped, while - // staying styled. A set `portalRoot` also stops focus moving into the + // staying styled. A set `portalElement` also stops focus moving into the // dropdown, which would blur the editor and dismiss the on-screen // keyboard. On desktop it's `undefined`, keeping the default inline // rendering. - portalRoot={portalRoot} + portalElement={dropdownPortalElement} > { const Components = useComponentsContext()!; const dict = useDictionary(); const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -139,10 +139,10 @@ export const CreateLinkButton = () => { // On mobile, portal the popover into the toolbar's themed body-level // container (see `MobileFormattingToolbarController`) so it escapes the // editor's scroll container overflow instead of being clipped, while - // staying styled. A set `portalRoot` also stops focus moving into the + // staying styled. A set `portalElement` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={portalRoot} + portalElement={dropdownPortalElement} > {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 7c59fbaeff..62f2ea7927 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -9,7 +9,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; -import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; +import { usePortalElement } from "../../../editor/PortalElementOverride.js"; import { useUIMode } from "../../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; @@ -19,11 +19,11 @@ export const FileCaptionButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -115,10 +115,10 @@ export const FileCaptionButton = () => { // On mobile, portal the popover into the toolbar's themed body-level // container (see `MobileFormattingToolbarController`) so it escapes the // editor's scroll container overflow instead of being clipped, while - // staying styled. A set `portalRoot` also stops focus moving into the + // staying styled. A set `portalElement` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={portalRoot} + portalElement={dropdownPortalElement} > { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -115,10 +115,10 @@ export const FileRenameButton = () => { // On mobile, portal the popover into the toolbar's themed body-level // container (see `MobileFormattingToolbarController`) so it escapes the // editor's scroll container overflow instead of being clipped, while - // staying styled. A set `portalRoot` also stops focus moving into the + // staying styled. A set `portalElement` also stops focus moving into the // popover, which would blur the editor and dismiss the on-screen keyboard. // On desktop it's `undefined`, keeping the default inline rendering. - portalRoot={portalRoot} + portalElement={dropdownPortalElement} > { const dict = useDictionary(); const Components = useComponentsContext()!; const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -73,7 +73,7 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={portalRoot} + portalElement={dropdownPortalElement} > { const Components = useComponentsContext()!; const uiMode = useUIMode(); - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; + const dropdownPortalElement = + uiMode === "mobile" ? (portalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -220,7 +220,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index cdb95fd59e..0a2275d1ba 100644 --- a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx @@ -13,7 +13,7 @@ import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorState } from "../../hooks/useEditorState.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; @@ -119,10 +119,10 @@ export const DesktopFormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( - + {show && } - + ); }; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index 45ee4def36..b074604684 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -2,9 +2,9 @@ import { FC, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { - EditorPortalProvider, - useEditorPortalElement, -} from "../../editor/EditorPortalProvider.js"; + PortalElementOverride, + usePortalElement, +} from "../../editor/PortalElementOverride.js"; import { UIModeContext } from "../../editor/UIModeContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; @@ -83,23 +83,23 @@ export const MobileFormattingToolbarController = (props: { } return ( - + - + ); }; function MobileFormattingToolbar(props: { formattingToolbar: FC; }) { - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); const Component = props.formattingToolbar; - if (!editorPortalElement) { + if (!portalElement) { return null; } @@ -107,6 +107,6 @@ function MobileFormattingToolbar(props: {
, - editorPortalElement, + portalElement, ); } diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index c629307f5f..d886012526 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -6,7 +6,7 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -185,7 +185,7 @@ export const LinkToolbarController = (props: { const Component = props.linkToolbar || LinkToolbar; return ( - + {link && ( )} - + ); }; diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 3a5da7c0f8..276ecfe447 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,7 +14,7 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; -import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; +import { usePortalElement } from "../../editor/PortalElementOverride.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -120,12 +120,12 @@ export const GenericPopover = ( }, ) => { const editor = useBlockNoteEditor(); - // The ambient portal root — always a resolved, themed, registered root, as - // `EditorPortalContext` is only ever provided by `EditorPortalProvider` (the default from + // The ambient portal element — always a resolved, themed, registered root, as + // `EditorPortalContext` is only ever provided by `PortalElementOverride` (the default from // `BlockNoteView`, or a controller's / the mobile toolbar's override). // `null` during SSR and for the frame before resolution — handled after the // hooks below. - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); const { whileElementsMounted: _whileElementsMounted, middleware, @@ -216,7 +216,7 @@ export const GenericPopover = ( [status, props.reference, props.children], ); - if (!isMounted || !editorPortalElement) { + if (!isMounted || !portalElement) { return false; } @@ -245,7 +245,7 @@ export const GenericPopover = ( // should be open. So without this fix, the popover just won't transition // out and will instead appear to hide instantly. return ( - +
+
{props.children} @@ -268,7 +268,7 @@ export const GenericPopover = ( } return ( - +
{props.children}
diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 16ac66f36b..1e49bb8402 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -5,7 +5,7 @@ import { FC, useCallback, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; @@ -150,13 +150,13 @@ export const SideMenuController = (props: { const Component = props.sideMenu || SideMenu; return ( - + {block?.id && } - + ); }; diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index be0d137814..81924c808c 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -12,7 +12,7 @@ import { useExtension, useExtensionState, } from "../../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -185,7 +185,7 @@ export function GridSuggestionMenuController< } return ( - + {triggerCharacter && ( )} - + ); } diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 59dda4e150..2627264fc6 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -10,7 +10,7 @@ import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -178,7 +178,7 @@ export function SuggestionMenuController< } return ( - + {triggerCharacter && ( )} - + ); } diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 1bc60e7ea2..25136102a1 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -12,7 +12,7 @@ import { FC, useCallback, useMemo, useState } from "react"; import { autoUpdate, offset, ReferenceElement, size } from "@floating-ui/react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; -import { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -316,7 +316,7 @@ export const TableHandlesController = < const TableCellHandleComponent = props.tableCellHandle || TableCellButton; return ( - + )} - + ); }; diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 472904b052..88d8c0bede 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -18,7 +18,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 { PortalElementsMap, resolvePortalTarget } from "./portalElements.js"; +import { PortalElementsMap, resolvePortalElement } from "./portalElements.js"; // Lazily load the comments components to avoid pulling in the comments extensions into the main bundle const FloatingComposerController = lazy( @@ -103,15 +103,17 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { } const map = props.portalElements; - const formattingToolbarPortal = resolvePortalTarget(map?.formattingToolbar); - const linkToolbarPortal = resolvePortalTarget(map?.linkToolbar); - const slashMenuPortal = resolvePortalTarget(map?.slashMenu); - const emojiPickerPortal = resolvePortalTarget(map?.emojiPicker); - const sideMenuPortal = resolvePortalTarget(map?.sideMenu); - const filePanelPortal = resolvePortalTarget(map?.filePanel); - const tableHandlesPortal = resolvePortalTarget(map?.tableHandles); - const commentsPortal = resolvePortalTarget(map?.comments); - const attributionTooltipPortal = resolvePortalTarget(map?.attributionTooltip); + const formattingToolbarPortal = resolvePortalElement(map?.formattingToolbar); + const linkToolbarPortal = resolvePortalElement(map?.linkToolbar); + const slashMenuPortal = resolvePortalElement(map?.slashMenu); + const emojiPickerPortal = resolvePortalElement(map?.emojiPicker); + const sideMenuPortal = resolvePortalElement(map?.sideMenu); + const filePanelPortal = resolvePortalElement(map?.filePanel); + const tableHandlesPortal = resolvePortalElement(map?.tableHandles); + const commentsPortal = resolvePortalElement(map?.comments); + const attributionTooltipPortal = resolvePortalElement( + map?.attributionTooltip, + ); return ( <> diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 1c5162c970..7cb9306d23 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -15,7 +15,6 @@ import React, { } from "react"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; import { useEditorChange } from "../hooks/useEditorChange.js"; -import { useEditorDOMElement } from "../hooks/useEditorDomElement.js"; import { useEditorSelectionChange } from "../hooks/useEditorSelectionChange.js"; import { usePrefersColorScheme } from "../hooks/usePrefersColorScheme.js"; import { @@ -27,11 +26,10 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { EditorPortalProvider } from "./EditorPortalProvider.js"; -import { resolvePortalTarget } from "./portalElements.js"; +import { PortalElementOverride } from "./PortalElementOverride.js"; +import { resolvePortalElement } from "./portalElements.js"; import { BlockNoteViewContext, - ThemedRootProps, useBlockNoteViewContext, } from "./BlockNoteViewContext.js"; import { useComponentsContext } from "./ComponentsContext.js"; @@ -94,11 +92,12 @@ export type BlockNoteViewProps< children?: ReactNode; /** - * Attributes to apply to every themed BlockNote root element. UI-library - * wrappers use this to carry their theming (color-scheme data attributes, - * theme CSS variables) to floating UI portalled outside the editor's DOM. + * Applies the UI library's theming — its color-scheme attribute, theme CSS + * variables — to a themed BlockNote root element. Wrappers pass this so that + * floating UI portalled outside the editor's DOM is themed too; they style + * the editor container itself through `className` and a `ref`. */ - themedRootProps?: ThemedRootProps; + applyThemedRoot?: (element: HTMLElement) => void; ref?: Ref | undefined; // only here to get types working with the generics. Regular form doesn't work } & BlockNoteDefaultUIProps; @@ -136,20 +135,18 @@ function BlockNoteViewComponent< tableHandles, comments, portalElements, - themedRootProps, + applyThemedRoot: applyLibraryTheme, autoFocus, renderEditor = true, ...rest } = props; - const editorDOMElement = useEditorDOMElement(editor); - const portalElement = - useMemo( - () => resolvePortalTarget(portalElements?.default), - [portalElements?.default], - ) ?? - editorDOMElement?.parentElement ?? - undefined; + // `default` redirects every element without its own entry. When omitted, + // the ambient portal element (the editor container) stays in effect. + const defaultPortalElement = useMemo( + () => resolvePortalElement(portalElements?.default), + [portalElements?.default], + ); // Used so other components (suggestion menu) can set // aria related props to the contenteditable div @@ -202,13 +199,20 @@ function BlockNoteViewComponent< [editor], ); - const portalRootProps = useMemo( - () => ({ - ...themedRootProps, - className: mergeCSSClasses("bn-root", editorColorScheme, className || ""), - "data-color-scheme": editorColorScheme, - }), - [themedRootProps, editorColorScheme, className], + // Everything that makes an element a themed `.bn-root`: what the container + // below renders as props, applied imperatively for the portal roots + // `PortalElementOverride` creates outside React's tree. + const applyThemedRoot = useCallback( + (element: HTMLElement) => { + element.className = mergeCSSClasses( + "bn-root", + editorColorScheme, + className || "", + ); + element.setAttribute("data-color-scheme", editorColorScheme); + applyLibraryTheme?.(element); + }, + [editorColorScheme, className, applyLibraryTheme], ); // The BlockNoteContext makes sure the editor and some helper methods @@ -232,14 +236,14 @@ function BlockNoteViewComponent< editable, }, defaultUIProps, - portalRootProps, + applyThemedRoot, }; }, [ autoFocus, contentEditableProps, editable, defaultUIProps, - portalRootProps, + applyThemedRoot, ]); return ( @@ -250,8 +254,7 @@ function BlockNoteViewComponent< className={className} renderEditor={renderEditor} editorColorScheme={editorColorScheme} - themedRootProps={themedRootProps} - portalElement={portalElement} + defaultPortalElement={defaultPortalElement} ref={ref} {...rest} > @@ -271,8 +274,7 @@ const BlockNoteViewContainer = React.forwardRef< { renderEditor: boolean; editorColorScheme: "light" | "dark"; - themedRootProps?: ThemedRootProps; - portalElement?: HTMLElement; + defaultPortalElement?: HTMLElement; children: ReactNode; } & Omit< HTMLAttributes, @@ -284,16 +286,13 @@ const BlockNoteViewContainer = React.forwardRef< className, renderEditor, editorColorScheme, - themedRootProps, - portalElement, + defaultPortalElement, children, - style, ...rest }, ref, ) => (
- + {renderEditor ? ( {children} ) : ( children )} - +
), ); diff --git a/packages/react/src/editor/BlockNoteViewContext.ts b/packages/react/src/editor/BlockNoteViewContext.ts index 699777d429..d3a851e282 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -1,21 +1,6 @@ -import { createContext, CSSProperties, useContext } from "react"; +import { createContext, useContext } from "react"; import { BlockNoteDefaultUIProps } from "./BlockNoteDefaultUI.js"; -/** - * Attributes a UI-library wrapper needs on every themed BlockNote root - * element, beyond what the base layer applies: its color-scheme data - * attributes and any theme CSS variables. Passed to `BlockNoteViewRaw` via the - * `themedRootProps` prop; the base layer merges them into - * {@link BlockNoteViewContextValue.portalRootProps} without knowing which - * attributes each library uses. - */ -export type ThemedRootProps = { - /** Intended for theme CSS variables (custom properties). */ - style?: CSSProperties; -} & { - [attribute: `data-${string}`]: string | undefined; -}; - export type BlockNoteViewContextValue = { editorProps: { autoFocus?: boolean; @@ -24,16 +9,16 @@ export type BlockNoteViewContextValue = { }; defaultUIProps: BlockNoteDefaultUIProps; /** - * Props that turn an element into a themed `.bn-root`: the classes and - * color-scheme attribute existing CSS keys off, plus the UI-library extras - * from {@link ThemedRootProps}. Rendered on the editor container and used - * by `EditorPortalProvider` to theme the portal roots it creates — all from the - * same data. + * Makes `element` a themed BlockNote root: the classes and color-scheme + * attribute the stylesheet keys off, plus whatever the UI library adds (its + * own color-scheme attribute, theme CSS variables). + * + * Applied imperatively because it is used for the portal roots BlockNote + * mounts outside React's DOM tree, which cannot be themed with props (see + * `PortalElementOverride`). The editor container is themed by rendering the + * same values as props instead. */ - portalRootProps: ThemedRootProps & { - className: string; - "data-color-scheme": "light" | "dark"; - }; + applyThemedRoot: (element: HTMLElement) => void; }; export const BlockNoteViewContext = createContext< diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 5d71bc58dc..757456ed7d 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -47,7 +47,7 @@ type ToolbarSelectType = { isDisabled?: boolean; }[]; isDisabled?: boolean; - portalRoot?: HTMLElement | null; + portalElement?: HTMLElement | null; }; type MenuButtonType = { @@ -334,7 +334,7 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; - portalRoot?: HTMLElement | null; + portalElement?: HTMLElement | null; children?: ReactNode; }; Divider: { @@ -374,7 +374,7 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; - portalRoot?: HTMLElement | null; + portalElement?: HTMLElement | null; children?: ReactNode; }; Content: { diff --git a/packages/react/src/editor/EditorPortalProvider.tsx b/packages/react/src/editor/EditorPortalProvider.tsx deleted file mode 100644 index b8cb6c15e9..0000000000 --- a/packages/react/src/editor/EditorPortalProvider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { - createContext, - ReactNode, - useContext, - useEffect, - useState, -} from "react"; -import { createPortal } from "react-dom"; - -import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; -import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; - -const EditorPortalContext = createContext(null); - -export function useEditorPortalElement(): HTMLElement | null { - return useContext(EditorPortalContext); -} - -// Registers a portal root on mount and deregisters it on unmount. -function useRegisterPortalRoot(root: HTMLElement | null) { - const editor = useBlockNoteEditor(); - - useEffect(() => { - if (!root) { - return; - } - - editor.registerPortalRoot(root); - return () => { - editor.unregisterPortalRoot(root); - }; - }, [editor, root]); -} - -// Given a target element, checks whether a `.bn-root` element is somewhere up the DOM tree, as -// one is necessary to apply correct theming & styling. If one doesn't exist, creates one and -// returns it, both as a React node and HTML element. Otherwise, just returns the target element or -// null if the target is undefined. -function useThemedPortalRoot(target: HTMLElement | undefined): { - root: HTMLElement | null; - themingContainer: ReactNode; -} { - const rootProps = useBlockNoteViewContext()?.portalRootProps; - - const [needsContainer, setNeedsContainer] = useState<{ - target: HTMLElement; - value: boolean; - }>(); - const [containerElement, setContainerElement] = useState( - null, - ); - - useEffect(() => { - if (target) { - setNeedsContainer({ target, value: !target.closest(".bn-root") }); - } - }, [target]); - - if (!target || needsContainer?.target !== target) { - return { root: null, themingContainer: null }; - } - - if (!needsContainer.value) { - return { root: target, themingContainer: null }; - } - - return { - root: containerElement, - themingContainer: createPortal( -
, - target, - ), - }; -} - -// Exposes a target portal element for consumers of `EditorPortalContext` to consume. If the target -// element has no `.bn-root` element in its ancestors, so that styles & theming are properly -// applied to the element's descendants, one is created. -export function EditorPortalProvider(props: { - target?: HTMLElement | null; - children?: ReactNode; -}) { - const { target, children } = props; - - const resolvedTarget = - target === null - ? typeof document !== "undefined" - ? document.body - : undefined - : target; - - const { root, themingContainer } = useThemedPortalRoot(resolvedTarget); - - useRegisterPortalRoot(root); - - if (target === undefined) { - return children; - } - - return ( - <> - - {children} - - {themingContainer} - - ); -} diff --git a/packages/react/src/editor/PortalElementOverride.tsx b/packages/react/src/editor/PortalElementOverride.tsx new file mode 100644 index 0000000000..07239e6e7c --- /dev/null +++ b/packages/react/src/editor/PortalElementOverride.tsx @@ -0,0 +1,117 @@ +import { + createContext, + ReactNode, + useContext, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../hooks/useEditorDomElement.js"; +import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +// Set only by `PortalElementOverride`; the default comes from the editor +// itself, see `usePortalElement`. +const PortalElementContext = createContext(null); + +/** + * The element the editor's floating UI (toolbars, menus, popovers) should + * portal into: the nearest {@link PortalElementOverride}'s element, or by + * default the editor's own container. Either way it is a themed `.bn-root`, + * so portalled UI keeps the editor's styling and color scheme wherever in the + * DOM it lands. + * + * `null` until the editor has mounted, and on the server. Consumers render + * nothing until it exists. + */ +export function usePortalElement(): HTMLElement | null { + const override = useContext(PortalElementContext); + const editorDOMElement = useEditorDOMElement(); + + if (override) { + return override; + } + + return editorDOMElement?.closest(".bn-container") ?? null; +} + +/** + * Redirects the floating UI below it into `target`, for UI that must escape + * the editor container — an ancestor's `overflow` clipping it, or a stacking + * context painting it behind the page (see + * `MobileFormattingToolbarController`). + * + * The portal element is a themed `.bn-root` mounted inside `target`, so + * portalled UI stays styled wherever it goes. It is created up front rather + * than rendered, so consumers have it on their first render, and mounted in a + * layout effect, so it is in the DOM before paint. It is also registered with + * the editor, so focus inside it still counts as focus within the editor. + * + * - `undefined` — no redirect; the ambient portal element stays in effect. + * - `null` — `document.body`, escaping every ancestor. + */ +export function PortalElementOverride(props: { + target?: HTMLElement | null; + children?: ReactNode; +}) { + const { target, children } = props; + + const editor = useBlockNoteEditor(); + const applyThemedRoot = useBlockNoteViewContext()?.applyThemedRoot; + + const [portalElement] = useState(() => + typeof document === "undefined" ? null : document.createElement("div"), + ); + + const resolvedTarget = + target === null + ? typeof document === "undefined" + ? undefined + : document.body + : target; + + useIsomorphicLayoutEffect(() => { + if (!portalElement || !resolvedTarget) { + return; + } + + resolvedTarget.appendChild(portalElement); + return () => portalElement.remove(); + }, [portalElement, resolvedTarget]); + + // React does not render this element, so the same theming the editor + // container gets from its props is applied here by hand. + useIsomorphicLayoutEffect(() => { + if (!portalElement || !resolvedTarget) { + return; + } + + applyThemedRoot?.(portalElement); + }, [portalElement, resolvedTarget, applyThemedRoot]); + + // Floating UI portalled out of the editor's DOM tree is still the editor's + // UI: registering the element keeps `editor.isWithinEditor` (and the focus + // tracking built on it) true for what renders inside. + useEffect(() => { + if (!portalElement || !resolvedTarget) { + return; + } + + editor.registerPortalElement(portalElement); + return () => editor.unregisterPortalElement(portalElement); + }, [editor, portalElement, resolvedTarget]); + + if (target === undefined) { + return children; + } + + return ( + + {children} + + ); +} diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index 464c34dc98..30e2e38a9c 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -30,7 +30,7 @@ export type PortalElementsMap = { export type PortalElementKey = Exclude; -export function resolvePortalTarget( +export function resolvePortalElement( target: PortalElement | undefined, ): HTMLElement | undefined { if (target === undefined) { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5d8be69c43..4c47ee3586 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -46,9 +46,9 @@ export * from "./components/FormattingToolbar/DesktopFormattingToolbarController export * from "./components/FormattingToolbar/FormattingToolbarController.js"; export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js"; export { - EditorPortalProvider, - useEditorPortalElement, -} from "./editor/EditorPortalProvider.js"; + PortalElementOverride, + usePortalElement, +} from "./editor/PortalElementOverride.js"; export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index 98942b0f74..6fe3842a8b 100644 --- a/packages/shadcn/src/badge/Badge.tsx +++ b/packages/shadcn/src/badge/Badge.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps, usePortalElement } from "@blocknote/react"; import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; @@ -28,7 +28,7 @@ export const Badge = forwardRef< // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); const badge = ( {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index abe101b9fa..48aafb678a 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,11 +1,11 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps, usePortalElement } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( +const PortalElementPropContext = createContext( undefined, ); @@ -14,7 +14,7 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { children, onOpenChange, position: _position, // Unused - portalRoot, + portalElement, sub, ...rest } = props; @@ -28,9 +28,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - + {children} - + ); } else { @@ -39,9 +39,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - + {children} - + ); } @@ -81,11 +81,11 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); + const portalElementProp = useContext(PortalElementPropContext); // Default to the ambient portal target (a themed `.bn-root`) so the menu // inherits light/dark mode instead of the document body's. - const editorPortalElement = useEditorPortalElement(); - const container = portalRoot ?? editorPortalElement ?? undefined; + const portalElement = usePortalElement(); + const container = portalElementProp ?? portalElement ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 793530ce3b..3064cac736 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,11 +1,11 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps, usePortalElement } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( +const PortalElementPropContext = createContext( undefined, ); @@ -17,7 +17,7 @@ export const Popover = ( open, onOpenChange, position: _position, // unused - portalRoot, + portalElement, ...rest } = props; @@ -27,9 +27,9 @@ export const Popover = ( return ( - + {children} - + ); }; @@ -61,16 +61,16 @@ export const PopoverContent = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); + const portalElementProp = useContext(PortalElementPropContext); // Default to the ambient portal target (a themed `.bn-root`) so popovers // inherit light/dark mode instead of the document body's, and escape the // mobile formatting toolbar's horizontal scroll clip. - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); return ( ( // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); const trigger = isSelected === undefined ? ( @@ -112,7 +112,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -127,7 +127,13 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalElement: portalElementProp, + ...rest + } = props; assertEmpty(rest); @@ -135,7 +141,7 @@ export const ToolbarSelect = forwardRef< // Default to the ambient portal target (a themed `.bn-root`) so the dropdown // inherits light/dark mode instead of the body's. - const editorPortalElement = useEditorPortalElement(); + const portalElement = usePortalElement(); // TODO? const SelectItemContent = (props: any) => ( @@ -164,7 +170,7 @@ export const ToolbarSelect = forwardRef< Date: Fri, 4 Sep 2026 19:02:01 +0200 Subject: [PATCH 10/21] refactor(ui): keep the portalElement prop's plain name in the UI libraries Inside a component that receives a `portalElement` prop, `portalElement` now means that prop, and the surrounding default from `usePortalElement` takes the qualified name. Reads more directly, and keeps the props destructures on one line as they are on the base branch. --- packages/ariakit/src/menu/Menu.tsx | 10 +++++----- packages/ariakit/src/popover/Popover.tsx | 10 +++++----- packages/shadcn/src/menu/Menu.tsx | 16 ++++++++-------- packages/shadcn/src/popover/popover.tsx | 12 ++++++------ packages/shadcn/src/toolbar/Toolbar.tsx | 12 +++--------- 5 files changed, 27 insertions(+), 33 deletions(-) diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 3e3403a616..eff4d411f8 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -15,7 +15,7 @@ import { createContext, forwardRef, useContext } from "react"; // Threads the `portalElement` override from `Menu` (the provider) down to // `MenuDropdown`, where ariakit's `portalElement` prop actually lives. -const PortalElementPropContext = createContext( +const PortalElementContext = createContext( undefined, ); @@ -37,9 +37,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { setOpen={onOpenChange} virtualFocus={true} > - + {children} - + ); }; @@ -57,13 +57,13 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); - const portalElementProp = useContext(PortalElementPropContext); + const portalElement = useContext(PortalElementContext); return ( {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index a2952a0755..026672e0cb 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -8,7 +8,7 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -const PortalElementPropContext = createContext( +const PortalElementContext = createContext( undefined, ); @@ -31,7 +31,7 @@ export const PopoverContent = forwardRef< assertEmpty(rest); - const portalElementProp = useContext(PortalElementPropContext); + const portalElement = useContext(PortalElementContext); return ( {children} @@ -62,9 +62,9 @@ export const Popover = ( setOpen={onOpenChange} placement={position} > - + {children} - + ); }; diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 48aafb678a..515e5c15c0 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -5,7 +5,7 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalElementPropContext = createContext( +const PortalElementContext = createContext( undefined, ); @@ -28,9 +28,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - + {children} - + ); } else { @@ -39,9 +39,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - + {children} - + ); } @@ -81,11 +81,11 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalElementProp = useContext(PortalElementPropContext); + const portalElement = useContext(PortalElementContext); // Default to the ambient portal target (a themed `.bn-root`) so the menu // inherits light/dark mode instead of the document body's. - const portalElement = usePortalElement(); - const container = portalElementProp ?? portalElement ?? undefined; + const ambientPortalElement = usePortalElement(); + const container = portalElement ?? ambientPortalElement ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 3064cac736..513b98f45e 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -5,7 +5,7 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalElementPropContext = createContext( +const PortalElementContext = createContext( undefined, ); @@ -27,9 +27,9 @@ export const Popover = ( return ( - + {children} - + ); }; @@ -61,16 +61,16 @@ export const PopoverContent = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalElementProp = useContext(PortalElementPropContext); + const portalElement = useContext(PortalElementContext); // Default to the ambient portal target (a themed `.bn-root`) so popovers // inherit light/dark mode instead of the document body's, and escape the // mobile formatting toolbar's horizontal scroll clip. - const portalElement = usePortalElement(); + const ambientPortalElement = usePortalElement(); return ( ((props, ref) => { - const { - className, - items, - isDisabled, - portalElement: portalElementProp, - ...rest - } = props; + const { className, items, isDisabled, portalElement, ...rest } = props; assertEmpty(rest); @@ -141,7 +135,7 @@ export const ToolbarSelect = forwardRef< // Default to the ambient portal target (a themed `.bn-root`) so the dropdown // inherits light/dark mode instead of the body's. - const portalElement = usePortalElement(); + const ambientPortalElement = usePortalElement(); // TODO? const SelectItemContent = (props: any) => ( @@ -170,7 +164,7 @@ export const ToolbarSelect = forwardRef< Date: Fri, 4 Sep 2026 19:11:21 +0200 Subject: [PATCH 11/21] Added tests --- .../portals/portalElements.test.tsx | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/src/end-to-end/portals/portalElements.test.tsx diff --git a/tests/src/end-to-end/portals/portalElements.test.tsx b/tests/src/end-to-end/portals/portalElements.test.tsx new file mode 100644 index 0000000000..232dcc62d8 --- /dev/null +++ b/tests/src/end-to-end/portals/portalElements.test.tsx @@ -0,0 +1,147 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { PortalElementsMap, useCreateBlockNote } from "@blocknote/react"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +function PortalTestEditor(props: { + portalElements?: PortalElementsMap; + theme?: "light" | "dark"; + onEditor: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote(); + + useEffect(() => { + props.onEditor(editor); + }, [editor, props]); + + return ( + + ); +} + +function createPortalTarget(id: string, className?: string) { + const target = document.createElement("div"); + target.id = id; + target.dataset.testPortalTarget = ""; + target.className = className || ""; + document.body.append(target); + return target; +} + +async function renderEditor(props: { + portalElements?: PortalElementsMap; + theme?: "light" | "dark"; +}) { + let editor: BlockNoteEditor | undefined; + + await render( + { + editor = value; + }} + />, + ); + await waitForSelector(".bn-editor"); + await vi.waitFor(() => { + if (!editor) { + throw new Error("Editor was not created"); + } + }); + + if (!editor) { + throw new Error("Editor was not created"); + } + + return editor; +} + +async function openSlashMenu() { + await focusOnEditor(); + await userEvent.keyboard("/"); + return waitForSelector("#bn-suggestion-menu"); +} + +afterEach(() => { + document + .querySelectorAll("[data-test-portal-target]") + .forEach((target) => target.remove()); +}); + +describe("Portal elements", () => { + test("uses the editor container as the default portal target", async () => { + const editor = await renderEditor({}); + const menu = await openSlashMenu(); + const container = document.querySelector(".bn-container"); + + expect(container).not.toBeNull(); + expect(container?.contains(menu)).toBe(true); + expect(menu.closest(".bn-root")).toBe(container); + expect(editor.isWithinEditor(menu)).toBe(true); + }); + + test("creates a themed root in an external default portal target", async () => { + const target = createPortalTarget("default-portal-target"); + const editor = await renderEditor({ + portalElements: { default: target }, + theme: "dark", + }); + const menu = await openSlashMenu(); + const root = menu.closest(".bn-root"); + + expect(target.contains(menu)).toBe(true); + expect(root?.parentElement).toBe(target); + expect(root?.classList.contains("bn-mantine")).toBe(true); + expect(root?.classList.contains("dark")).toBe(true); + expect(root?.getAttribute("data-mantine-color-scheme")).toBe("dark"); + expect(editor.isWithinEditor(menu)).toBe(true); + }); + + test("uses a per-element selector target instead of the default target", async () => { + const defaultTarget = createPortalTarget("default-portal-target"); + const slashTarget = createPortalTarget( + "slash-portal-target", + "bn-root bn-mantine dark", + ); + slashTarget.setAttribute("data-mantine-color-scheme", "dark"); + + const editor = await renderEditor({ + portalElements: { + default: defaultTarget, + slashMenu: "#slash-portal-target", + }, + theme: "dark", + }); + const menu = await openSlashMenu(); + + expect(defaultTarget.contains(menu)).toBe(false); + expect(slashTarget.contains(menu)).toBe(true); + expect(menu.closest(".bn-root")).toBe(slashTarget); + expect(editor.isWithinEditor(menu)).toBe(true); + }); + + test("treats null as a document-body portal target without registering the whole page", async () => { + const editor = await renderEditor({ + portalElements: { slashMenu: null }, + theme: "dark", + }); + const menu = await openSlashMenu(); + const root = menu.closest(".bn-root"); + + expect(root?.parentElement).toBe(document.body); + expect(root?.classList.contains("bn-mantine")).toBe(true); + expect(root?.getAttribute("data-mantine-color-scheme")).toBe("dark"); + expect(editor.isWithinEditor(menu)).toBe(true); + expect(editor.isWithinEditor(document.body)).toBe(false); + }); +}); From 9e7dfa5bacaee7d91608a39fb2f34326bca91906 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 07:26:16 +0200 Subject: [PATCH 12/21] refactor(core)!: drop the portal option from editor.mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: `editor.mount(element, { portalTarget })` loses its options argument. The option decided where `editor.portalElement` was appended, and that element no longer exists; it had already been reduced to an alias for `registerPortalElement`, which callers can call directly and explicitly: editor.mount(element); editor.registerPortalElement(someContainer); Only needed when floating UI renders outside the editor's DOM tree — UI next to the contenteditable already counts as within the editor. --- packages/core/src/editor/BlockNoteEditor.ts | 21 +++++-------------- .../TableHandles/TableHandles.browser.test.ts | 4 +++- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 5c44136b35..d55523b6d8 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -719,25 +719,14 @@ export class BlockNoteEditor< * Mount the editor to a DOM element. * * @param element The DOM element to mount the editor's contenteditable into. - * @param options.portalElement An element to register as a portal element — a - * convenience for {@link registerPortalElement}, for non-React setups that - * render the editor's floating UI outside the editor's DOM tree, so - * {@link isWithinEditor} recognizes it. An ordinary registration like any - * other: release it with {@link unregisterPortalElement} if ever needed. Not - * needed for UI rendered next to the contenteditable (the mount element's - * parent already counts as within the editor). Prefer a dedicated - * container over e.g. `document.body`, which would make the whole page - * count. + * + * Floating UI rendered next to the contenteditable counts as within the + * editor already; UI rendered outside its DOM tree has to be registered with + * {@link registerPortalElement} so {@link isWithinEditor} recognizes it. * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ - public mount = ( - element: HTMLElement, - options?: { portalElement?: HTMLElement }, - ) => { - if (options?.portalElement) { - this.registerPortalElement(options.portalElement); - } + public mount = (element: HTMLElement) => { this._tiptapEditor.mount({ mount: element }); }; diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts index 3b4b56a54e..6ae5547bb3 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts @@ -60,7 +60,9 @@ const nestedEditorBlock = createBlockSpec( }, ], }); - nestedEditor.mount(dom, { portalElement: document.body }); + nestedEditor.mount(dom); + // The nested editor's UI renders at the body, outside its own DOM tree. + nestedEditor.registerPortalElement(document.body); return { dom, destroy: () => nestedEditor.unmount() }; }, From bbb5ddf99612ab128d5ba4454f7ec8ec44f9302c Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 07:52:38 +0200 Subject: [PATCH 13/21] small fixes --- docs/content/docs/react/components/index.mdx | 2 +- .../AttributionTooltipController.tsx | 2 +- .../Comments/FloatingComposerController.tsx | 4 ++-- .../Comments/FloatingThreadController.tsx | 4 ++-- .../FilePanel/FilePanelController.tsx | 4 ++-- .../DesktopFormattingToolbarController.tsx | 4 ++-- .../FormattingToolbarController.tsx | 4 ++-- .../LinkToolbar/LinkToolbarController.tsx | 4 ++-- .../SideMenu/SideMenuController.tsx | 4 ++-- .../GridSuggestionMenuController.tsx | 2 +- .../SuggestionMenuController.tsx | 2 +- .../TableHandles/TableHandlesController.tsx | 4 ++-- .../src/editor/PortalElementOverride.tsx | 23 +++++++------------ packages/react/src/editor/portalElements.ts | 7 ++---- .../react/src/hooks/useEditorDomElement.ts | 8 ++++++- 15 files changed, 37 insertions(+), 41 deletions(-) diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index 153e79b43a..a5a31ab084 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -25,7 +25,7 @@ By default, all floating UI elements (toolbars, menus, table handles, etc.) port portalElements={{ // Global default for any element not listed below. default: document.body, - // Per-element overrides. Values can be HTMLElement, a CSS selector, or null (= document.body). + // Per-element overrides. Values can be an HTMLElement or a CSS selector. tableHandles: ".bn-container", }} /> diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx index 96cc0ecb74..79708cc934 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltipController.tsx @@ -34,7 +34,7 @@ export const AttributionTooltipController = (props: { * Override the DOM node this floating element portals into. Falls back to * the ambient portal target when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const state = useExtensionState("attribution", { selector: (state) => state, diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 479ce5945b..37b09e287a 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -31,10 +31,10 @@ export default function FloatingComposerController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) { const editor = useBlockNoteEditor(); const dict = useDictionary(); diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 6b72e3eab3..d4f4f1050d 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -23,10 +23,10 @@ export default function FloatingThreadController(props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) { const editor = useBlockNoteEditor(); const dict = useDictionary(); diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index e84e6a2d67..91d8010b09 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -15,10 +15,10 @@ export const FilePanelController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index 0a2275d1ba..7edc69c135 100644 --- a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx @@ -39,10 +39,10 @@ export const DesktopFormattingToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor< BlockSchema, diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index 6266ed998f..b72ba4539c 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -12,10 +12,10 @@ export const FormattingToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const keyboardOpen = useVirtualKeyboard(); diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index d886012526..e5f194f023 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -20,10 +20,10 @@ export const LinkToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 1e49bb8402..e0464abed9 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -62,10 +62,10 @@ export const SideMenuController = (props: { floatingUIOptions?: Partial; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); const state = useExtensionState(SideMenuExtension, { diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 81924c808c..41786bb047 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -50,7 +50,7 @@ export function GridSuggestionMenuController< * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; } & (ItemType extends DefaultReactGridSuggestionItem ? { // can be undefined diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 2627264fc6..b774c15da8 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -44,7 +44,7 @@ export function SuggestionMenuController< * the ambient portal target (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; } & (ItemType extends DefaultReactSuggestionItem ? { // can be undefined diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 25136102a1..64187f9c1c 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -34,10 +34,10 @@ export const TableHandlesController = < extendButton?: FC; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal element (the editor's `bn-container` by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); diff --git a/packages/react/src/editor/PortalElementOverride.tsx b/packages/react/src/editor/PortalElementOverride.tsx index 07239e6e7c..3459c2196e 100644 --- a/packages/react/src/editor/PortalElementOverride.tsx +++ b/packages/react/src/editor/PortalElementOverride.tsx @@ -55,7 +55,7 @@ export function usePortalElement(): HTMLElement | null { * - `null` — `document.body`, escaping every ancestor. */ export function PortalElementOverride(props: { - target?: HTMLElement | null; + target?: HTMLElement; children?: ReactNode; }) { const { target, children } = props; @@ -67,43 +67,36 @@ export function PortalElementOverride(props: { typeof document === "undefined" ? null : document.createElement("div"), ); - const resolvedTarget = - target === null - ? typeof document === "undefined" - ? undefined - : document.body - : target; - useIsomorphicLayoutEffect(() => { - if (!portalElement || !resolvedTarget) { + if (!portalElement || !target) { return; } - resolvedTarget.appendChild(portalElement); + target.appendChild(portalElement); return () => portalElement.remove(); - }, [portalElement, resolvedTarget]); + }, [portalElement, target]); // React does not render this element, so the same theming the editor // container gets from its props is applied here by hand. useIsomorphicLayoutEffect(() => { - if (!portalElement || !resolvedTarget) { + if (!portalElement || !target) { return; } applyThemedRoot?.(portalElement); - }, [portalElement, resolvedTarget, applyThemedRoot]); + }, [portalElement, target, applyThemedRoot]); // Floating UI portalled out of the editor's DOM tree is still the editor's // UI: registering the element keeps `editor.isWithinEditor` (and the focus // tracking built on it) true for what renders inside. useEffect(() => { - if (!portalElement || !resolvedTarget) { + if (!portalElement || !target) { return; } editor.registerPortalElement(portalElement); return () => editor.unregisterPortalElement(portalElement); - }, [editor, portalElement, resolvedTarget]); + }, [editor, portalElement, target]); if (target === undefined) { return children; diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index 30e2e38a9c..ee2cca6301 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -3,9 +3,8 @@ * * - `HTMLElement` — used as-is. * - `string` — treated as a CSS selector and resolved via `document.querySelector`. - * - `null` — explicit `document.body` (escape any ancestor stacking context). */ -export type PortalElement = HTMLElement | string | null; +export type PortalElement = HTMLElement | string; /** * Per-element portal targets for BlockNote's floating UI. Keys mirror the @@ -36,9 +35,7 @@ export function resolvePortalElement( if (target === undefined) { return undefined; } - if (target === null) { - return typeof document !== "undefined" ? document.body : undefined; - } + if (typeof target === "string") { if (typeof document === "undefined") { return undefined; diff --git a/packages/react/src/hooks/useEditorDomElement.ts b/packages/react/src/hooks/useEditorDomElement.ts index 856765a056..2eaf058b8e 100644 --- a/packages/react/src/hooks/useEditorDomElement.ts +++ b/packages/react/src/hooks/useEditorDomElement.ts @@ -10,9 +10,15 @@ export function useEditorDOMElement(editor?: BlockNoteEditor) { editor = editorContext?.editor; } + if (!editor) { + throw new Error( + "'editor' is required in `useEditorDOMElement`, either from BlockNoteContext or as a function argument", + ); + } + return useEditorState({ editor, - selector: (ctx) => ctx.editor?.domElement, + selector: (ctx) => ctx.editor.domElement, equalityFn: (a, b) => a === b, on: "mount", }); From ceb93bdce6157bea0cbb4d4cec60780618e28568 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 09:27:49 +0200 Subject: [PATCH 14/21] refactor: require portalRoot and never fall back to the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `portalRoot` is now required on `Menu.Root`, `Popover.Root` and the toolbar select. The UI-library adapters never read the editor's context, so they cannot fall back to it themselves; making the prop required lets the compiler enforce what was a convention. Call sites pass the ambient element as-is — `null` before the editor has mounted — instead of coercing it to `undefined`. What each adapter does with `null` follows its library, so none of them portal to the document body: - shadcn passes it through: Base UI waits for a container. - ariakit toggles `portal` off: it renders inline until there is one (Ariakit appends a fresh div to the body for null and undefined alike). - mantine already rendered inline on a falsy value. The shadcn tooltips (`ToolbarButton`, `Badge`) keep reading the ambient element through `useEditorPortalElement`. That is the one documented exception: mantine and ariakit tooltips render inline and would ignore a passed element, so a prop would buy nothing there. --- packages/ariakit/src/menu/Menu.tsx | 9 +++++---- packages/ariakit/src/popover/Popover.tsx | 9 +++++---- packages/ariakit/src/toolbar/ToolbarSelect.tsx | 5 ++++- packages/react/src/components/Comments/Comment.tsx | 2 +- packages/react/src/components/Comments/EmojiPicker.tsx | 2 +- .../DefaultButtons/ColorStyleButton.tsx | 2 +- .../DefaultButtons/CreateLinkButton.tsx | 2 +- .../DefaultButtons/FileCaptionButton.tsx | 2 +- .../DefaultButtons/FileRenameButton.tsx | 2 +- .../DefaultButtons/FileReplaceButton.tsx | 2 +- .../FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx | 2 +- .../LinkToolbar/DefaultButtons/EditLinkButton.tsx | 2 +- .../SideMenu/DefaultButtons/DragHandleButton.tsx | 2 +- .../DragHandleMenu/DefaultItems/BlockColorsItem.tsx | 2 +- .../src/components/TableHandles/TableCellButton.tsx | 2 +- .../TableCellMenu/DefaultButtons/ColorPicker.tsx | 2 +- .../react/src/components/TableHandles/TableHandle.tsx | 2 +- .../TableHandleMenu/DefaultButtons/ColorPicker.tsx | 2 +- .../react/src/components/Versioning/CurrentSnapshot.tsx | 2 +- packages/react/src/components/Versioning/Snapshot.tsx | 2 +- packages/react/src/editor/ComponentsContext.tsx | 6 +++--- packages/shadcn/src/badge/Badge.tsx | 4 ++++ packages/shadcn/src/menu/Menu.tsx | 8 ++++---- packages/shadcn/src/popover/popover.tsx | 8 ++++---- packages/shadcn/src/toolbar/Toolbar.tsx | 9 +++++++-- 25 files changed, 53 insertions(+), 39 deletions(-) diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 94ec44ecab..f1a5c527c0 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -15,9 +15,7 @@ import { createContext, forwardRef, useContext } from "react"; // Threads the `portalRoot` override from `Menu` (the provider) down to // `MenuDropdown`, where ariakit's `portalElement` prop actually lives. -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -66,7 +64,10 @@ export const MenuDropdown = forwardRef< {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index d0e557998c..93d2d8141b 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -8,9 +8,7 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -40,7 +38,10 @@ export const PopoverContent = forwardRef< className || "", variant === "panel-popover" ? "bn-ak-panel-popover" : "", )} - portalElement={portalRoot ?? undefined} + // Ariakit falls back to a body-appended div for a missing element, so + // don't portal at all until there is one (editor not mounted yet). + portal={portalRoot !== null} + portalElement={portalRoot} ref={ref} > {children} diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 4b28092eb3..4eca7d3a5f 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -47,7 +47,10 @@ export const ToolbarSelect = forwardRef< className={mergeCSSClasses("bn-ak-popover", className || "")} ref={ref} gutter={4} - portalElement={portalRoot ?? undefined} + // Ariakit falls back to a body-appended div for a missing element, + // so don't portal at all until there is one (editor not mounted yet). + portal={portalRoot !== null} + portalElement={portalRoot} > {items.map((option) => (
{ // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the dropdown, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 31567965e3..f8dc565d2e 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -138,7 +138,7 @@ export const CreateLinkButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index bfca8ca31b..7058398840 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -114,7 +114,7 @@ export const FileCaptionButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 8de84ad1f8..b516f58d39 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -114,7 +114,7 @@ export const FileRenameButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 97312cac63..59ccca4fb5 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -75,7 +75,7 @@ export const FileReplaceButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 0319053db8..8f2ef1f352 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -219,7 +219,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { // Portal the dropdown into the editor's themed portal target so it // inherits styling; on mobile `preventFocusOnOpen` keeps focus in the // editor so the on-screen keyboard stays up. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} /> ); diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index 41b6e03fc2..04f3233080 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -17,7 +17,7 @@ export const EditLinkButton = ( return ( { diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index fc68457d1a..df96f2a6d6 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -79,7 +79,7 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { ( - undefined, -); +const PortalRootContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -86,7 +84,9 @@ export const MenuDropdown = forwardRef< // The `portalRoot` supplied at the call site is a themed `.bn-root`, so the // menu inherits light/dark mode instead of the document body's. - const container = useContext(PortalRootContext) ?? undefined; + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(PortalRootContext); if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index a6735dfd75..886a7af479 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -5,9 +5,7 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], @@ -67,7 +65,9 @@ export const PopoverContent = forwardRef< // The `portalRoot` supplied at the call site is a themed `.bn-root`, so // popovers inherit light/dark mode instead of the document body's, and escape // the mobile formatting toolbar's horizontal scroll clip. - const container = useContext(PortalRootContext) ?? undefined; + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(PortalRootContext); return ( ( // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. + // NOTE: Only ShadCN Badge / Tooltip depend on useEditorPortalElement. + // Alternative would be to pass a portalElement to these components, but they + // would be ignored by ariakit / mantine. For now keep these two exceptions + // (ideally skin components don't have a dependency on the editor's context) + const editorPortalElement = useEditorPortalElement(); const trigger = @@ -112,7 +117,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -169,7 +174,7 @@ export const ToolbarSelect = forwardRef< Date: Sat, 5 Sep 2026 09:43:00 +0200 Subject: [PATCH 15/21] fix: pass the required portal element in the SettingsSelect examples `vp run build` typechecks example projects that lint doesn't cover: the three `SettingsSelect` copies render a `Toolbar.Select` without the now required prop. They sit inside `BlockNoteView`, so they read the ambient element and pass it. Also drops the last `?? undefined` on a portal value: the shadcn `Badge` tooltip passes the element as-is, like `ToolbarButton` already does, so nothing falls back to the body. --- .../05-comments/src/SettingsSelect.tsx | 11 ++++++++++- .../06-comments-with-sidebar/src/SettingsSelect.tsx | 11 ++++++++++- .../11-versioning-yjs13/src/SettingsSelect.tsx | 11 ++++++++++- packages/shadcn/src/badge/Badge.tsx | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx +++ b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx +++ b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index c3f0c57692..ddb6067d0c 100644 --- a/packages/shadcn/src/badge/Badge.tsx +++ b/packages/shadcn/src/badge/Badge.tsx @@ -58,7 +58,7 @@ export const Badge = forwardRef< {mainTooltip} From 17eca7233c5c1d89f66f43bd1873657d8fa17258 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 11:03:06 +0200 Subject: [PATCH 16/21] test(mantine): pin the render profile of BlockNoteView around portals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal setups — a `portalElements` default, a per-element target, a controller's `portalElement` — must add no React commits or child renders at mount, and cost the same as the default setup on an unrelated parent re-render, with and without StrictMode. Counts are relative to the default setup measured in the same run, so unrelated editor render changes don't break them. Colocated browser-mode test rather than end-to-end: it needs a real browser (jsdom schedules commits differently) but nothing from the e2e harness, and renders with `createRoot` + `act` since `vitest-browser-react` only exists in the tests package. Red on the previous portal implementation (5 commits vs 3 at mount with an external default target), green here and on the base branch. --- .../src/BlockNoteView.browser.test.tsx | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 packages/mantine/src/BlockNoteView.browser.test.tsx diff --git a/packages/mantine/src/BlockNoteView.browser.test.tsx b/packages/mantine/src/BlockNoteView.browser.test.tsx new file mode 100644 index 0000000000..17773950cd --- /dev/null +++ b/packages/mantine/src/BlockNoteView.browser.test.tsx @@ -0,0 +1,172 @@ +import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core"; +import { + getDefaultReactSlashMenuItems, + type PortalElementsMap, + SuggestionMenuController, +} from "@blocknote/react"; +import { act, Profiler, StrictMode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; + +import { BlockNoteView } from "./BlockNoteView.js"; +import "./style.css"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Guards the render profile of `BlockNoteView` around portalling: routing the + * floating UI elsewhere must not cost React commits or child renders. An + * earlier portal implementation added one to three commits at mount depending + * on the setup; that is the regression these pin. + * + * Every number is relative to the default setup measured in the same run, so + * unrelated changes to how the editor itself renders don't break them. Both + * StrictMode settings are covered: its double-invoked effects are where + * append/remove and register/unregister pairs go wrong. + */ + +type Setup = + | "default" + | "portalElements.default" + | "portalElements.slashMenu" + | "controller"; + +const PORTAL_SETUPS: Setup[] = [ + "portalElements.default", + "portalElements.slashMenu", + "controller", +]; + +let editor: BlockNoteEditor; +let root: Root | undefined; +let container: HTMLDivElement; +let target: HTMLDivElement; +let commits = 0; +let childRenders = 0; +let rerenderParent: () => void = () => {}; + +function onRender() { + commits++; +} + +function Child() { + childRenders++; + return null; +} + +function Harness(props: { setup: Setup }) { + const [, setTick] = useState(0); + rerenderParent = () => setTick((tick) => tick + 1); + + const portalElements: PortalElementsMap | undefined = + props.setup === "portalElements.default" + ? { default: target } + : props.setup === "portalElements.slashMenu" + ? { slashMenu: target } + : undefined; + + return ( + + + {props.setup === "controller" && ( + + filterSuggestionItems( + getDefaultReactSlashMenuItems(editor), + query, + ) + } + portalElement={target} + /> + )} + + + + ); +} + +async function mount(setup: Setup, strict: boolean) { + editor = BlockNoteEditor.create(); + commits = 0; + childRenders = 0; + root = createRoot(container); + const tree = ; + await act(async () => { + root!.render(strict ? {tree} : tree); + }); + if (!container.querySelector(".bn-editor")) { + throw new Error("editor did not mount"); + } + return { commits, childRenders }; +} + +async function unmount() { + await act(async () => { + root?.unmount(); + }); + root = undefined; + editor._tiptapEditor.destroy(); +} + +beforeEach(() => { + container = document.createElement("div"); + target = document.createElement("div"); + document.body.append(container, target); +}); + +afterEach(async () => { + if (root) { + await unmount(); + } + container.remove(); + target.remove(); +}); + +describe.each([{ strict: false }, { strict: true }])( + "BlockNoteView render profile with portals (StrictMode: $strict)", + ({ strict }) => { + test("portal setups add no commits or child renders at mount", async () => { + const baseline = await mount("default", strict); + await unmount(); + + for (const setup of PORTAL_SETUPS) { + const measured = await mount(setup, strict); + await unmount(); + expect({ setup, ...measured }).toEqual({ setup, ...baseline }); + } + }); + + test("an unrelated parent re-render costs the same with portals as without", async () => { + async function rerenderCost(setup: Setup) { + await mount(setup, strict); + const before = { commits, childRenders }; + await act(async () => { + rerenderParent(); + }); + const cost = { + commits: commits - before.commits, + childRenders: childRenders - before.childRenders, + }; + await unmount(); + return cost; + } + + const baseline = await rerenderCost("default"); + for (const setup of PORTAL_SETUPS) { + expect({ setup, ...(await rerenderCost(setup)) }).toEqual({ + setup, + ...baseline, + }); + } + }); + }, +); From 7c50b00c43f97b63395082f86bf464d37d8bd402 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 21:50:49 +0200 Subject: [PATCH 17/21] fix(mantine,ariakit): style portalled menus by their own class Menus now portal out of the toolbar, side menu and table handle that open them, so rules scoped on those ancestors (`.bn-toolbar .mantine-Menu-item`, `.bn-side-menu .mantine-Menu-dropdown`, ...) stopped applying: 14px items, a missing min-width, an ariakit gap on every popover. Scope on the dropdowns' own classes instead, and keep the ariakit gap for form popovers only. The e2e drag-handle menu selector no longer assumes nesting either. --- packages/ariakit/src/style.css | 4 +++- packages/mantine/src/blocknoteStyles.css | 25 +++++++----------------- tests/src/utils/const.ts | 4 +++- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 59974a6d60..0a7ad05825 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -38,7 +38,9 @@ inset 0 1px 1px 1px var(--shadow); } -.bn-toolbar .bn-ak-popover { +/* Form popovers (link, caption, rename) stack their fields; lists keep the + tighter spacing. */ +.bn-ariakit .bn-form-popover { gap: 0.5rem; } diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index de73c5a66b..11408d5a90 100644 --- a/packages/mantine/src/blocknoteStyles.css +++ b/packages/mantine/src/blocknoteStyles.css @@ -216,15 +216,16 @@ on touch devices (e.g. the mobile formatting toolbar). */ height: 40px; } -.bn-toolbar .mantine-Menu-item { +/* Menus are portalled into the editor container, so they can't be styled + through the toolbar, side menu or table handle that opened them; scope on + the dropdowns themselves instead. */ +.bn-mantine .bn-select .mantine-Menu-item, +.bn-mantine .bn-menu-dropdown .mantine-Menu-item, +.bn-mantine .bn-table-handle-menu .mantine-Menu-item { font-size: 12px; height: 30px; } -.bn-toolbar .mantine-Menu-item:hover { - background-color: var(--bn-colors-hovered-background); -} - .bn-mantine .bn-form-popover { background-color: var(--bn-colors-menu-background); border: var(--bn-border); @@ -494,12 +495,6 @@ on touch devices (e.g. the mobile formatting toolbar). */ overflow: visible; } -.bn-side-menu .mantine-Menu-item, -.bn-table-handle-menu .mantine-Menu-item { - font-size: 12px; - height: 30px; -} - .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) { background-color: transparent; } @@ -519,10 +514,8 @@ on touch devices (e.g. the mobile formatting toolbar). */ display: flex; } -.bn-side-menu .mantine-Menu-dropdown { +.bn-mantine .bn-drag-handle-menu { min-width: 100px; - padding: 2px; - position: absolute; } /* Image Panel styling*/ @@ -736,10 +729,6 @@ on touch devices (e.g. the mobile formatting toolbar). */ color: var(--bn-colors-disabled-text); } -.bn-mantine .bn-action-toolbar .mantine-Menu-itemLabel { - font-size: 12px; -} - /* Badge styling */ .bn-mantine .bn-badge-group { display: flex; diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts index 93d7f13b73..54d8165968 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -18,7 +18,9 @@ export const TABLE_SELECTOR = `[data-content-type="table"]`; export const DRAG_HANDLE_SELECTOR = `[data-test="dragHandle"]`; export const DRAG_HANDLE_ADD_SELECTOR = `[data-test="dragHandleAdd"]`; -export const DRAG_HANDLE_MENU_SELECTOR = `.bn-side-menu > .bn-menu-dropdown`; +// The menu is portalled into the editor container, so it is not a descendant +// of the side menu that opens it; match it by its own class. +export const DRAG_HANDLE_MENU_SELECTOR = `.bn-drag-handle-menu`; export const SLASH_MENU_SELECTOR = `.bn-suggestion-menu`; export const EMOJI_PICKER_SELECTOR = `.bn-grid-suggestion-menu`; From 4150a35d2f53f6ca7af0d677898e05e195a91b78 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 21:51:15 +0200 Subject: [PATCH 18/21] feat(react): render a floating component's menus inside its wrapper Every menu, popover and form a floating component (toolbar, side menu, table handle, ...) opens now portals into a zero-size anchor next to that component, inside the wrapper floating-ui positions. So they share its stacking context and visibility (the ariakit colors submenu paints above the drag handle without a z-index override; ariakit and shadcn dropdowns hide with their toolbar instead of staying orphaned when it scrolls away), follow it when `portalElements` relocates it, and, for the mobile toolbar, sit outside its scroll strip, which iOS WebKit would otherwise not paint. `GenericPopover`'s closing snapshot must ignore the anchor's holder, or a popover whose children are already gone would snapshot an empty wrapper and vanish instead of fading out. The adapter-private contexts in ariakit and shadcn are renamed so they no longer share a name with the react package's context. --- docs/content/docs/react/components/index.mdx | 2 +- .../20-portal-elements/README.md | 4 +- packages/ariakit/src/menu/Menu.tsx | 12 +- packages/ariakit/src/popover/Popover.tsx | 10 +- .../MobileFormattingToolbarController.tsx | 12 +- .../components/Popovers/GenericPopover.tsx | 26 ++- .../src/editor/PortalElementOverride.tsx | 121 +++++++++- packages/shadcn/src/menu/Menu.tsx | 14 +- packages/shadcn/src/popover/popover.tsx | 10 +- tests/src/end-to-end/ariakit/ariakit.test.tsx | 28 ++- .../portals/floatingComponentMenus.test.tsx | 208 ++++++++++++++++++ 11 files changed, 409 insertions(+), 38 deletions(-) create mode 100644 tests/src/end-to-end/portals/floatingComponentMenus.test.tsx diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index a5a31ab084..d22664ac42 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -17,7 +17,7 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can ## Configuring Portal Targets -By default, all floating UI elements (toolbars, menus, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. If your layout needs them to escape — e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context — pass a `portalElements` prop to `BlockNoteView`: +By default, the floating components (formatting toolbar, side menu, slash menu, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. The menus and popovers a floating component opens render inside that component's wrapper, so they move and hide with it. If your layout needs the floating components to escape the container, e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context, pass a `portalElements` prop to `BlockNoteView`: ```tsx diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 11cc94601a..9f6b9f9671 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -13,9 +13,9 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -// Threads the `portalElement` override from `Menu` (the provider) down to -// `MenuDropdown`, where ariakit's `portalElement` prop actually lives. -const PortalElementContext = createContext(null); +// Hands the `portalElement` prop from `Menu` (the root) down to +// `MenuDropdown`, where Ariakit takes it. +const MenuPortalElementContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -38,9 +38,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { setOpen={onOpenChange} virtualFocus={true} > - + {children} - + ); }; @@ -58,7 +58,7 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); - const portalElement = useContext(PortalElementContext); + const portalElement = useContext(MenuPortalElementContext); return ( (null); +// Hands the `portalElement` prop from `Popover` (the root) down to +// `PopoverContent`, where Ariakit takes it. +const PopoverPortalElementContext = createContext(null); export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -29,7 +31,7 @@ export const PopoverContent = forwardRef< assertEmpty(rest); - const portalElement = useContext(PortalElementContext); + const portalElement = useContext(PopoverPortalElementContext); return ( - + {children} - + ); }; diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index b074604684..7d02378ee1 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -2,6 +2,7 @@ import { FC, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { + PortalElementAnchor, PortalElementOverride, usePortalElement, } from "../../editor/PortalElementOverride.js"; @@ -103,10 +104,15 @@ function MobileFormattingToolbar(props: { return null; } + // The anchor is rendered next to the toolbar, not inside it: the toolbar + // scrolls horizontally, and iOS WebKit clips positioned descendants of a + // scroll container, so its dropdowns must not be descendants of it. return createPortal( -
- -
, + +
+ +
+
, portalElement, ); } diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 276ecfe447..73c26cce9b 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,7 +14,11 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; -import { usePortalElement } from "../../editor/PortalElementOverride.js"; +import { + hasChildrenBesidesPortalElementAnchor, + PortalElementAnchor, + usePortalElement, +} from "../../editor/PortalElementOverride.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -206,7 +210,13 @@ export const GenericPopover = ( useEffect( () => { if (status === "initial" || status === "open") { - if (ref.current?.innerHTML) { + // Only store while the children have rendered something. In the + // render where a controller flips `open` to `false`, its children are + // typically already gone while `status` is still "open", and that + // empty state must not replace the snapshot the closing popover is + // about to show. The wrapper is never truly empty though: it always + // contains the `PortalElementAnchor` holder. + if (ref.current && hasChildrenBesidesPortalElementAnchor(ref.current)) { innerHTML.current = ref.current.innerHTML; } } @@ -255,12 +265,20 @@ export const GenericPopover = ( ); } + // The children render inside a `PortalElementAnchor`: the menus and popovers + // they open portal into this wrapper instead of the editor container, so + // they share its stacking context and visibility (they paint above what the + // wrapper paints above, and hide when it hides) and move with it when + // `portalElements` relocates it. Rendering them inline instead would clip + // them to the toolbar, or, on iOS, to a scrolling one. See + // `PortalElementAnchor` for the details; behaviour is pinned by + // `tests/src/end-to-end/portals/floatingComponentMenus.test.tsx`. if (!props.focusManagerProps?.disabled) { return (
- {props.children} + {props.children}
@@ -270,7 +288,7 @@ export const GenericPopover = ( return (
- {props.children} + {props.children}
); diff --git a/packages/react/src/editor/PortalElementOverride.tsx b/packages/react/src/editor/PortalElementOverride.tsx index 3459c2196e..96c1178ccf 100644 --- a/packages/react/src/editor/PortalElementOverride.tsx +++ b/packages/react/src/editor/PortalElementOverride.tsx @@ -1,6 +1,7 @@ import { createContext, ReactNode, + useCallback, useContext, useEffect, useLayoutEffect, @@ -14,16 +15,18 @@ import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; -// Set only by `PortalElementOverride`; the default comes from the editor -// itself, see `usePortalElement`. +// Set by `PortalElementOverride` (a root to escape to) and by +// `PortalElementAnchor` (a UI element's own wrapper); the default comes from +// the editor itself, see `usePortalElement`. const PortalElementContext = createContext(null); /** - * The element the editor's floating UI (toolbars, menus, popovers) should - * portal into: the nearest {@link PortalElementOverride}'s element, or by - * default the editor's own container. Either way it is a themed `.bn-root`, - * so portalled UI keeps the editor's styling and color scheme wherever in the - * DOM it lands. + * The element the floating UI below should portal into: the nearest + * {@link PortalElementAnchor} (the wrapper of the toolbar, side menu, … that + * opens it), else the nearest {@link PortalElementOverride}'s element, else by + * default the editor's own container. All of these sit inside a themed + * `.bn-root`, so portalled UI keeps the editor's styling and color scheme + * wherever in the DOM it lands. * * `null` until the editor has mounted, and on the server. Consumers render * nothing until it exists. @@ -108,3 +111,107 @@ export function PortalElementOverride(props: { ); } + +/** + * An anchor for the floating UI a UI element opens (its menus, popovers and + * forms): a zero-size, absolutely positioned element next to that UI element, + * inside the wrapper that positions it. What portals into it stays a DOM + * descendant of that wrapper, so it shares the wrapper's stacking context and + * visibility (it hides when the UI element hides) while taking no part in its + * layout. + * + * The anchor exists from the first render (created up front and attached to + * the rendered holder on commit, before any effect runs), so consumers never + * see a `null` and nothing re-renders to pick it up. The holder is rendered + * by React so that it, and with it the anchor, is re-attached whenever the + * wrapper's content is re-rendered. + */ +function usePortalElementAnchor(): { + anchor: HTMLElement | null; + holder: ReactNode; +} { + const [anchor] = useState(() => { + if (typeof document === "undefined") { + return null; + } + const element = document.createElement("span"); + element.className = "bn-portal-anchor"; + return element; + }); + + const holderRef = useCallback( + (holder: HTMLElement | null) => { + if (holder && anchor && anchor.parentElement !== holder) { + holder.appendChild(anchor); + } + }, + [anchor], + ); + + const holder = ( + + ); + + return { anchor, holder }; +} + +const PORTAL_ELEMENT_ANCHOR_HOLDER_CLASS = "bn-portal-anchor-holder"; + +/** + * Whether `element` has rendered children other than a + * {@link PortalElementAnchor}'s holder. The holder means a wrapper that renders + * an anchor is never empty, so "the UI element rendered nothing" has to be + * checked with this instead of the wrapper's `innerHTML`. + */ +export function hasChildrenBesidesPortalElementAnchor( + element: HTMLElement, +): boolean { + return Array.from(element.childNodes).some( + (node) => + !( + node instanceof Element && + node.classList.contains(PORTAL_ELEMENT_ANCHOR_HOLDER_CLASS) + ), + ); +} + +/** + * Renders a portal anchor inside a UI element's wrapper and makes it the + * portal element for everything below (see {@link usePortalElementAnchor}): the + * menus and popovers a toolbar, side menu or table handle opens render inside + * the wrapper that positions and hides that UI element. Nested menus resolve + * to the same anchor, never to their parent dropdown, which may clip. + * + * The anchor is a sibling of the UI element, not a descendant, so it is never + * inside a scrolling part of it (iOS WebKit clips positioned descendants of + * scroll containers); and a `portalElements` override that relocates the + * wrapper takes the anchor, and so the popups, along with it. + * + * Pass a function as `children` to receive the portal element for props that + * need it explicitly. + */ +export function PortalElementAnchor(props: { + children?: ReactNode | ((portalElement: HTMLElement | null) => ReactNode); +}) { + const { anchor, holder } = usePortalElementAnchor(); + const ambient = usePortalElement(); + const portalElement = anchor ?? ambient; + + const children = + typeof props.children === "function" + ? props.children(portalElement) + : props.children; + + return ( + <> + {holder} + + {children} + + + ); +} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 767c2c5ef6..7a66eac19e 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -5,7 +5,9 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalElementContext = createContext(null); +// Hands the `portalElement` prop from `Menu` (the root) down to +// `MenuDropdown`, where the dropdown's `container` is set. +const MenuPortalElementContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -29,9 +31,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - + {children} - + ); } else { @@ -40,9 +42,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - + {children} - + ); } @@ -86,7 +88,7 @@ export const MenuDropdown = forwardRef< // menu inherits light/dark mode instead of the document body's. // `null` (editor not mounted yet) makes Base UI wait for a container // instead of falling back to the body; nothing is open at that point. - const container = useContext(PortalElementContext); + const container = useContext(MenuPortalElementContext); if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 66f0a94961..98bb1058a5 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -5,7 +5,9 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalElementContext = createContext(null); +// Hands the `portalElement` prop from `Popover` (the root) down to +// `PopoverContent`, where the content's `container` is set. +const PopoverPortalElementContext = createContext(null); export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], @@ -28,9 +30,9 @@ export const Popover = ( return ( - + {children} - + ); }; @@ -67,7 +69,7 @@ export const PopoverContent = forwardRef< // the mobile formatting toolbar's horizontal scroll clip. // `null` (editor not mounted yet) makes Base UI wait for a container // instead of falling back to the body; nothing is open at that point. - const container = useContext(PortalElementContext); + const container = useContext(PopoverPortalElementContext); return ( { await expectElement(document.body).toMatchScreenshot( "ariakit-drag-handle-menu", ); + + // The colors submenu opens over the side menu. Menus render inside the + // side menu's wrapper, so they paint above its buttons; a menu portalled + // elsewhere with Ariakit's own z-index would be covered by the drag handle. + await moveMouseOverElement( + Array.from(document.querySelectorAll("[role=menuitem]")).find((item) => + item.textContent?.includes("Colors"), + )!, + ); + const submenu = await waitForSelector(".bn-color-picker-dropdown"); + const handle = document + .querySelector(DRAG_HANDLE_SELECTOR)! + .getBoundingClientRect(); + const onTop = document.elementFromPoint( + handle.x + handle.width / 2, + handle.y + handle.height / 2, + ); + const submenuRect = submenu.getBoundingClientRect(); + const overlaps = + handle.x < submenuRect.right && + handle.right > submenuRect.x && + handle.y < submenuRect.bottom && + handle.bottom > submenuRect.y; + if (overlaps) { + expect(submenu.contains(onTop)).toBe(true); + } }); test("Check image toolbar", async () => { await focusOnEditor(); diff --git a/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx b/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx new file mode 100644 index 0000000000..d6c54d05d8 --- /dev/null +++ b/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx @@ -0,0 +1,208 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView as AriakitBlockNoteView } from "@blocknote/ariakit"; +import "@blocknote/ariakit/style.css"; +import { BlockNoteView as MantineBlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { PortalElementsMap, useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView as ShadCNBlockNoteView } from "@blocknote/shadcn"; +import "@blocknote/shadcn/style.css"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { ComponentType, useEffect } from "react"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { waitForSelector } from "../../utils/editor.js"; + +// The menus and popovers a floating component opens (here: the formatting +// toolbar's block type menu) render inside that component's wrapper, next to +// the component, not in the editor container. These tests pin what that buys +// the user: the menu hides together with its toolbar, it travels with the +// toolbar when `portalElements` relocates it, and the toolbar still fades out +// showing its content. They run per skin because each skin brings its own +// menu implementation, and Mantine's would hide its menu on its own +// (`hideDetached`) while Ariakit's and shadcn's would stay orphaned on screen. + +type ViewProps = { + editor: BlockNoteEditor; + portalElements?: PortalElementsMap; +}; + +const skins: { name: string; View: ComponentType }[] = [ + { + name: "mantine", + View: (props) => , + }, + { + name: "ariakit", + View: (props) => , + }, + { + name: "shadcn", + View: (props) => , + }, +]; + +function ScrollingEditor(props: { + View: ComponentType; + portalElements?: PortalElementsMap; + onEditor: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote({ + initialContent: Array.from({ length: 12 }, (_, i) => ({ + type: "paragraph" as const, + content: `Paragraph ${i}`, + })), + }); + + useEffect(() => { + props.onEditor(editor); + }, [editor, props]); + + // A short scroll container, so a selection can be scrolled out of view. + return ( +
+ +
+ ); +} + +async function renderEditor(props: { + View: ComponentType; + portalElements?: PortalElementsMap; +}) { + let editor: BlockNoteEditor | undefined; + + await render( + { + editor = value; + }} + />, + ); + await waitForSelector(".bn-editor"); + await vi.waitFor(() => { + if (!editor) { + throw new Error("Editor was not created"); + } + }); + + if (!editor) { + throw new Error("Editor was not created"); + } + + return editor; +} + +function createPortalTarget(id: string) { + const target = document.createElement("div"); + target.id = id; + target.dataset.testPortalTarget = ""; + document.body.append(target); + return target; +} + +/** Selects text in the first paragraph, which shows the formatting toolbar. */ +async function showFormattingToolbar(editor: BlockNoteEditor) { + editor.focus(); + editor._tiptapEditor.commands.setTextSelection({ from: 3, to: 12 }); + return waitForSelector(".bn-formatting-toolbar"); +} + +/** + * Opens the block type menu from the toolbar's first control. Ariakit and + * shadcn render the menu as a listbox, Mantine as a menu. + */ +async function openBlockTypeMenu(toolbar: HTMLElement) { + await userEvent.click(toolbar.querySelector("button, [role=combobox]")!); + return waitForSelector("[role=menu], [role=listbox]"); +} + +function isVisible(element: Element) { + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + getComputedStyle(element).visibility !== "hidden" + ); +} + +afterEach(() => { + document + .querySelectorAll("[data-test-portal-target]") + .forEach((target) => target.remove()); +}); + +describe.each(skins)( + "Menus opened from a floating component ($name)", + ({ View }) => { + test("render next to the component, inside its wrapper", async () => { + const editor = await renderEditor({ View }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + + const wrapper = toolbar.parentElement!; + expect(wrapper.contains(menu)).toBe(true); + expect(toolbar.contains(menu)).toBe(false); + }); + + test("hide when the component hides", async () => { + const editor = await renderEditor({ View }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + expect(isVisible(menu)).toBe(true); + + // Scroll the selection out of view: the toolbar's reference is hidden, so + // its wrapper gets `visibility: hidden`, and the menu must go with it. + const scroller = document.querySelector( + "[data-test=scroller]", + )!; + scroller.scrollTop = scroller.scrollHeight; + scroller.dispatchEvent(new Event("scroll")); + + await vi.waitFor(() => { + expect(getComputedStyle(toolbar.parentElement!).visibility).toBe( + "hidden", + ); + }); + expect(isVisible(menu)).toBe(false); + }); + + test("follow the component when portalElements relocates it", async () => { + const target = createPortalTarget("portal-target"); + const editor = await renderEditor({ + View, + portalElements: { default: target }, + }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + + expect(target.contains(toolbar)).toBe(true); + expect(target.contains(menu)).toBe(true); + expect(document.querySelector(".bn-container")!.contains(menu)).toBe( + false, + ); + }); + }, +); + +describe("A floating component that closes", () => { + test("still shows its content while fading out", async () => { + const editor = await renderEditor({ View: skins[0].View }); + const toolbar = await showFormattingToolbar(editor); + + // Collapse the selection: the live toolbar is replaced by a snapshot that + // fades out, and that snapshot must still show the toolbar. + editor._tiptapEditor.commands.setTextSelection(3); + await vi.waitFor(() => { + expect(document.querySelector(".bn-formatting-toolbar")).not.toBe( + toolbar, + ); + }); + expect(document.querySelector(".bn-formatting-toolbar")).not.toBeNull(); + + await vi.waitFor(() => { + expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); + }); + }); +}); From 4dbe297a80860356e6b7fd8b07ebe22174b89317 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 21:51:18 +0200 Subject: [PATCH 19/21] fix(mantine): drop the toolbar focus trap Mantine's `useFocusTrap`, armed once focus was within the toolbar, moved focus back into the toolbar a tick after a menu or form opened. With those now portalled next to the toolbar rather than inside it, that stole focus from the link form's URL field (0 ms clicks in e2e, and always for the link toolbar's Edit button). Tab now moves through the buttons and on, as in the other skins. The color menu's deferred `editor.focus()` existed only to work around the trap. --- packages/mantine/src/toolbar/Toolbar.tsx | 14 ++-- .../DefaultButtons/ColorStyleButton.tsx | 10 +-- .../linktoolbar/linkToolbar.test.tsx | 64 +++++++++++++++++++ 3 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx diff --git a/packages/mantine/src/toolbar/Toolbar.tsx b/packages/mantine/src/toolbar/Toolbar.tsx index a31e53bc8c..75f8b65058 100644 --- a/packages/mantine/src/toolbar/Toolbar.tsx +++ b/packages/mantine/src/toolbar/Toolbar.tsx @@ -2,7 +2,6 @@ import { Flex } from "@mantine/core"; import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { mergeRefs, useFocusTrap, useFocusWithin } from "@mantine/hooks"; import { forwardRef } from "react"; type ToolbarProps = ComponentProps["Generic"]["Toolbar"]["Root"]; @@ -20,17 +19,14 @@ export const Toolbar = forwardRef( assertEmpty(rest); - // use a focus trap so that tab cycles through toolbar buttons, but only if focus is within the toolbar - const { ref: focusRef, focused } = useFocusWithin(); - - const trapRef = useFocusTrap(focused); - - const combinedRef = mergeRefs(ref, focusRef, trapRef); - + // No focus trap: the toolbar's menus and popovers portal next to it, so + // they are not in its subtree, and Mantine's trap would move focus back + // out of a just-opened form into the toolbar. Tab moves through the + // buttons and then on, as in the other skins. return ( { editor.addStyles({ textColor: color }); } - setTimeout(() => { - // timeout needed to ensure compatibility with Mantine Toolbar useFocusTrap - editor.focus(); - }); + editor.focus(); }, [editor, textColorInSchema], ); @@ -127,10 +124,7 @@ export const ColorStyleButton = () => { editor.addStyles({ backgroundColor: color }); } - setTimeout(() => { - // timeout needed to ensure compatibility with Mantine Toolbar useFocusTrap - editor.focus(); - }); + editor.focus(); }, [backgroundColorInSchema, editor], ); diff --git a/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx b/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx new file mode 100644 index 0000000000..f1e2308be5 --- /dev/null +++ b/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx @@ -0,0 +1,64 @@ +import App from "@examples/01-basic/testing/src/App"; +import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, sleep, waitForSelector } from "../../utils/editor.js"; +import { moveMouseOverElement } from "../../utils/mouse.js"; + +// The link forms open from toolbars whose menus and popovers portal next to +// the toolbar rather than inside it. These tests click with `userEvent.click` +// on purpose: it presses and releases within the same tick, which is how the +// mantine toolbar's former focus trap used to steal the form's autofocus back +// into the toolbar before a human could type. + +beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); +}); + +const LINK_SELECTOR = 'a[data-inline-content-type="link"]'; + +async function createLink(url: string) { + await focusOnEditor(); + await userEvent.keyboard("Paragraph"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + await userEvent.click(await waitForSelector(LINK_BUTTON_SELECTOR)); + await userEvent.keyboard(url); + await userEvent.keyboard("{Enter}"); + await waitForSelector(LINK_SELECTOR); +} + +describe("Link forms keep focus after an instantaneous click", () => { + test("Create link: typing right after the click fills the URL field", async () => { + await createLink("https://example.com"); + + expect(document.querySelector(LINK_SELECTOR)?.href).toBe( + "https://example.com/", + ); + }); + + test("Link toolbar: Edit focuses the URL field and Enter applies the change", async () => { + await createLink("https://example.com"); + + await userEvent.keyboard("{End}"); + await userEvent.keyboard("{ArrowLeft}"); + await moveMouseOverElement(LINK_SELECTOR); + const linkToolbar = await waitForSelector(".bn-link-toolbar"); + + await userEvent.click(linkToolbar.querySelector("button")!); + await sleep(300); + + const input = await waitForSelector(".bn-form-popover input"); + expect(document.activeElement).toBe(input); + + await userEvent.keyboard("{Control>}a{/Control}{Meta>}a{/Meta}"); + await userEvent.keyboard("https://changed.example"); + await userEvent.keyboard("{Enter}"); + await sleep(300); + + expect(document.querySelector(LINK_SELECTOR)?.href).toBe( + "https://changed.example/", + ); + }); +}); From 4d3a67cca4de7636ba53e4b1e1ed4fc136b41a23 Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 16:03:42 +0200 Subject: [PATCH 20/21] test(mantine): let commits settle before measuring the render profile Mounting schedules an update from an effect that can commit after `act` has returned. On Linux WebKit whether it does varies from mount to mount, so the setup measured second sometimes counted one commit fewer or more than the baseline and the comparison failed (CI's webkit shard, 1 of 3 runs locally in Docker). Wait until no commit has landed for 50 ms before reading the counts, at mount and after the parent re-render, so every mount is measured once things have settled. --- .../src/BlockNoteView.browser.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/mantine/src/BlockNoteView.browser.test.tsx b/packages/mantine/src/BlockNoteView.browser.test.tsx index 17773950cd..605b971190 100644 --- a/packages/mantine/src/BlockNoteView.browser.test.tsx +++ b/packages/mantine/src/BlockNoteView.browser.test.tsx @@ -94,6 +94,26 @@ function Harness(props: { setup: Setup }) { ); } +/** + * Waits until no commit has landed for a while. Mounting schedules an update + * from an effect that can commit after `act` has returned, and on Linux WebKit + * whether it does varies from mount to mount; measuring only once things have + * settled makes every mount count the same set of commits. + */ +async function settle() { + let last = commits; + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + if (commits === last) { + return; + } + last = commits; + } + throw new Error("commits did not settle"); +} + async function mount(setup: Setup, strict: boolean) { editor = BlockNoteEditor.create(); commits = 0; @@ -103,6 +123,7 @@ async function mount(setup: Setup, strict: boolean) { await act(async () => { root!.render(strict ? {tree} : tree); }); + await settle(); if (!container.querySelector(".bn-editor")) { throw new Error("editor did not mount"); } @@ -152,6 +173,7 @@ describe.each([{ strict: false }, { strict: true }])( await act(async () => { rerenderParent(); }); + await settle(); const cost = { commits: commits - before.commits, childRenders: childRenders - before.childRenders, From 9cfade69d7743cb4eacdc76be86224da17adcbad Mon Sep 17 00:00:00 2001 From: yousefed Date: Sat, 5 Sep 2026 22:20:43 +0200 Subject: [PATCH 21/21] fix(react): portal floating UI next to the editor element by default `usePortalElement` fell back to the editor's `bn-container`, which is the editor element's parent in the default layout but not when a layout renders `BlockNoteViewEditor` itself: there the container may also hold a sidebar, and the table's extend button, sized to the table's full width, escaped the editor's scrolling pane and painted over the sidebar while the table stayed clipped. Fall back to the editor element's parent, as `mount()` did before: floating UI clips and scrolls with the editor, and `portalElements` remains the way to escape. --- docs/content/docs/react/components/index.mdx | 2 +- .../20-portal-elements/README.md | 2 +- .../Comments/FloatingComposerController.tsx | 2 +- .../Comments/FloatingThreadController.tsx | 2 +- .../FilePanel/FilePanelController.tsx | 2 +- .../DesktopFormattingToolbarController.tsx | 2 +- .../FormattingToolbarController.tsx | 2 +- .../LinkToolbar/LinkToolbarController.tsx | 2 +- .../SideMenu/SideMenuController.tsx | 2 +- .../GridSuggestionMenuController.tsx | 2 +- .../SuggestionMenuController.tsx | 2 +- .../TableHandles/TableHandlesController.tsx | 2 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 3 +- .../src/editor/PortalElementOverride.tsx | 11 ++-- packages/react/src/editor/portalElements.ts | 4 +- .../portals/portalElements.test.tsx | 62 ++++++++++++++++++- 16 files changed, 84 insertions(+), 20 deletions(-) diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index d22664ac42..c068e16ec7 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -17,7 +17,7 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can ## Configuring Portal Targets -By default, the floating components (formatting toolbar, side menu, slash menu, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. The menus and popovers a floating component opens render inside that component's wrapper, so they move and hide with it. If your layout needs the floating components to escape the container, e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context, pass a `portalElements` prop to `BlockNoteView`: +By default, the floating components (formatting toolbar, side menu, slash menu, table handles, etc.) portal into the element that wraps the editor: the editor's `bn-container`, or, when you render `BlockNoteViewEditor` yourself, the element you render it into. So they stay scoped to the editor and clip with it. The menus and popovers a floating component opens render inside that component's wrapper, so they move and hide with it. If your layout needs the floating components to escape, e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context, pass a `portalElements` prop to `BlockNoteView`: ```tsx ; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal element (the editor's `bn-container` by default) + * the ambient portal element (the element wrapping the editor by default) * when omitted. */ portalElement?: HTMLElement; diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 41786bb047..2366a4ea46 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -47,7 +47,7 @@ export function GridSuggestionMenuController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal target (the element wrapping the editor by default) * when omitted. */ portalElement?: HTMLElement; diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index b774c15da8..d2ac15b22e 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -41,7 +41,7 @@ export function SuggestionMenuController< floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal target (the editor's `bn-container` by default) + * the ambient portal target (the element wrapping the editor by default) * when omitted. */ portalElement?: HTMLElement; diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index 64187f9c1c..4e4d61f10e 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -34,7 +34,7 @@ export const TableHandlesController = < extendButton?: FC; /** * Override the DOM node this floating element portals into. Falls back to - * the ambient portal element (the editor's `bn-container` by default) + * the ambient portal element (the element wrapping the editor by default) * when omitted. */ portalElement?: HTMLElement; diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 88d8c0bede..afaaf7f4db 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -88,7 +88,8 @@ export type BlockNoteDefaultUIProps = { * of the default UI elements; values can be an `HTMLElement`, a CSS * selector string, or `null` (= `document.body`). The optional `default` * key sets the target for every element without its own entry; when - * omitted, the editor's `bn-container` element is used. + * omitted, the element wrapping the editor is used (its `bn-container` in + * the default layout). */ portalElements?: PortalElementsMap; }; diff --git a/packages/react/src/editor/PortalElementOverride.tsx b/packages/react/src/editor/PortalElementOverride.tsx index 96c1178ccf..73d85f1d50 100644 --- a/packages/react/src/editor/PortalElementOverride.tsx +++ b/packages/react/src/editor/PortalElementOverride.tsx @@ -24,9 +24,12 @@ const PortalElementContext = createContext(null); * The element the floating UI below should portal into: the nearest * {@link PortalElementAnchor} (the wrapper of the toolbar, side menu, … that * opens it), else the nearest {@link PortalElementOverride}'s element, else by - * default the editor's own container. All of these sit inside a themed - * `.bn-root`, so portalled UI keeps the editor's styling and color scheme - * wherever in the DOM it lands. + * default the element wrapping the editor element. In the default layout that + * is the editor's `bn-container`; with `renderEditor={false}` it is whatever + * `BlockNoteViewEditor` was rendered into, so the floating UI clips and scrolls + * with the editor rather than escaping into the layout around it. All of these + * sit inside a themed `.bn-root`, so portalled UI keeps the editor's styling + * and color scheme wherever in the DOM it lands. * * `null` until the editor has mounted, and on the server. Consumers render * nothing until it exists. @@ -39,7 +42,7 @@ export function usePortalElement(): HTMLElement | null { return override; } - return editorDOMElement?.closest(".bn-container") ?? null; + return editorDOMElement?.parentElement ?? null; } /** diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index ee2cca6301..13c8ed36df 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -11,8 +11,8 @@ export type PortalElement = HTMLElement | string; * default UI element flags on `BlockNoteView`. * * `default` is the fallback used for any element whose key is omitted. If - * `default` is also omitted, floating UI portals into the editor's - * `bn-container` element. + * `default` is also omitted, floating UI portals into the element wrapping the + * editor: its `bn-container`, or what `BlockNoteViewEditor` was rendered into. */ export type PortalElementsMap = { default?: PortalElement; diff --git a/tests/src/end-to-end/portals/portalElements.test.tsx b/tests/src/end-to-end/portals/portalElements.test.tsx index 7f0c8495e3..2ec11d1825 100644 --- a/tests/src/end-to-end/portals/portalElements.test.tsx +++ b/tests/src/end-to-end/portals/portalElements.test.tsx @@ -2,7 +2,11 @@ import { BlockNoteEditor } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { PortalElementsMap, useCreateBlockNote } from "@blocknote/react"; +import { + BlockNoteViewEditor, + PortalElementsMap, + useCreateBlockNote, +} from "@blocknote/react"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import { useEffect } from "react"; import { render } from "vitest-browser-react"; @@ -29,6 +33,34 @@ function PortalTestEditor(props: { ); } +/** + * A layout that renders the editor itself: a scrolling pane with the editor + * next to a sidebar, both inside the `BlockNoteView`, as an app would. + */ +function ManualLayoutEditor(props: { + onEditor: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote(); + + useEffect(() => { + props.onEditor(editor); + }, [editor, props]); + + return ( + +
+
+ +
+
+
+ + ); +} + function createPortalTarget(id: string, className?: string) { const target = document.createElement("div"); target.id = id; @@ -142,4 +174,32 @@ describe("Portal elements", () => { expect(editor.isWithinEditor(menu)).toBe(true); expect(editor.isWithinEditor(document.body)).toBe(false); }); + + test("portals next to the editor when the layout renders it manually", async () => { + let editor: BlockNoteEditor | undefined; + await render( + { + editor = value; + }} + />, + ); + await waitForSelector(".bn-editor"); + await vi.waitFor(() => { + if (!editor) { + throw new Error("Editor was not created"); + } + }); + if (!editor) { + throw new Error("Editor was not created"); + } + + const menu = await openSlashMenu(); + const pane = document.querySelector("[data-test=pane]")!; + + // The menu lives in the editor's pane, so it clips and scrolls with the + // editor instead of spilling over the sidebar next to it. + expect(pane.contains(menu)).toBe(true); + expect(editor.isWithinEditor(menu)).toBe(true); + }); });