-
-
-
+
+
+
- ,
- 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`;