diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index 1b6e18d3c0..c068e16ec7 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -17,7 +17,7 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can ## Configuring Portal Targets -By default, all floating UI elements (toolbars, menus, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. If your layout needs them to escape — e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context — pass a `portalElements` prop to `BlockNoteView`: +By default, the floating components (formatting toolbar, side menu, slash menu, table handles, etc.) portal into the element that wraps the editor: the editor's `bn-container`, or, when you render `BlockNoteViewEditor` yourself, the element you render it into. So they stay scoped to the editor and clip with it. The menus and popovers a floating component opens render inside that component's wrapper, so they move and hide with it. If your layout needs the floating components to escape, e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context, pass a `portalElements` prop to `BlockNoteView`: ```tsx ``` -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..2c27dcd6a3 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - useMobileToolbarPortal, + usePortalElement, useSelectedBlocks, } from "@blocknote/react"; import { useCallback, useEffect, useState } from "react"; @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const mobileToolbarPortal = useMobileToolbarPortal(); + const editorPortalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={mobileToolbarPortal ?? undefined} + portalElement={editorPortalElement} > diff --git a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx +++ b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx index 0dfc79dc3f..b5394fe89e 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx +++ b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const portalElement = usePortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 177dc37f73..9f6b9f9671 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -13,18 +13,19 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -// Threads the `portalRoot` override from `Menu` (the provider) down to -// `MenuDropdown`, where ariakit's `portalElement` prop actually lives. -const PortalRootContext = createContext( - undefined, -); +// Hands the `portalElement` prop from `Menu` (the root) down to +// `MenuDropdown`, where Ariakit takes it. +const MenuPortalElementContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { children, onOpenChange, position, - portalRoot, + portalElement, + // ariakit's `virtualFocus` keeps DOM focus on the editor (roving via + // `aria-activedescendant`), so there is no focus to suppress here. + preventFocusOnOpen: _preventFocusOnOpen, sub: _sub, // unused ...rest } = props; @@ -37,9 +38,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { setOpen={onOpenChange} virtualFocus={true} > - + {children} - + ); }; @@ -57,13 +58,16 @@ export const MenuDropdown = forwardRef< assertEmpty(rest); - const portalRoot = useContext(PortalRootContext); + const portalElement = useContext(MenuPortalElementContext); return ( {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index df8e01128b..9e5a16ebda 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -8,9 +8,9 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -const PortalRootContext = createContext( - undefined, -); +// Hands the `portalElement` prop from `Popover` (the root) down to +// `PopoverContent`, where Ariakit takes it. +const PopoverPortalElementContext = createContext(null); export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -31,7 +31,7 @@ export const PopoverContent = forwardRef< assertEmpty(rest); - const portalRoot = useContext(PortalRootContext); + const portalElement = useContext(PopoverPortalElementContext); return ( {children} @@ -51,7 +54,15 @@ export const PopoverContent = forwardRef< export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { children, open, onOpenChange, position, portalRoot, ...rest } = props; + const { + children, + open, + onOpenChange, + position, + portalElement, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); @@ -61,9 +72,9 @@ export const Popover = ( setOpen={onOpenChange} placement={position} > - + {children} - + ); }; diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 59974a6d60..0a7ad05825 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -38,7 +38,9 @@ inset 0 1px 1px 1px var(--shadow); } -.bn-toolbar .bn-ak-popover { +/* Form popovers (link, caption, rename) stack their fields; lists keep the + tighter spacing. */ +.bn-ariakit .bn-form-popover { gap: 0.5rem; } diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 26d817976e..a41ced67a3 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,14 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalElement, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); @@ -40,7 +47,10 @@ export const ToolbarSelect = forwardRef< className={mergeCSSClasses("bn-ak-popover", className || "")} ref={ref} gutter={4} - portalElement={portalRoot ?? undefined} + // Ariakit falls back to a body-appended div for a missing element, + // so don't portal at all until there is one (editor not mounted yet). + portal={portalElement !== null} + portalElement={portalElement} > {items.map((option) => ( { - 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); + public mount = (element: HTMLElement) => { this._tiptapEditor.mount({ mount: element }); }; @@ -746,7 +734,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 +761,63 @@ export class BlockNoteEditor< return this.prosemirrorView?.dom as HTMLDivElement | undefined; } - private _portalElement: HTMLElement | undefined; + // Portal elements registered by the view layer, with reference counts so + // several UI elements can share one (e.g. multiple popovers portalling into + // the same custom element). + private _portalElements = new Map(); /** - * 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 element for this editor's floating UI, so + * {@link isWithinEditor} treats its contents as part of the editor. The view + * layer calls this for each portal element it designates (see + * `PortalElementOverride` in `@blocknote/react`) — without it, UI portalled outside + * the editor's DOM tree would be considered outside the editor. + * Registrations are reference-counted; release with + * {@link unregisterPortalElement}. */ - public get portalElement() { - if (typeof document === "undefined") { - throw new Error( - "Portal element accessed, but not available in headless mode", - ); + public registerPortalElement = (element: HTMLElement) => { + this._portalElements.set( + element, + (this._portalElements.get(element) ?? 0) + 1, + ); + }; + + /** + * Releases a registration made with {@link registerPortalElement}. The element + * stops counting as part of the editor once every registration for it has + * been released. + */ + public unregisterPortalElement = (element: HTMLElement) => { + const count = this._portalElements.get(element); + if (count === undefined) { + return; } - if (!this._portalElement) { - this._portalElement = document.createElement("div"); + + if (count <= 1) { + this._portalElements.delete(element); + } else { + this._portalElements.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 element registered via + * {@link registerPortalElement} (used for floating UI elements like menus and + * toolbars, which may portal outside the editor's DOM tree). */ public isWithinEditor = (element: Element): boolean => { - return !!( - this.domElement?.parentElement?.contains(element) || - this.portalElement?.contains(element) - ); + if (this.domElement?.parentElement?.contains(element)) { + return true; + } + + for (const portalElement of this._portalElements.keys()) { + if (portalElement.contains(element)) { + return true; + } + } + + return false; }; public isFocused() { diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts index 30c7549284..6ae5547bb3 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts @@ -60,7 +60,9 @@ const nestedEditorBlock = createBlockSpec( }, ], }); - nestedEditor.mount(dom, { portalTarget: document.body }); + nestedEditor.mount(dom); + // The nested editor's UI renders at the body, outside its own DOM tree. + nestedEditor.registerPortalElement(document.body); return { dom, destroy: () => nestedEditor.unmount() }; }, diff --git a/packages/mantine/src/BlockNoteView.browser.test.tsx b/packages/mantine/src/BlockNoteView.browser.test.tsx new file mode 100644 index 0000000000..605b971190 --- /dev/null +++ b/packages/mantine/src/BlockNoteView.browser.test.tsx @@ -0,0 +1,194 @@ +import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core"; +import { + getDefaultReactSlashMenuItems, + type PortalElementsMap, + SuggestionMenuController, +} from "@blocknote/react"; +import { act, Profiler, StrictMode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; + +import { BlockNoteView } from "./BlockNoteView.js"; +import "./style.css"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Guards the render profile of `BlockNoteView` around portalling: routing the + * floating UI elsewhere must not cost React commits or child renders. An + * earlier portal implementation added one to three commits at mount depending + * on the setup; that is the regression these pin. + * + * Every number is relative to the default setup measured in the same run, so + * unrelated changes to how the editor itself renders don't break them. Both + * StrictMode settings are covered: its double-invoked effects are where + * append/remove and register/unregister pairs go wrong. + */ + +type Setup = + | "default" + | "portalElements.default" + | "portalElements.slashMenu" + | "controller"; + +const PORTAL_SETUPS: Setup[] = [ + "portalElements.default", + "portalElements.slashMenu", + "controller", +]; + +let editor: BlockNoteEditor; +let root: Root | undefined; +let container: HTMLDivElement; +let target: HTMLDivElement; +let commits = 0; +let childRenders = 0; +let rerenderParent: () => void = () => {}; + +function onRender() { + commits++; +} + +function Child() { + childRenders++; + return null; +} + +function Harness(props: { setup: Setup }) { + const [, setTick] = useState(0); + rerenderParent = () => setTick((tick) => tick + 1); + + const portalElements: PortalElementsMap | undefined = + props.setup === "portalElements.default" + ? { default: target } + : props.setup === "portalElements.slashMenu" + ? { slashMenu: target } + : undefined; + + return ( + + + {props.setup === "controller" && ( + + filterSuggestionItems( + getDefaultReactSlashMenuItems(editor), + query, + ) + } + portalElement={target} + /> + )} + + + + ); +} + +/** + * Waits until no commit has landed for a while. Mounting schedules an update + * from an effect that can commit after `act` has returned, and on Linux WebKit + * whether it does varies from mount to mount; measuring only once things have + * settled makes every mount count the same set of commits. + */ +async function settle() { + let last = commits; + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + if (commits === last) { + return; + } + last = commits; + } + throw new Error("commits did not settle"); +} + +async function mount(setup: Setup, strict: boolean) { + editor = BlockNoteEditor.create(); + commits = 0; + childRenders = 0; + root = createRoot(container); + const tree = ; + await act(async () => { + root!.render(strict ? {tree} : tree); + }); + await settle(); + if (!container.querySelector(".bn-editor")) { + throw new Error("editor did not mount"); + } + return { commits, childRenders }; +} + +async function unmount() { + await act(async () => { + root?.unmount(); + }); + root = undefined; + editor._tiptapEditor.destroy(); +} + +beforeEach(() => { + container = document.createElement("div"); + target = document.createElement("div"); + document.body.append(container, target); +}); + +afterEach(async () => { + if (root) { + await unmount(); + } + container.remove(); + target.remove(); +}); + +describe.each([{ strict: false }, { strict: true }])( + "BlockNoteView render profile with portals (StrictMode: $strict)", + ({ strict }) => { + test("portal setups add no commits or child renders at mount", async () => { + const baseline = await mount("default", strict); + await unmount(); + + for (const setup of PORTAL_SETUPS) { + const measured = await mount(setup, strict); + await unmount(); + expect({ setup, ...measured }).toEqual({ setup, ...baseline }); + } + }); + + test("an unrelated parent re-render costs the same with portals as without", async () => { + async function rerenderCost(setup: Setup) { + await mount(setup, strict); + const before = { commits, childRenders }; + await act(async () => { + rerenderParent(); + }); + await settle(); + const cost = { + commits: commits - before.commits, + childRenders: childRenders - before.childRenders, + }; + await unmount(); + return cost; + } + + const baseline = await rerenderCost("default"); + for (const setup of PORTAL_SETUPS) { + expect({ setup, ...(await rerenderCost(setup)) }).toEqual({ + setup, + ...baseline, + }); + } + }); + }, +); diff --git a/packages/mantine/src/BlockNoteView.tsx b/packages/mantine/src/BlockNoteView.tsx index 2b714326ce..1a5954eb1a 100644 --- a/packages/mantine/src/BlockNoteView.tsx +++ b/packages/mantine/src/BlockNoteView.tsx @@ -11,7 +11,7 @@ import { usePrefersColorScheme, } from "@blocknote/react"; import { MantineContext, MantineProvider } from "@mantine/core"; -import React, { useCallback, useContext, useEffect } from "react"; +import React, { useCallback, useContext } from "react"; import { applyBlockNoteCSSVariablesFromTheme, removeBlockNoteCSSVariables, @@ -76,13 +76,16 @@ export const BlockNoteView = < [defaultColorScheme, 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]); + // Themes an element BlockNote creates outside React's tree — the portal + // roots its floating UI mounts (see `PortalElementOverride`). The editor + // container gets the same treatment from the props and `ref` below. + const applyThemedRoot = useCallback( + (element: HTMLElement) => { + element.setAttribute("data-mantine-color-scheme", finalTheme); + applyThemeVariables(element); + }, + [applyThemeVariables, finalTheme], + ); const mantineContext = useContext(MantineContext); @@ -91,6 +94,7 @@ export const BlockNoteView = < (undefined); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - const { children, onOpenChange, position, portalRoot, sub, ...rest } = props; + const { + children, + onOpenChange, + position, + portalElement, + preventFocusOnOpen, + sub, + ...rest + } = props; assertEmpty(rest); @@ -36,11 +44,11 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { return ( { - const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + const { + open, + onOpenChange, + position, + portalElement, + preventFocusOnOpen, + children, + ...rest + } = props; assertEmpty(rest); return ( ( assertEmpty(rest); - // use a focus trap so that tab cycles through toolbar buttons, but only if focus is within the toolbar - const { ref: focusRef, focused } = useFocusWithin(); - - const trapRef = useFocusTrap(focused); - - const combinedRef = mergeRefs(ref, focusRef, trapRef); - + // No focus trap: the toolbar's menus and popovers portal next to it, so + // they are not in its subtree, and Mantine's trap would move focus back + // out of a just-opened form into the toolbar. Tab moves through the + // buttons and then on, as in the other skins. return ( ((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalElement, + preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); @@ -26,15 +33,15 @@ export const ToolbarSelect = forwardRef< return ( { const state = useExtensionState("attribution", { selector: (state) => state, @@ -114,12 +115,10 @@ export const AttributionTooltipController = (props: { ); return ( - - {tooltipProps && } - + + + {tooltipProps && } + + ); }; diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index d4a6df40d6..b4f2a4b410 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -18,6 +18,7 @@ import { Components, useComponentsContext, } from "../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../editor/PortalElementOverride.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; @@ -161,6 +162,7 @@ export const Comment = ({ }); const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const [isEditing, setEditing] = useState(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); @@ -289,7 +291,10 @@ export const Comment = ({ ))} {(canDeleteComment || canEditComment) && ( - + +
{ diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 9d2feba38d..71a02e610c 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 { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -30,10 +31,10 @@ 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) { const editor = useBlockNoteEditor(); const dict = useDictionary(); @@ -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..829bae026d 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 { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; @@ -22,10 +23,10 @@ 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) { const editor = useBlockNoteEditor(); const dict = useDictionary(); @@ -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..682dc95f00 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 { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { FilePanel } from "./FilePanel.js"; @@ -14,10 +15,10 @@ 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); @@ -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..976c75858b 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 { usePortalElement } from "../../../editor/PortalElementOverride.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,8 @@ function checkColorInSchema( export const ColorStyleButton = () => { const Components = useComponentsContext()!; const dict = useDictionary(); - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -103,10 +105,7 @@ export const ColorStyleButton = () => { editor.addStyles({ textColor: color }); } - setTimeout(() => { - // timeout needed to ensure compatibility with Mantine Toolbar useFocusTrap - editor.focus(); - }); + editor.focus(); }, [editor, textColorInSchema], ); @@ -125,10 +124,7 @@ export const ColorStyleButton = () => { editor.addStyles({ backgroundColor: color }); } - setTimeout(() => { - // timeout needed to ensure compatibility with Mantine Toolbar useFocusTrap - editor.focus(); - }); + editor.focus(); }, [backgroundColorInSchema, editor], ); @@ -139,14 +135,14 @@ export const ColorStyleButton = () => { return ( { const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -130,13 +132,14 @@ export const CreateLinkButton = () => { {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 03de3f0857..b1b30c9e60 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 { usePortalElement } from "../../../editor/PortalElementOverride.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,8 @@ import { useDictionary } from "../../../i18n/dictionary.js"; export const FileCaptionButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -106,13 +108,14 @@ export const FileCaptionButton = () => { { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -106,13 +108,14 @@ export const FileRenameButton = () => { { const dict = useDictionary(); const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -67,7 +69,14 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={mobileToolbarPortal ?? undefined} + // Portal the popover into the editor's themed portal target so it + // inherits styling and escapes any scroll-container overflow clipping. + // On mobile that target is the toolbar's body-level container (see + // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops + // focus moving into the popover, which would blur the editor and dismiss + // the on-screen keyboard. + portalElement={portalElement} + preventFocusOnOpen={uiMode === "mobile"} > { const Components = useComponentsContext()!; - const mobileToolbarPortal = useMobileToolbarPortal(); + const uiMode = useUIMode(); + const portalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -214,7 +216,11 @@ 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..d1fc4af10a 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 { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; @@ -38,10 +39,10 @@ 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor< BlockSchema, @@ -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..372912e639 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -12,10 +12,10 @@ export const FormattingToolbarController = (props: { floatingUIOptions?: FloatingUIOptions; /** * Override the DOM node this floating element portals into. Falls back to - * `editor.portalElement` (which by default is mounted inside `bn-container`) + * the ambient portal element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const keyboardOpen = useVirtualKeyboard(); diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index dd33ce607e..7d02378ee1 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,24 +1,17 @@ -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 { + PortalElementAnchor, + PortalElementOverride, + usePortalElement, +} from "../../editor/PortalElementOverride.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 +26,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 +84,35 @@ 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 portalElement = usePortalElement(); const Component = props.formattingToolbar; + if (!portalElement) { + return null; + } + + // The anchor is rendered next to the toolbar, not inside it: the toolbar + // scrolls horizontally, and iOS WebKit clips positioned descendants of a + // scroll container, so its dropdowns must not be descendants of it. return createPortal( - -
-
- -
+ +
+
- , - document.body, +
, + portalElement, ); } diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index 52cef731f4..230cbb267a 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -1,4 +1,5 @@ import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../../editor/PortalElementOverride.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { EditLinkMenuItems } from "../EditLinkMenuItems.js"; import { LinkToolbarProps } from "../LinkToolbarProps.js"; @@ -10,11 +11,13 @@ export const EditLinkButton = ( >, ) => { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const dict = useDictionary(); return ( { const editor = useBlockNoteEditor(); @@ -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..73c26cce9b 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -14,6 +14,11 @@ import { } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; +import { + hasChildrenBesidesPortalElementAnchor, + PortalElementAnchor, + usePortalElement, +} from "../../editor/PortalElementOverride.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; @@ -116,23 +121,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 element — always a resolved, themed, registered root, as + // `EditorPortalContext` is only ever provided by `PortalElementOverride` (the default from + // `BlockNoteView`, or a controller's / the mobile toolbar's override). + // `null` during SSR and for the frame before resolution — handled after the + // hooks below. + const portalElement = usePortalElement(); const { whileElementsMounted: _whileElementsMounted, middleware, @@ -213,7 +210,13 @@ export const GenericPopover = ( useEffect( () => { if (status === "initial" || status === "open") { - if (ref.current?.innerHTML) { + // Only store while the children have rendered something. In the + // render where a controller flips `open` to `false`, its children are + // typically already gone while `status` is still "open", and that + // empty state must not replace the snapshot the closing popover is + // about to show. The wrapper is never truly empty though: it always + // contains the `PortalElementAnchor` holder. + if (ref.current && hasChildrenBesidesPortalElementAnchor(ref.current)) { innerHTML.current = ref.current.innerHTML; } } @@ -223,7 +226,7 @@ export const GenericPopover = ( [status, props.reference, props.children], ); - if (!isMounted) { + if (!isMounted || !portalElement) { return false; } @@ -252,7 +255,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} + {props.children}
@@ -275,9 +286,9 @@ export const GenericPopover = ( } return ( - +
- {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/DefaultButtons/DragHandleButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx index 44f83463fe..37aa012db1 100644 --- a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx @@ -2,6 +2,7 @@ import { SideMenuExtension } from "@blocknote/core/extensions"; import { MdDragIndicator } from "react-icons/md"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../../editor/PortalElementOverride.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { DragHandleMenu } from "../DragHandleMenu/DragHandleMenu.js"; import { SideMenuProps } from "../SideMenuProps.js"; @@ -16,6 +17,7 @@ export const DragHandleButton = ( }, ) => { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const dict = useDictionary(); const sideMenu = useExtension(SideMenuExtension); @@ -39,6 +41,7 @@ export const DragHandleButton = ( } }} position={"left"} + portalElement={portalElement} > { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const editor = useBlockNoteEditor(); @@ -30,7 +32,11 @@ export const BlockColorsItem = (props: { children: ReactNode }) => { } return ( - + ; /** * 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); const state = useExtensionState(SideMenuExtension, { @@ -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..2366a4ea46 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 { PortalElementOverride } from "../../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -46,10 +47,10 @@ 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 element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; } & (ItemType extends DefaultReactGridSuggestionItem ? { // can be undefined @@ -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..d2ac15b22e 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 { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, @@ -40,10 +41,10 @@ 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 element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; } & (ItemType extends DefaultReactSuggestionItem ? { // can be undefined @@ -177,23 +178,21 @@ export function SuggestionMenuController< } return ( - - {triggerCharacter && ( - > - } - onItemClick={onItemClickOrDefault} - /> - )} - + + + {triggerCharacter && ( + > + } + onItemClick={onItemClickOrDefault} + /> + )} + + ); } diff --git a/packages/react/src/components/TableHandles/TableCellButton.tsx b/packages/react/src/components/TableHandles/TableCellButton.tsx index 4f14eae6af..db9c4a4eb8 100644 --- a/packages/react/src/components/TableHandles/TableCellButton.tsx +++ b/packages/react/src/components/TableHandles/TableCellButton.tsx @@ -3,6 +3,7 @@ import { ReactNode } from "react"; import { MdArrowDropDown } from "react-icons/md"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../editor/PortalElementOverride.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension } from "../../hooks/useExtension.js"; import { TableCellButtonProps } from "./TableCellButtonProps.js"; @@ -16,6 +17,7 @@ export const TableCellButton = ( props: TableCellButtonProps & { children?: ReactNode }, ) => { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const editor = useBlockNoteEditor(); @@ -45,6 +47,7 @@ export const TableCellButton = ( } }} position={"right"} + portalElement={portalElement} > diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index 36e1ecca2f..7546ebaffe 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -3,6 +3,7 @@ import { TableHandlesExtension } from "@blocknote/core/extensions"; import { ReactNode } from "react"; import { useComponentsContext } from "../../../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../../../editor/PortalElementOverride.js"; import { useBlockNoteEditor } from "../../../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../../../hooks/useExtension.js"; import { useDictionary } from "../../../../i18n/dictionary.js"; @@ -10,6 +11,7 @@ import { ColorPicker } from "../../../ColorPicker/ColorPicker.js"; export const ColorPickerButton = (props: { children?: ReactNode }) => { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor(); @@ -74,7 +76,11 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { } return ( - + { const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const [isDragging, setIsDragging] = useState(false); @@ -66,6 +68,7 @@ export const TableHandle = ( } }} position={"right"} + portalElement={portalElement} > { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor< { table: DefaultBlockSchema["table"] }, @@ -104,7 +106,11 @@ export const ColorPickerButton = < const firstCell = mapTableCell(currentCells[0].cell); return ( - + ; /** * 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 element (the element wrapping the editor by default) * when omitted. */ - portalElement?: HTMLElement | null; + portalElement?: HTMLElement; }) => { const editor = useBlockNoteEditor(); @@ -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/components/Versioning/CurrentSnapshot.tsx b/packages/react/src/components/Versioning/CurrentSnapshot.tsx index 139701376f..cc09c5d412 100644 --- a/packages/react/src/components/Versioning/CurrentSnapshot.tsx +++ b/packages/react/src/components/Versioning/CurrentSnapshot.tsx @@ -6,6 +6,7 @@ import { import { RiArrowLeftRightLine, RiMoreFill } from "react-icons/ri"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { usePortalElement } from "../../editor/PortalElementOverride.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { dateToString } from "./dateToString.js"; import { useSnapshotLabel } from "./useVersionUsers.js"; @@ -31,6 +32,7 @@ export const CurrentSnapshot = ({ previousSnapshot?: VersionSnapshot; }) => { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const { canPreviewCurrent, previewCurrentVersion, exitPreview } = useExtension(VersioningExtension); const selected = useExtensionState(VersioningExtension, { @@ -72,7 +74,10 @@ export const CurrentSnapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + { const Components = useComponentsContext()!; + const portalElement = usePortalElement(); const { canRestore, restore, @@ -110,7 +112,10 @@ export const Snapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index d6e6f85b8e..7cb9306d23 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,7 +26,8 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { resolvePortalTarget } from "./portalElements.js"; +import { PortalElementOverride } from "./PortalElementOverride.js"; +import { resolvePortalElement } from "./portalElements.js"; import { BlockNoteViewContext, useBlockNoteViewContext, @@ -91,12 +91,20 @@ export type BlockNoteViewProps< */ children?: ReactNode; + /** + * Applies the UI library's theming — its color-scheme attribute, theme CSS + * variables — to a themed BlockNote root element. Wrappers pass this so that + * floating UI portalled outside the editor's DOM is themed too; they style + * the editor container itself through `className` and a `ref`. + */ + applyThemedRoot?: (element: HTMLElement) => void; + 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,16 +135,16 @@ function BlockNoteViewComponent< tableHandles, comments, portalElements, + applyThemedRoot: applyLibraryTheme, 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, + // `default` redirects every element without its own entry. When omitted, + // the ambient portal element (the editor container) stays in effect. + const defaultPortalElement = useMemo( + () => resolvePortalElement(portalElements?.default), [portalElements?.default], ); @@ -191,17 +199,21 @@ 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]); + // Everything that makes an element a themed `.bn-root`: what the container + // below renders as props, applied imperatively for the portal roots + // `PortalElementOverride` creates outside React's tree. + const applyThemedRoot = useCallback( + (element: HTMLElement) => { + element.className = mergeCSSClasses( + "bn-root", + editorColorScheme, + className || "", + ); + element.setAttribute("data-color-scheme", editorColorScheme); + applyLibraryTheme?.(element); + }, + [editorColorScheme, className, applyLibraryTheme], + ); // The BlockNoteContext makes sure the editor and some helper methods // are always available to nesteed compoenents @@ -222,11 +234,17 @@ function BlockNoteViewComponent< autoFocus, contentEditableProps, editable, - portalTarget, }, defaultUIProps, + applyThemedRoot, }; - }, [autoFocus, contentEditableProps, editable, defaultUIProps, portalTarget]); + }, [ + autoFocus, + contentEditableProps, + editable, + defaultUIProps, + applyThemedRoot, + ]); return ( @@ -236,6 +254,7 @@ function BlockNoteViewComponent< className={className} renderEditor={renderEditor} editorColorScheme={editorColorScheme} + defaultPortalElement={defaultPortalElement} ref={ref} {...rest} > @@ -255,30 +274,45 @@ const BlockNoteViewContainer = React.forwardRef< { renderEditor: boolean; editorColorScheme: "light" | "dark"; + defaultPortalElement?: 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, + defaultPortalElement, + children, + ...rest + }, + ref, + ) => ( +
+ + {renderEditor ? ( + {children} + ) : ( + children + )} + +
+ ), +); // https://fettblog.eu/typescript-react-generic-forward-refs/ export const BlockNoteViewRaw = React.forwardRef(BlockNoteViewComponent) as < @@ -306,8 +340,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 +352,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 +379,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..d3a851e282 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -6,15 +6,19 @@ 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; + /** + * Makes `element` a themed BlockNote root: the classes and color-scheme + * attribute the stylesheet keys off, plus whatever the UI library adds (its + * own color-scheme attribute, theme CSS variables). + * + * Applied imperatively because it is used for the portal roots BlockNote + * mounts outside React's DOM tree, which cannot be themed with props (see + * `PortalElementOverride`). The editor container is themed by rendering the + * same values as props instead. + */ + applyThemedRoot: (element: HTMLElement) => void; }; export const BlockNoteViewContext = createContext< diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 5d71bc58dc..33a8d44ab2 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -47,7 +47,14 @@ type ToolbarSelectType = { isDisabled?: boolean; }[]; isDisabled?: boolean; - portalRoot?: HTMLElement | null; + portalElement: HTMLElement | null; + /** + * When true, the surface must not move DOM focus onto itself when it opens. + * On mobile, stealing focus blurs the editor's `contentEditable` and + * dismisses the on-screen keyboard. Adapters map this to their own library's + * focus mechanism. + */ + preventFocusOnOpen?: boolean; }; type MenuButtonType = { @@ -334,7 +341,14 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; - portalRoot?: HTMLElement | null; + portalElement: HTMLElement | null; + /** + * When true, the surface must not move DOM focus onto itself when it + * opens. On mobile, stealing focus blurs the editor's `contentEditable` + * and dismisses the on-screen keyboard. Adapters map this to their own + * library's focus mechanism. + */ + preventFocusOnOpen?: boolean; children?: ReactNode; }; Divider: { @@ -374,7 +388,14 @@ export type ComponentProps = { | "bottom" | "left" | `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`; - portalRoot?: HTMLElement | null; + portalElement: HTMLElement | null; + /** + * When true, the surface must not move DOM focus onto itself when it + * opens. On mobile, stealing focus blurs the editor's `contentEditable` + * and dismisses the on-screen keyboard. Adapters map this to their own + * library's focus mechanism. + */ + preventFocusOnOpen?: boolean; children?: ReactNode; }; Content: { 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/PortalElementOverride.tsx b/packages/react/src/editor/PortalElementOverride.tsx new file mode 100644 index 0000000000..73d85f1d50 --- /dev/null +++ b/packages/react/src/editor/PortalElementOverride.tsx @@ -0,0 +1,220 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../hooks/useEditorDomElement.js"; +import { useBlockNoteViewContext } from "./BlockNoteViewContext.js"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +// Set by `PortalElementOverride` (a root to escape to) and by +// `PortalElementAnchor` (a UI element's own wrapper); the default comes from +// the editor itself, see `usePortalElement`. +const PortalElementContext = createContext(null); + +/** + * The element the floating UI below should portal into: the nearest + * {@link PortalElementAnchor} (the wrapper of the toolbar, side menu, … that + * opens it), else the nearest {@link PortalElementOverride}'s element, else by + * default the element wrapping the editor element. In the default layout that + * is the editor's `bn-container`; with `renderEditor={false}` it is whatever + * `BlockNoteViewEditor` was rendered into, so the floating UI clips and scrolls + * with the editor rather than escaping into the layout around it. All of these + * sit inside a themed `.bn-root`, so portalled UI keeps the editor's styling + * and color scheme wherever in the DOM it lands. + * + * `null` until the editor has mounted, and on the server. Consumers render + * nothing until it exists. + */ +export function usePortalElement(): HTMLElement | null { + const override = useContext(PortalElementContext); + const editorDOMElement = useEditorDOMElement(); + + if (override) { + return override; + } + + return editorDOMElement?.parentElement ?? null; +} + +/** + * Redirects the floating UI below it into `target`, for UI that must escape + * the editor container — an ancestor's `overflow` clipping it, or a stacking + * context painting it behind the page (see + * `MobileFormattingToolbarController`). + * + * The portal element is a themed `.bn-root` mounted inside `target`, so + * portalled UI stays styled wherever it goes. It is created up front rather + * than rendered, so consumers have it on their first render, and mounted in a + * layout effect, so it is in the DOM before paint. It is also registered with + * the editor, so focus inside it still counts as focus within the editor. + * + * - `undefined` — no redirect; the ambient portal element stays in effect. + * - `null` — `document.body`, escaping every ancestor. + */ +export function PortalElementOverride(props: { + target?: HTMLElement; + children?: ReactNode; +}) { + const { target, children } = props; + + const editor = useBlockNoteEditor(); + const applyThemedRoot = useBlockNoteViewContext()?.applyThemedRoot; + + const [portalElement] = useState(() => + typeof document === "undefined" ? null : document.createElement("div"), + ); + + useIsomorphicLayoutEffect(() => { + if (!portalElement || !target) { + return; + } + + target.appendChild(portalElement); + return () => portalElement.remove(); + }, [portalElement, target]); + + // React does not render this element, so the same theming the editor + // container gets from its props is applied here by hand. + useIsomorphicLayoutEffect(() => { + if (!portalElement || !target) { + return; + } + + applyThemedRoot?.(portalElement); + }, [portalElement, target, applyThemedRoot]); + + // Floating UI portalled out of the editor's DOM tree is still the editor's + // UI: registering the element keeps `editor.isWithinEditor` (and the focus + // tracking built on it) true for what renders inside. + useEffect(() => { + if (!portalElement || !target) { + return; + } + + editor.registerPortalElement(portalElement); + return () => editor.unregisterPortalElement(portalElement); + }, [editor, portalElement, target]); + + if (target === undefined) { + return children; + } + + return ( + + {children} + + ); +} + +/** + * An anchor for the floating UI a UI element opens (its menus, popovers and + * forms): a zero-size, absolutely positioned element next to that UI element, + * inside the wrapper that positions it. What portals into it stays a DOM + * descendant of that wrapper, so it shares the wrapper's stacking context and + * visibility (it hides when the UI element hides) while taking no part in its + * layout. + * + * The anchor exists from the first render (created up front and attached to + * the rendered holder on commit, before any effect runs), so consumers never + * see a `null` and nothing re-renders to pick it up. The holder is rendered + * by React so that it, and with it the anchor, is re-attached whenever the + * wrapper's content is re-rendered. + */ +function usePortalElementAnchor(): { + anchor: HTMLElement | null; + holder: ReactNode; +} { + const [anchor] = useState(() => { + if (typeof document === "undefined") { + return null; + } + const element = document.createElement("span"); + element.className = "bn-portal-anchor"; + return element; + }); + + const holderRef = useCallback( + (holder: HTMLElement | null) => { + if (holder && anchor && anchor.parentElement !== holder) { + holder.appendChild(anchor); + } + }, + [anchor], + ); + + const holder = ( + + ); + + return { anchor, holder }; +} + +const PORTAL_ELEMENT_ANCHOR_HOLDER_CLASS = "bn-portal-anchor-holder"; + +/** + * Whether `element` has rendered children other than a + * {@link PortalElementAnchor}'s holder. The holder means a wrapper that renders + * an anchor is never empty, so "the UI element rendered nothing" has to be + * checked with this instead of the wrapper's `innerHTML`. + */ +export function hasChildrenBesidesPortalElementAnchor( + element: HTMLElement, +): boolean { + return Array.from(element.childNodes).some( + (node) => + !( + node instanceof Element && + node.classList.contains(PORTAL_ELEMENT_ANCHOR_HOLDER_CLASS) + ), + ); +} + +/** + * Renders a portal anchor inside a UI element's wrapper and makes it the + * portal element for everything below (see {@link usePortalElementAnchor}): the + * menus and popovers a toolbar, side menu or table handle opens render inside + * the wrapper that positions and hides that UI element. Nested menus resolve + * to the same anchor, never to their parent dropdown, which may clip. + * + * The anchor is a sibling of the UI element, not a descendant, so it is never + * inside a scrolling part of it (iOS WebKit clips positioned descendants of + * scroll containers); and a `portalElements` override that relocates the + * wrapper takes the anchor, and so the popups, along with it. + * + * Pass a function as `children` to receive the portal element for props that + * need it explicitly. + */ +export function PortalElementAnchor(props: { + children?: ReactNode | ((portalElement: HTMLElement | null) => ReactNode); +}) { + const { anchor, holder } = usePortalElementAnchor(); + const ambient = usePortalElement(); + const portalElement = anchor ?? ambient; + + const children = + typeof props.children === "function" + ? props.children(portalElement) + : props.children; + + return ( + <> + {holder} + + {children} + + + ); +} diff --git a/packages/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..13c8ed36df 100644 --- a/packages/react/src/editor/portalElements.ts +++ b/packages/react/src/editor/portalElements.ts @@ -3,43 +3,39 @@ * * - `HTMLElement` — used as-is. * - `string` — treated as a CSS selector and resolved via `document.querySelector`. - * - `null` — explicit `document.body` (escape any ancestor stacking context). */ -export type PortalTarget = HTMLElement | string | null; +export type PortalElement = HTMLElement | string; /** * 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 element wrapping the + * editor: its `bn-container`, or what `BlockNoteViewEditor` was rendered into. */ 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, +export function resolvePortalElement( + target: PortalElement | undefined, ): HTMLElement | undefined { if (target === undefined) { return undefined; } - if (target === null) { - return typeof document !== "undefined" ? document.body : undefined; - } + if (typeof target === "string") { if (typeof document === "undefined") { return undefined; diff --git a/packages/react/src/hooks/useEditorDomElement.ts b/packages/react/src/hooks/useEditorDomElement.ts index 856765a056..2eaf058b8e 100644 --- a/packages/react/src/hooks/useEditorDomElement.ts +++ b/packages/react/src/hooks/useEditorDomElement.ts @@ -10,9 +10,15 @@ export function useEditorDOMElement(editor?: BlockNoteEditor) { editor = editorContext?.editor; } + if (!editor) { + throw new Error( + "'editor' is required in `useEditorDOMElement`, either from BlockNoteContext or as a function argument", + ); + } + return useEditorState({ editor, - selector: (ctx) => ctx.editor?.domElement, + selector: (ctx) => ctx.editor.domElement, equalityFn: (a, b) => a === b, on: "mount", }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bb1811a729..4c47ee3586 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 { + PortalElementOverride, + usePortalElement, +} from "./editor/PortalElementOverride.js"; +export * from "./editor/UIModeContext.js"; export * from "./components/FormattingToolbar/useVirtualKeyboard.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index a6417a8b27..ddb6067d0c 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, usePortalElement } from "@blocknote/react"; import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; @@ -25,9 +25,14 @@ 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. + // NOTE: Only ShadCN Badge / Tooltip depend on usePortalElement. + // Alternative would be to pass a portalElement to these components, but they + // would be ignored by ariakit / mantine. For now keep these two exceptions + // (ideally skin components don't have a dependency on the editor's context) + const portalElement = usePortalElement(); const badge = ( {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 47114a79c5..7a66eac19e 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,20 +1,23 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); +// Hands the `portalElement` prop from `Menu` (the root) down to +// `MenuDropdown`, where the dropdown's `container` is set. +const MenuPortalElementContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { children, onOpenChange, position: _position, // Unused - portalRoot, + portalElement, + // base-ui manages menu focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, sub, ...rest } = props; @@ -28,9 +31,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - + {children} - + ); } else { @@ -39,9 +42,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { modal={false} onOpenChange={onOpenChange} > - + {children} - + ); } @@ -81,11 +84,11 @@ 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; + // The `portalElement` supplied at the call site is a themed `.bn-root`, so the + // menu inherits light/dark mode instead of the document body's. + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(MenuPortalElementContext); if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 1ccb01243f..98bb1058a5 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,13 +1,13 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useBlockNoteEditor } from "@blocknote/react"; +import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); +// Hands the `portalElement` prop from `Popover` (the root) down to +// `PopoverContent`, where the content's `container` is set. +const PopoverPortalElementContext = createContext(null); export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], @@ -17,7 +17,10 @@ export const Popover = ( open, onOpenChange, position: _position, // unused - portalRoot, + portalElement, + // base-ui manages popover focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, ...rest } = props; @@ -27,9 +30,9 @@ export const Popover = ( return ( - + {children} - + ); }; @@ -61,16 +64,17 @@ 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(); + // The `portalElement` supplied at the call site is a themed `.bn-root`, so + // popovers inherit light/dark mode instead of the document body's, and escape + // the mobile formatting toolbar's horizontal scroll clip. + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(PopoverPortalElementContext); 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. + // NOTE: Only ShadCN Badge / Tooltip depend on usePortalElement. + // Alternative would be to pass a portalElement to these components, but they + // would be ignored by ariakit / mantine. For now keep these two exceptions + // (ideally skin components don't have a dependency on the editor's context) + + const portalElement = usePortalElement(); const trigger = isSelected === undefined ? ( @@ -111,7 +117,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -126,16 +132,21 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalElement, + // base-ui manages select focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; - // Default to the editor's portal element (which carries the color-scheme - // class) so the dropdown inherits light/dark mode instead of the body's. - const editor = useBlockNoteEditor(); - // TODO? const SelectItemContent = (props: any) => (
@@ -163,7 +174,7 @@ export const ToolbarSelect = forwardRef< { await expectElement(document.body).toMatchScreenshot( "ariakit-drag-handle-menu", ); + + // The colors submenu opens over the side menu. Menus render inside the + // side menu's wrapper, so they paint above its buttons; a menu portalled + // elsewhere with Ariakit's own z-index would be covered by the drag handle. + await moveMouseOverElement( + Array.from(document.querySelectorAll("[role=menuitem]")).find((item) => + item.textContent?.includes("Colors"), + )!, + ); + const submenu = await waitForSelector(".bn-color-picker-dropdown"); + const handle = document + .querySelector(DRAG_HANDLE_SELECTOR)! + .getBoundingClientRect(); + const onTop = document.elementFromPoint( + handle.x + handle.width / 2, + handle.y + handle.height / 2, + ); + const submenuRect = submenu.getBoundingClientRect(); + const overlaps = + handle.x < submenuRect.right && + handle.right > submenuRect.x && + handle.y < submenuRect.bottom && + handle.bottom > submenuRect.y; + if (overlaps) { + expect(submenu.contains(onTop)).toBe(true); + } }); test("Check image toolbar", async () => { await focusOnEditor(); diff --git a/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx b/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx new file mode 100644 index 0000000000..f1e2308be5 --- /dev/null +++ b/tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx @@ -0,0 +1,64 @@ +import App from "@examples/01-basic/testing/src/App"; +import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, sleep, waitForSelector } from "../../utils/editor.js"; +import { moveMouseOverElement } from "../../utils/mouse.js"; + +// The link forms open from toolbars whose menus and popovers portal next to +// the toolbar rather than inside it. These tests click with `userEvent.click` +// on purpose: it presses and releases within the same tick, which is how the +// mantine toolbar's former focus trap used to steal the form's autofocus back +// into the toolbar before a human could type. + +beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); +}); + +const LINK_SELECTOR = 'a[data-inline-content-type="link"]'; + +async function createLink(url: string) { + await focusOnEditor(); + await userEvent.keyboard("Paragraph"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + await userEvent.click(await waitForSelector(LINK_BUTTON_SELECTOR)); + await userEvent.keyboard(url); + await userEvent.keyboard("{Enter}"); + await waitForSelector(LINK_SELECTOR); +} + +describe("Link forms keep focus after an instantaneous click", () => { + test("Create link: typing right after the click fills the URL field", async () => { + await createLink("https://example.com"); + + expect(document.querySelector(LINK_SELECTOR)?.href).toBe( + "https://example.com/", + ); + }); + + test("Link toolbar: Edit focuses the URL field and Enter applies the change", async () => { + await createLink("https://example.com"); + + await userEvent.keyboard("{End}"); + await userEvent.keyboard("{ArrowLeft}"); + await moveMouseOverElement(LINK_SELECTOR); + const linkToolbar = await waitForSelector(".bn-link-toolbar"); + + await userEvent.click(linkToolbar.querySelector("button")!); + await sleep(300); + + const input = await waitForSelector(".bn-form-popover input"); + expect(document.activeElement).toBe(input); + + await userEvent.keyboard("{Control>}a{/Control}{Meta>}a{/Meta}"); + await userEvent.keyboard("https://changed.example"); + await userEvent.keyboard("{Enter}"); + await sleep(300); + + expect(document.querySelector(LINK_SELECTOR)?.href).toBe( + "https://changed.example/", + ); + }); +}); diff --git a/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx b/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx new file mode 100644 index 0000000000..d6c54d05d8 --- /dev/null +++ b/tests/src/end-to-end/portals/floatingComponentMenus.test.tsx @@ -0,0 +1,208 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView as AriakitBlockNoteView } from "@blocknote/ariakit"; +import "@blocknote/ariakit/style.css"; +import { BlockNoteView as MantineBlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { PortalElementsMap, useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView as ShadCNBlockNoteView } from "@blocknote/shadcn"; +import "@blocknote/shadcn/style.css"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { ComponentType, useEffect } from "react"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { waitForSelector } from "../../utils/editor.js"; + +// The menus and popovers a floating component opens (here: the formatting +// toolbar's block type menu) render inside that component's wrapper, next to +// the component, not in the editor container. These tests pin what that buys +// the user: the menu hides together with its toolbar, it travels with the +// toolbar when `portalElements` relocates it, and the toolbar still fades out +// showing its content. They run per skin because each skin brings its own +// menu implementation, and Mantine's would hide its menu on its own +// (`hideDetached`) while Ariakit's and shadcn's would stay orphaned on screen. + +type ViewProps = { + editor: BlockNoteEditor; + portalElements?: PortalElementsMap; +}; + +const skins: { name: string; View: ComponentType }[] = [ + { + name: "mantine", + View: (props) => , + }, + { + name: "ariakit", + View: (props) => , + }, + { + name: "shadcn", + View: (props) => , + }, +]; + +function ScrollingEditor(props: { + View: ComponentType; + portalElements?: PortalElementsMap; + onEditor: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote({ + initialContent: Array.from({ length: 12 }, (_, i) => ({ + type: "paragraph" as const, + content: `Paragraph ${i}`, + })), + }); + + useEffect(() => { + props.onEditor(editor); + }, [editor, props]); + + // A short scroll container, so a selection can be scrolled out of view. + return ( +
+ +
+ ); +} + +async function renderEditor(props: { + View: ComponentType; + portalElements?: PortalElementsMap; +}) { + let editor: BlockNoteEditor | undefined; + + await render( + { + editor = value; + }} + />, + ); + await waitForSelector(".bn-editor"); + await vi.waitFor(() => { + if (!editor) { + throw new Error("Editor was not created"); + } + }); + + if (!editor) { + throw new Error("Editor was not created"); + } + + return editor; +} + +function createPortalTarget(id: string) { + const target = document.createElement("div"); + target.id = id; + target.dataset.testPortalTarget = ""; + document.body.append(target); + return target; +} + +/** Selects text in the first paragraph, which shows the formatting toolbar. */ +async function showFormattingToolbar(editor: BlockNoteEditor) { + editor.focus(); + editor._tiptapEditor.commands.setTextSelection({ from: 3, to: 12 }); + return waitForSelector(".bn-formatting-toolbar"); +} + +/** + * Opens the block type menu from the toolbar's first control. Ariakit and + * shadcn render the menu as a listbox, Mantine as a menu. + */ +async function openBlockTypeMenu(toolbar: HTMLElement) { + await userEvent.click(toolbar.querySelector("button, [role=combobox]")!); + return waitForSelector("[role=menu], [role=listbox]"); +} + +function isVisible(element: Element) { + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + getComputedStyle(element).visibility !== "hidden" + ); +} + +afterEach(() => { + document + .querySelectorAll("[data-test-portal-target]") + .forEach((target) => target.remove()); +}); + +describe.each(skins)( + "Menus opened from a floating component ($name)", + ({ View }) => { + test("render next to the component, inside its wrapper", async () => { + const editor = await renderEditor({ View }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + + const wrapper = toolbar.parentElement!; + expect(wrapper.contains(menu)).toBe(true); + expect(toolbar.contains(menu)).toBe(false); + }); + + test("hide when the component hides", async () => { + const editor = await renderEditor({ View }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + expect(isVisible(menu)).toBe(true); + + // Scroll the selection out of view: the toolbar's reference is hidden, so + // its wrapper gets `visibility: hidden`, and the menu must go with it. + const scroller = document.querySelector( + "[data-test=scroller]", + )!; + scroller.scrollTop = scroller.scrollHeight; + scroller.dispatchEvent(new Event("scroll")); + + await vi.waitFor(() => { + expect(getComputedStyle(toolbar.parentElement!).visibility).toBe( + "hidden", + ); + }); + expect(isVisible(menu)).toBe(false); + }); + + test("follow the component when portalElements relocates it", async () => { + const target = createPortalTarget("portal-target"); + const editor = await renderEditor({ + View, + portalElements: { default: target }, + }); + const toolbar = await showFormattingToolbar(editor); + const menu = await openBlockTypeMenu(toolbar); + + expect(target.contains(toolbar)).toBe(true); + expect(target.contains(menu)).toBe(true); + expect(document.querySelector(".bn-container")!.contains(menu)).toBe( + false, + ); + }); + }, +); + +describe("A floating component that closes", () => { + test("still shows its content while fading out", async () => { + const editor = await renderEditor({ View: skins[0].View }); + const toolbar = await showFormattingToolbar(editor); + + // Collapse the selection: the live toolbar is replaced by a snapshot that + // fades out, and that snapshot must still show the toolbar. + editor._tiptapEditor.commands.setTextSelection(3); + await vi.waitFor(() => { + expect(document.querySelector(".bn-formatting-toolbar")).not.toBe( + toolbar, + ); + }); + expect(document.querySelector(".bn-formatting-toolbar")).not.toBeNull(); + + await vi.waitFor(() => { + expect(document.querySelector(".bn-formatting-toolbar")).toBeNull(); + }); + }); +}); 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..2ec11d1825 --- /dev/null +++ b/tests/src/end-to-end/portals/portalElements.test.tsx @@ -0,0 +1,205 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + BlockNoteViewEditor, + PortalElementsMap, + useCreateBlockNote, +} from "@blocknote/react"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { render } from "vitest-browser-react"; +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 ( + + ); +} + +/** + * A layout that renders the editor itself: a scrolling pane with the editor + * next to a sidebar, both inside the `BlockNoteView`, as an app would. + */ +function ManualLayoutEditor(props: { + onEditor: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote(); + + useEffect(() => { + props.onEditor(editor); + }, [editor, props]); + + return ( + +
+
+ +
+
+
+ + ); +} + +function createPortalTarget(id: string, className?: string) { + const target = document.createElement("div"); + target.id = id; + 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"); + + const editor = await renderEditor({ + portalElements: { + default: defaultTarget, + slashMenu: "#slash-portal-target", + }, + theme: "dark", + }); + const menu = await openSlashMenu(); + const root = menu.closest(".bn-root"); + + expect(defaultTarget.contains(menu)).toBe(false); + expect(slashTarget.contains(menu)).toBe(true); + expect(root?.parentElement).toBe(slashTarget); + expect(root?.getAttribute("data-mantine-color-scheme")).toBe("dark"); + expect(editor.isWithinEditor(menu)).toBe(true); + }); + + test("portals into document.body without registering the whole page", async () => { + const editor = await renderEditor({ + portalElements: { slashMenu: document.body }, + 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); + }); + + test("portals next to the editor when the layout renders it manually", async () => { + let editor: BlockNoteEditor | undefined; + await render( + { + editor = value; + }} + />, + ); + await waitForSelector(".bn-editor"); + await vi.waitFor(() => { + if (!editor) { + throw new Error("Editor was not created"); + } + }); + if (!editor) { + throw new Error("Editor was not created"); + } + + const menu = await openSlashMenu(); + const pane = document.querySelector("[data-test=pane]")!; + + // The menu lives in the editor's pane, so it clips and scrolls with the + // editor instead of spilling over the sidebar next to it. + expect(pane.contains(menu)).toBe(true); + expect(editor.isWithinEditor(menu)).toBe(true); + }); +}); diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts index 93d7f13b73..54d8165968 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -18,7 +18,9 @@ export const TABLE_SELECTOR = `[data-content-type="table"]`; export const DRAG_HANDLE_SELECTOR = `[data-test="dragHandle"]`; export const DRAG_HANDLE_ADD_SELECTOR = `[data-test="dragHandleAdd"]`; -export const DRAG_HANDLE_MENU_SELECTOR = `.bn-side-menu > .bn-menu-dropdown`; +// The menu is portalled into the editor container, so it is not a descendant +// of the side menu that opens it; match it by its own class. +export const DRAG_HANDLE_MENU_SELECTOR = `.bn-drag-handle-menu`; export const SLASH_MENU_SELECTOR = `.bn-suggestion-menu`; export const EMOJI_PICKER_SELECTOR = `.bn-grid-suggestion-menu`;