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/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..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 @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - useMobileToolbarPortal, + 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 = useMobileToolbarPortal(); + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={editorPortalElement} > { - 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,35 +772,60 @@ export class BlockNoteEditor< return this.prosemirrorView?.dom as HTMLDivElement | undefined; } - private _portalElement: HTMLElement | undefined; + // Portal roots registered by the view layer, with reference counts so + // multiple UI elements can share a root (e.g. several popovers portalling + // into the same custom target). + private _portalRoots = new Map(); /** - * 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. + * 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 + * `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}. */ - public get portalElement() { - if (typeof document === "undefined") { - throw new Error( - "Portal element accessed, but not available in headless mode", - ); + public registerPortalRoot = (element: HTMLElement) => { + this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1); + }; + + /** + * 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; } - if (!this._portalElement) { - this._portalElement = document.createElement("div"); + + if (count <= 1) { + this._portalRoots.delete(element); + } else { + this._portalRoots.set(element, count - 1); } - return this._portalElement; - } + }; /** - * 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). + * Checks whether a DOM element belongs to this editor — inside the editor's + * DOM tree, or inside any portal root registered via + * {@link registerPortalRoot} (used for floating UI elements like menus and + * toolbars, which may portal outside the editor's DOM tree). */ public isWithinEditor = (element: Element): boolean => { - return !!( - this.domElement?.parentElement?.contains(element) || - this.portalElement?.contains(element) - ); + if (this.domElement?.parentElement?.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..a6b57bb3c4 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 and any portal roots): the color-scheme attribute the + // stylesheet keys off, plus CSS variables for custom object themes. + // `BlockNoteViewRaw` applies these to every root, so they all update in the + // same commit. + const themedRootProps = useMemo(() => { + const themeCSSVariables = + typeof theme !== "object" + ? undefined + : "light" in theme && "dark" in theme + ? themeToCSSVariables( + theme[defaultColorScheme === "dark" ? "dark" : "light"], + ) + : themeToCSSVariables(theme); - 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..ce5e0bf04d 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -31,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; }) => { @@ -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 db078703f2..641abdfe07 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 { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -13,14 +14,13 @@ export const EmojiPicker = (props: { const Components = useComponentsContext()!; const blockNoteContext = useBlockNoteContext()!; - const portalRoot = blockNoteContext.editor?.portalElement; - - if (!portalRoot) { - throw new Error("Portal root not found"); - } + const editorPortalElement = useEditorPortalElement(); return ( - +
{ diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 9d2feba38d..8e4c93cdcc 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -30,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; @@ -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..15a2b2c457 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -22,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; @@ -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..c68062a965 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { FilePanel } from "./FilePanel.js"; @@ -14,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; @@ -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 25e0e429e7..ed3aa38c11 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 { useEditorPortalElement } from "../../../editor/EditorPortalProvider.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 editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -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 editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -136,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..7c59fbaeff 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 { useEditorPortalElement } from "../../../editor/EditorPortalProvider.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 editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,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 editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,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 editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -67,7 +73,7 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={mobileToolbarPortal ?? undefined} + portalRoot={portalRoot} > { const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const editorPortalElement = useEditorPortalElement(); + // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop + // renders inline with default focus behavior. + const portalRoot = + uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -214,7 +220,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/DesktopFormattingToolbarController.tsx index 5ba258dfca..cdb95fd59e 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; @@ -38,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; @@ -118,12 +119,10 @@ export const DesktopFormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( - - {show && } - + + + {show && } + + ); }; 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 dd33ce607e..45ee4def36 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,24 +1,16 @@ -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 { + EditorPortalProvider, + useEditorPortalElement, +} from "../../editor/EditorPortalProvider.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 +25,11 @@ 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 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 @@ -97,54 +83,30 @@ export const MobileFormattingToolbarController = (props: { } return ( - + + + + + ); }; -/** - * 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. - */ 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 editorPortalElement = useEditorPortalElement(); const Component = props.formattingToolbar; + if (!editorPortalElement) { + return null; + } + return createPortal( - -
-
- -
-
-
, - document.body, +
+ +
, + editorPortalElement, ); } diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index fbe789a544..c629307f5f 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -19,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; @@ -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 e185e36618..3a5da7c0f8 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 { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -116,23 +117,15 @@ export const GenericPopover = ( props: FloatingUIOptions & { reference?: GenericPopoverReference; children: ReactNode; - /** - * Override the DOM node this popover portals into. If omitted, falls back - * to `editor.portalElement`. - */ - portalElement?: HTMLElement | null; }, ) => { const editor = useBlockNoteEditor(); - const portalRoot = - props.portalElement === null - ? typeof document !== "undefined" - ? document.body - : undefined - : (props.portalElement ?? editor?.portalElement); - if (!portalRoot) { - throw new Error("Portal element not found"); - } + // The ambient portal root — always a resolved, themed, registered root, as + // `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 editorPortalElement = useEditorPortalElement(); const { whileElementsMounted: _whileElementsMounted, middleware, @@ -223,7 +216,7 @@ export const GenericPopover = ( [status, props.reference, props.children], ); - if (!isMounted) { + if (!isMounted || !editorPortalElement) { return false; } @@ -252,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} @@ -275,7 +268,7 @@ export const GenericPopover = ( } return ( - +
{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..16ac66f36b 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; @@ -61,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; @@ -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..be0d137814 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 { EditorPortalProvider } from "../../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -46,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; @@ -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..59dda4e150 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -40,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; @@ -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..1bc60e7ea2 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 { EditorPortalProvider } from "../../editor/EditorPortalProvider.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -33,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; @@ -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/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 d6e6f85b8e..1c5162c970 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -10,12 +10,12 @@ import React, { ReactNode, Ref, useCallback, - useEffect, useMemo, useState, } from "react"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; import { useEditorChange } from "../hooks/useEditorChange.js"; +import { useEditorDOMElement } from "../hooks/useEditorDomElement.js"; import { useEditorSelectionChange } from "../hooks/useEditorSelectionChange.js"; import { usePrefersColorScheme } from "../hooks/usePrefersColorScheme.js"; import { @@ -27,9 +27,11 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; +import { EditorPortalProvider } from "./EditorPortalProvider.js"; import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, + ThemedRootProps, useBlockNoteViewContext, } from "./BlockNoteViewContext.js"; import { useComponentsContext } from "./ComponentsContext.js"; @@ -91,12 +93,19 @@ export type BlockNoteViewProps< */ children?: ReactNode; + /** + * Attributes to apply to every themed BlockNote root element. UI-library + * wrappers use this to carry their theming (color-scheme data attributes, + * theme CSS variables) to floating UI portalled outside the editor's DOM. + */ + 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, @@ -127,18 +136,20 @@ function BlockNoteViewComponent< tableHandles, comments, portalElements, + themedRootProps, autoFocus, renderEditor = true, ...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], - ); + const editorDOMElement = useEditorDOMElement(editor); + const portalElement = + useMemo( + () => resolvePortalTarget(portalElements?.default), + [portalElements?.default], + ) ?? + editorDOMElement?.parentElement ?? + undefined; // Used so other components (suggestion menu) can set // aria related props to the contenteditable div @@ -191,17 +202,14 @@ 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]); + 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 @@ -222,11 +230,17 @@ function BlockNoteViewComponent< autoFocus, contentEditableProps, editable, - portalTarget, }, defaultUIProps, + portalRootProps, }; - }, [autoFocus, contentEditableProps, editable, defaultUIProps, portalTarget]); + }, [ + autoFocus, + contentEditableProps, + editable, + defaultUIProps, + portalRootProps, + ]); return ( @@ -236,6 +250,8 @@ function BlockNoteViewComponent< className={className} renderEditor={renderEditor} editorColorScheme={editorColorScheme} + themedRootProps={themedRootProps} + portalElement={portalElement} ref={ref} {...rest} > @@ -255,30 +271,50 @@ const BlockNoteViewContainer = React.forwardRef< { renderEditor: boolean; editorColorScheme: "light" | "dark"; + themedRootProps?: ThemedRootProps; + portalElement?: HTMLElement; 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, + portalElement, + children, + style, + ...rest + }, + ref, + ) => ( +
+ + {renderEditor ? ( + {children} + ) : ( + children + )} + +
+ ), +); // https://fettblog.eu/typescript-react-generic-forward-refs/ export const BlockNoteViewRaw = React.forwardRef(BlockNoteViewComponent) as < @@ -306,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. @@ -320,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 ( @@ -347,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 2b5b7413c8..699777d429 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -1,20 +1,39 @@ -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; 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 and used + * by `EditorPortalProvider` 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/EditorPortalProvider.tsx b/packages/react/src/editor/EditorPortalProvider.tsx new file mode 100644 index 0000000000..b8cb6c15e9 --- /dev/null +++ b/packages/react/src/editor/EditorPortalProvider.tsx @@ -0,0 +1,108 @@ +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; + +const EditorPortalContext = createContext(null); + +export function useEditorPortalElement(): HTMLElement | null { + return useContext(EditorPortalContext); +} + +// Registers a portal root on mount and deregisters it on unmount. +function useRegisterPortalRoot(root: HTMLElement | null) { + const editor = useBlockNoteEditor(); + + useEffect(() => { + if (!root) { + return; + } + + editor.registerPortalRoot(root); + return () => { + editor.unregisterPortalRoot(root); + }; + }, [editor, root]); +} + +// Given a target element, checks whether a `.bn-root` element is somewhere up the DOM tree, as +// one is necessary to apply correct theming & styling. If one doesn't exist, creates one and +// returns it, both as a React node and HTML element. Otherwise, just returns the target element or +// null if the target is undefined. +function useThemedPortalRoot(target: HTMLElement | undefined): { + root: HTMLElement | null; + themingContainer: ReactNode; +} { + const rootProps = useBlockNoteViewContext()?.portalRootProps; + + const [needsContainer, setNeedsContainer] = useState<{ + target: HTMLElement; + value: boolean; + }>(); + const [containerElement, setContainerElement] = useState( + null, + ); + + useEffect(() => { + if (target) { + setNeedsContainer({ target, value: !target.closest(".bn-root") }); + } + }, [target]); + + if (!target || needsContainer?.target !== target) { + return { root: null, themingContainer: null }; + } + + if (!needsContainer.value) { + return { root: target, themingContainer: null }; + } + + return { + root: containerElement, + themingContainer: createPortal( +
, + target, + ), + }; +} + +// Exposes a target portal element for consumers of `EditorPortalContext` to consume. If the target +// element has no `.bn-root` element in its ancestors, so that styles & theming are properly +// applied to the element's descendants, one is created. +export function EditorPortalProvider(props: { + target?: HTMLElement | null; + children?: ReactNode; +}) { + const { target, children } = props; + + const resolvedTarget = + target === null + ? typeof document !== "undefined" + ? document.body + : undefined + : target; + + const { root, themingContainer } = useThemedPortalRoot(resolvedTarget); + + useRegisterPortalRoot(root); + + if (target === undefined) { + return children; + } + + return ( + <> + + {children} + + {themingContainer} + + ); +} diff --git a/packages/react/src/editor/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/UIModeContext.ts b/packages/react/src/editor/UIModeContext.ts new file mode 100644 index 0000000000..d64f31c61a --- /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 + * 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"; + +export const UIModeContext = createContext("desktop"); + +export function useUIMode(): UIMode { + return useContext(UIModeContext); +} diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts index d4b4f93205..464c34dc98 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -5,34 +5,33 @@ * - `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 * 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?: 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 bb1811a729..5d8be69c43 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,7 +45,11 @@ 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 { + 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 a6417a8b27..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, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps, useEditorPortalElement } 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 editorPortalElement = useEditorPortalElement(); const badge = ( {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 47114a79c5..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, useBlockNoteEditor } 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"; @@ -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 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 1ccb01243f..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, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps, useEditorPortalElement } 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 editorPortalElement = useEditorPortalElement(); 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 editorPortalElement = useEditorPortalElement(); 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 editorPortalElement = useEditorPortalElement(); // TODO? const SelectItemContent = (props: any) => ( @@ -163,7 +164,7 @@ export const ToolbarSelect = forwardRef< 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); + }); +});