Skip to content
Draft
2 changes: 1 addition & 1 deletion docs/content/docs/react/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ By default, all floating UI elements (toolbars, menus, table handles, etc.) port
portalElements={{
// Global default for any element not listed below.
default: document.body,
// Per-element overrides. Values can be HTMLElement, a CSS selector, or null (= document.body).
// Per-element overrides. Values can be an HTMLElement or a CSS selector.
tableHandles: ".bn-container",
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useEditorPortalElement,
usePortalElement,
useSelectedBlocks,
} from "@blocknote/react";
import { useCallback, useEffect, useState } from "react";
Expand All @@ -24,7 +24,7 @@ export const FileReplaceButton = () => {
const dict = useDictionary();
const Components = useComponentsContext()!;
// Portal necessary to properly show popover on mobile.
const editorPortalElement = useEditorPortalElement();
const editorPortalElement = usePortalElement();

const editor = useBlockNoteEditor<
BlockSchema,
Expand Down Expand Up @@ -68,7 +68,7 @@ export const FileReplaceButton = () => {
open={isOpen}
onOpenChange={setIsOpen}
position={"bottom"}
portalRoot={editorPortalElement}
portalElement={editorPortalElement}
>
<Components.Generic.Popover.Trigger>
<Components.FormattingToolbar.Button
Expand Down
11 changes: 10 additions & 1 deletion examples/07-collaboration/05-comments/src/SettingsSelect.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
22 changes: 13 additions & 9 deletions packages/ariakit/src/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// Threads the `portalElement` override from `Menu` (the provider) down to
// `MenuDropdown`, where ariakit's `portalElement` prop actually lives.
const PortalRootContext = createContext<HTMLElement | null | undefined>(
undefined,
);
const PortalElementContext = createContext<HTMLElement | null>(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;
Expand All @@ -37,9 +38,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
setOpen={onOpenChange}
virtualFocus={true}
>
<PortalRootContext.Provider value={portalRoot}>
<PortalElementContext.Provider value={portalElement}>
{children}
</PortalRootContext.Provider>
</PortalElementContext.Provider>
</AriakitMenuProvider>
);
};
Expand All @@ -57,13 +58,16 @@ export const MenuDropdown = forwardRef<

assertEmpty(rest);

const portalRoot = useContext(PortalRootContext);
const portalElement = useContext(PortalElementContext);

return (
<AriakitMenu
unmountOnHide={true}
className={mergeCSSClasses("bn-ak-menu", className || "")}
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}
ref={ref}
>
{children}
Expand Down
25 changes: 17 additions & 8 deletions packages/ariakit/src/popover/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { createContext, forwardRef, useContext } from "react";

const PortalRootContext = createContext<HTMLElement | null | undefined>(
undefined,
);
const PortalElementContext = createContext<HTMLElement | null>(null);

export const PopoverTrigger = forwardRef<
HTMLButtonElement,
Expand All @@ -31,7 +29,7 @@ export const PopoverContent = forwardRef<

assertEmpty(rest);

const portalRoot = useContext(PortalRootContext);
const portalElement = useContext(PortalElementContext);

return (
<AriakitPopover
Expand All @@ -40,7 +38,10 @@ export const PopoverContent = forwardRef<
className || "",
variant === "panel-popover" ? "bn-ak-panel-popover" : "",
)}
portalElement={portalRoot ?? undefined}
// Ariakit falls back to a body-appended div for a missing element, so
// don't portal at all until there is one (editor not mounted yet).
portal={portalElement !== null}
portalElement={portalElement}
ref={ref}
>
{children}
Expand All @@ -51,7 +52,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);

