From e0e3e9093bf8738dff8449a8ecab94ad6609c223 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 2 Sep 2026 20:01:01 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 4ca626d6bec87fa80664176f2b111b9b6cc4d26e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 4 Sep 2026 19:11:21 +0200 Subject: [PATCH 8/8] 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); + }); +});