Expand All @@ -61,9 +70,9 @@ export const Popover = (
setOpen={onOpenChange}
placement={position}
>
<PortalRootContext.Provider value={portalRoot}>
<PortalElementContext.Provider value={portalElement}>
{children}
</PortalRootContext.Provider>
</PortalElementContext.Provider>
</AriakitPopoverProvider>
);
};
14 changes: 12 additions & 2 deletions packages/ariakit/src/toolbar/ToolbarSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) => (
<AriakitSelectItem
Expand Down
62 changes: 27 additions & 35 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,25 +719,14 @@ export class BlockNoteEditor<
* Mount the editor to a DOM element.
*
* @param element The DOM element to mount the editor's contenteditable into.
* @param options.portalTarget An element to register as a portal root — a
* convenience for {@link registerPortalRoot}, for non-React setups that
* render the editor's floating UI outside the editor's DOM tree, so
* {@link isWithinEditor} recognizes it. An ordinary registration like any
* other: release it with {@link unregisterPortalRoot} if ever needed. Not
* needed for UI rendered next to the contenteditable (the mount element's
* parent already counts as within the editor). Prefer a dedicated
* container over e.g. `document.body`, which would make the whole page
* count.
*
* Floating UI rendered next to the contenteditable counts as within the
* editor already; UI rendered outside its DOM tree has to be registered with
* {@link registerPortalElement} so {@link isWithinEditor} recognizes it.
*
* @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
*/
public mount = (
element: HTMLElement,
options?: { portalTarget?: HTMLElement },
) => {
if (options?.portalTarget) {
this.registerPortalRoot(options.portalTarget);
}
public mount = (element: HTMLElement) => {
this._tiptapEditor.mount({ mount: element });
};

Expand Down Expand Up @@ -772,55 +761,58 @@ export class BlockNoteEditor<
return this.prosemirrorView?.dom as HTMLDivElement | undefined;
}

// Portal roots registered by the view layer, with reference counts so
// multiple UI elements can share a root (e.g. several popovers portalling
// into the same custom target).
private _portalRoots = new Map<HTMLElement, number>();
// 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<HTMLElement, number>();

/**
* Registers an element as a portal root for this editor's floating UI, so
* Registers an element as a portal element for this editor's floating UI, so
* {@link isWithinEditor} treats its contents as part of the editor. The view
* layer calls this for each portal target it designates (see
* `EditorPortalProvider` in `@blocknote/react`) — without it, UI portalled outside
* layer calls this for each portal element it designates (see
* `PortalElementOverride` in `@blocknote/react`) — without it, UI portalled outside
* the editor's DOM tree would be considered outside the editor.
* Registrations are reference-counted; release with
* {@link unregisterPortalRoot}.
* {@link unregisterPortalElement}.
*/
public registerPortalRoot = (element: HTMLElement) => {
this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1);
public registerPortalElement = (element: HTMLElement) => {
this._portalElements.set(
element,
(this._portalElements.get(element) ?? 0) + 1,
);
};

/**
* Releases a registration made with {@link registerPortalRoot}. The element
* Releases a registration made with {@link registerPortalElement}. The element
* stops counting as part of the editor once every registration for it has
* been released.
*/
public unregisterPortalRoot = (element: HTMLElement) => {
const count = this._portalRoots.get(element);
public unregisterPortalElement = (element: HTMLElement) => {
const count = this._portalElements.get(element);
if (count === undefined) {
return;
}

if (count <= 1) {
this._portalRoots.delete(element);
this._portalElements.delete(element);
} else {
this._portalRoots.set(element, count - 1);
this._portalElements.set(element, count - 1);
}
};

/**
* Checks whether a DOM element belongs to this editor — inside the editor's
* DOM tree, or inside any portal root registered via
* {@link registerPortalRoot} (used for floating UI elements like menus and
* DOM tree, or inside any portal element registered via
* {@link registerPortalElement} (used for floating UI elements like menus and
* toolbars, which may portal outside the editor's DOM tree).
*/
public isWithinEditor = (element: Element): boolean => {
if (this.domElement?.parentElement?.contains(element)) {
return true;
}

for (const root of this._portalRoots.keys()) {
if (root.contains(element)) {
for (const portalElement of this._portalElements.keys()) {
if (portalElement.contains(element)) {
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
},
Expand Down
Loading
Loading