From a235126b8bf072be1c9ba9144bf24ab1b84df1b0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 May 2026 16:43:20 +0200 Subject: [PATCH] feat(react): configurable portal targets for floating UI Adds a `portalElements` prop on `BlockNoteView` for configuring where floating UI elements (slash menu, formatting toolbar, side menu, table handles, etc) portal to, plus a per-Controller `portalElement` override. Also changes `editor.mount()` to default to `element.parentElement` (i.e. `bn-container`) instead of `document.body` so floating UI is contained within the editor by default. Pass `portalElements={{ default: document.body }}` to opt back into the previous behaviour. Resolves #2692. --- .../20-portal-elements/.bnexample.json | 6 ++ .../20-portal-elements/README.md | 20 ++++++ .../20-portal-elements/index.html | 14 ++++ .../20-portal-elements/main.tsx | 11 +++ .../20-portal-elements/package.json | 30 ++++++++ .../20-portal-elements/src/App.tsx | 58 ++++++++++++++++ .../20-portal-elements/src/styles.css | 68 +++++++++++++++++++ .../20-portal-elements/tsconfig.json | 36 ++++++++++ .../20-portal-elements/vite.config.ts | 32 +++++++++ packages/core/src/editor/BlockNoteEditor.ts | 24 +++++-- .../Comments/FloatingComposerController.tsx | 12 +++- .../Comments/FloatingThreadController.tsx | 12 +++- .../FilePanel/FilePanelController.tsx | 12 +++- .../FormattingToolbarController.tsx | 12 +++- .../LinkToolbar/LinkToolbarController.tsx | 12 +++- .../src/components/Popovers/BlockPopover.tsx | 9 ++- .../components/Popovers/GenericPopover.tsx | 12 +++- .../components/Popovers/PositionPopover.tsx | 9 ++- .../SideMenu/SideMenuController.tsx | 12 +++- .../GridSuggestionMenuController.tsx | 12 +++- .../SuggestionMenuController.tsx | 12 +++- .../TableHandles/TableHandlesController.tsx | 11 +++ .../react/src/editor/BlockNoteDefaultUI.tsx | 48 +++++++++++-- packages/react/src/editor/BlockNoteView.tsx | 26 ++++++- .../react/src/editor/BlockNoteViewContext.ts | 7 ++ packages/react/src/editor/portalElements.ts | 57 ++++++++++++++++ playground/src/examples.gen.tsx | 20 ++++++ pnpm-lock.yaml | 43 ++++++++++++ 28 files changed, 608 insertions(+), 29 deletions(-) create mode 100644 examples/03-ui-components/20-portal-elements/.bnexample.json create mode 100644 examples/03-ui-components/20-portal-elements/README.md create mode 100644 examples/03-ui-components/20-portal-elements/index.html create mode 100644 examples/03-ui-components/20-portal-elements/main.tsx create mode 100644 examples/03-ui-components/20-portal-elements/package.json create mode 100644 examples/03-ui-components/20-portal-elements/src/App.tsx create mode 100644 examples/03-ui-components/20-portal-elements/src/styles.css create mode 100644 examples/03-ui-components/20-portal-elements/tsconfig.json create mode 100644 examples/03-ui-components/20-portal-elements/vite.config.ts create mode 100644 packages/react/src/editor/portalElements.ts diff --git a/examples/03-ui-components/20-portal-elements/.bnexample.json b/examples/03-ui-components/20-portal-elements/.bnexample.json new file mode 100644 index 0000000000..40dfffd4d9 --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/.bnexample.json @@ -0,0 +1,6 @@ +{ + "playground": true, + "docs": true, + "author": "nperez0111", + "tags": ["UI Components", "Advanced"] +} diff --git a/examples/03-ui-components/20-portal-elements/README.md b/examples/03-ui-components/20-portal-elements/README.md new file mode 100644 index 0000000000..c0e4a5c34a --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/README.md @@ -0,0 +1,20 @@ +# Configuring Portal Targets per Element + +By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container` element. The `portalElements` prop lets you change that — globally via `default`, or per element by key. + +In this example we deliberately wrap the editor in a small parent with `overflow: hidden` so the global default of `bn-container` would clip the slash menu and the formatting toolbar. We escape only those two to `document.body`, while keeping `tableHandles` inside `.bn-container` so the table handles can never escape the editor's visual boundary. + +```tsx + +``` + +**Relevant Docs:** + +- [UI Components](/docs/react/components) diff --git a/examples/03-ui-components/20-portal-elements/index.html b/examples/03-ui-components/20-portal-elements/index.html new file mode 100644 index 0000000000..a1d00cee32 --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/index.html @@ -0,0 +1,14 @@ + + + + + Configuring Portal Targets per Element + + + +
+ + + diff --git a/examples/03-ui-components/20-portal-elements/main.tsx b/examples/03-ui-components/20-portal-elements/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/examples/03-ui-components/20-portal-elements/package.json b/examples/03-ui-components/20-portal-elements/package.json new file mode 100644 index 0000000000..2ecd0a811d --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocknote/example-ui-components-portal-elements", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/03-ui-components/20-portal-elements/src/App.tsx b/examples/03-ui-components/20-portal-elements/src/App.tsx new file mode 100644 index 0000000000..0434ff819b --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/src/App.tsx @@ -0,0 +1,58 @@ +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote, type PortalElementsMap } from "@blocknote/react"; + +import "./styles.css"; + +const initialContent = [ + { + type: "paragraph" as const, + content: "Click in this editor and press / to open the slash menu.", + }, + { + type: "paragraph" as const, + content: + "Notice whether the menu fits inside the box or escapes it.", + }, + { + type: "paragraph" as const, + }, +]; + +function PortalDemoEditor({ + label, + description, + portalElements, +}: { + label: string; + description: string; + portalElements?: PortalElementsMap; +}) { + const editor = useCreateBlockNote({ initialContent }); + return ( +
+
{label}
+
{description}
+
+ +
+
+ ); +} + +export default function App() { + return ( +
+ + +
+ ); +} diff --git a/examples/03-ui-components/20-portal-elements/src/styles.css b/examples/03-ui-components/20-portal-elements/src/styles.css new file mode 100644 index 0000000000..8cf28385d0 --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/src/styles.css @@ -0,0 +1,68 @@ +.views { + container-name: views; + container-type: inline-size; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + padding: 8px; +} + +/* + * Each view is intentionally shorter than the slash menu so the clipping + * vs escaping behaviour is visible at a glance. + */ +.view-wrapper { + display: flex; + flex-direction: column; + height: 260px; + width: 100%; +} + +@container views (width > 1024px) { + .view-wrapper { + width: calc(50% - 4px); + } +} + +.view-label { + color: #0090ff; + display: flex; + font-size: 12px; + font-weight: bold; + justify-content: space-between; + margin-inline: 16px; +} + +.view-description { + color: #0090ff; + font-size: 12px; + margin: 2px 16px 0; +} + +/* + * `position: relative` is what actually makes `overflow: hidden` clip the + * absolutely-positioned floating UI. Without it the popover's containing + * block is the viewport and the clip is bypassed. + */ +.view { + border: solid #0090ff 1px; + border-radius: 16px; + flex: 1; + height: 0; + padding: 8px; + position: relative; + overflow: hidden; +} + +.view .bn-container { + height: 100%; + margin: 0; + max-width: none; + padding: 0; +} + +.view .bn-editor { + height: 100%; + overflow: auto; +} diff --git a/examples/03-ui-components/20-portal-elements/tsconfig.json b/examples/03-ui-components/20-portal-elements/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/20-portal-elements/vite.config.ts b/examples/03-ui-components/20-portal-elements/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/20-portal-elements/vite.config.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), + } as any), + }, +})); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ca6e0b4817..fbd8c9409b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -731,15 +731,27 @@ export class BlockNoteEditor< /** * Mount the editor to a DOM element. * + * @param element The DOM element to mount the editor's contenteditable into. + * @param options.portalTarget Where to mount `editor.portalElement` — the + * container that floating UI (toolbars, menus, etc) portals into. When + * omitted, defaults to `element.parentElement` (which is the editor's + * `bn-container` in typical React usage), or to `document.body` / + * the surrounding shadow root when no parent is available. + * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ - public mount = (element: HTMLElement) => { + public mount = ( + element: HTMLElement, + options?: { portalTarget?: HTMLElement | null }, + ) => { const root = element.getRootNode(); - if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) { - root.appendChild(this.portalElement); - } else { - document.body.appendChild(this.portalElement); - } + const isInShadowRoot = + typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot; + const target = + options?.portalTarget ?? + element.parentElement ?? + (isInShadowRoot ? (root as ShadowRoot) : document.body); + target.appendChild(this.portalElement); this._tiptapEditor.mount({ mount: element }); }; diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 90faec1d20..0c41402d66 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -24,6 +24,12 @@ export default function FloatingComposerController< >(props: { floatingComposer?: FC>; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) { const editor = useBlockNoteEditor(); @@ -82,7 +88,11 @@ 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 c57a1c9295..20799c1dea 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -16,6 +16,12 @@ import { useThreads } from "./useThreads.js"; export default function FloatingThreadController(props: { floatingThread?: FC>; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) { const editor = useBlockNoteEditor(); @@ -78,7 +84,11 @@ export default function FloatingThreadController(props: { const Component = props.floatingThread || Thread; return ( - + {thread && } ); diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index 6beb94ec1e..b9da146874 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -12,6 +12,12 @@ import { FilePanelProps } from "./FilePanelProps.js"; export const FilePanelController = (props: { filePanel?: FC; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) => { const editor = useBlockNoteEditor(); @@ -54,7 +60,11 @@ export const FilePanelController = (props: { const Component = props.filePanel || FilePanel; return ( - + {blockId && } ); diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index 20184e626f..a10469eab1 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -36,6 +36,12 @@ const textAlignmentToPlacement = ( export const FormattingToolbarController = (props: { formattingToolbar?: FC; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) => { const editor = useBlockNoteEditor< BlockSchema, @@ -112,7 +118,11 @@ export const FormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( - + {show && } ); diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 9e7fe42d07..fbe789a544 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -17,6 +17,12 @@ import { LinkToolbarProps } from "./LinkToolbarProps.js"; export const LinkToolbarController = (props: { linkToolbar?: FC; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) => { const editor = useBlockNoteEditor(); @@ -178,7 +184,11 @@ export const LinkToolbarController = (props: { const Component = props.linkToolbar || LinkToolbar; return ( - + {link && ( { - const { blockId, children, ...floatingUIOptions } = props; + const { blockId, children, portalElement, ...floatingUIOptions } = props; const editor = useBlockNoteEditor(); @@ -43,7 +44,11 @@ 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 5eb08edc4d..d03534a49d 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -109,10 +109,20 @@ 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 = editor?.portalElement; + const portalRoot = + props.portalElement === null + ? typeof document !== "undefined" + ? document.body + : undefined + : (props.portalElement ?? editor?.portalElement); if (!portalRoot) { throw new Error("Portal element not found"); } diff --git a/packages/react/src/components/Popovers/PositionPopover.tsx b/packages/react/src/components/Popovers/PositionPopover.tsx index 93ef837f61..f59b458900 100644 --- a/packages/react/src/components/Popovers/PositionPopover.tsx +++ b/packages/react/src/components/Popovers/PositionPopover.tsx @@ -10,9 +10,10 @@ export const PositionPopover = ( props: FloatingUIOptions & { position: { from: number; to?: number } | undefined; children: ReactNode; + portalElement?: HTMLElement | null; }, ) => { - const { position, children, ...floatingUIOptions } = props; + const { position, children, portalElement, ...floatingUIOptions } = props; const { from, to } = position || {}; const editor = useBlockNoteEditor(); @@ -34,7 +35,11 @@ export const PositionPopover = ( }, [editor, editorDOMElement, from, to]); return ( - + {position !== undefined && children} ); diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 37022b4d16..4f70e4b4c4 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -12,6 +12,12 @@ import { SideMenuProps } from "./SideMenuProps.js"; export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) => { const editor = useBlockNoteEditor(); const state = useExtensionState(SideMenuExtension, { @@ -89,7 +95,11 @@ export const SideMenuController = (props: { const Component = props.sideMenu || SideMenu; return ( - + {block?.id && } ); diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index a0fdcb61d4..1fe667635b 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -44,6 +44,12 @@ export function GridSuggestionMenuController< shouldOpen?: SuggestionMenuOptions["shouldOpen"]; minQueryLength?: number; floatingUIOptions?: FloatingUIOptions; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; } & (ItemType extends DefaultReactGridSuggestionItem ? { // can be undefined @@ -178,7 +184,11 @@ export function GridSuggestionMenuController< } return ( - + {triggerCharacter && ( extends DefaultReactSuggestionItem ? { // can be undefined @@ -171,7 +177,11 @@ export function SuggestionMenuController< } return ( - + {triggerCharacter && ( ; tableHandle?: FC; extendButton?: FC; + /** + * Override the DOM node this floating element portals into. Falls back to + * `editor.portalElement` (which by default is mounted inside `bn-container`) + * when omitted. + */ + portalElement?: HTMLElement | null; }) => { const editor = useBlockNoteEditor(); @@ -312,6 +318,7 @@ export const TableHandlesController = < <> {state.show && @@ -327,6 +334,7 @@ export const TableHandlesController = < {state.show && @@ -342,6 +350,7 @@ export const TableHandlesController = < {state.show && @@ -357,6 +366,7 @@ export const TableHandlesController = < {state.show && @@ -372,6 +382,7 @@ export const TableHandlesController = < {state.show && diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 6ea66d094e..f5044e837d 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -17,6 +17,10 @@ import { GridSuggestionMenuController } from "../components/SuggestionMenu/GridS import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController.js"; import { TableHandlesController } from "../components/TableHandles/TableHandlesController.js"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; +import { + PortalElementsMap, + resolvePortalTarget, +} from "./portalElements.js"; // Lazily load the comments components to avoid pulling in the comments extensions into the main bundle const FloatingComposerController = lazy( @@ -74,6 +78,18 @@ export type BlockNoteDefaultUIProps = { * @see {@link https://blocknotejs.org/docs/react/components/comments} */ comments?: boolean; + + /** + * Per-element portal targets for floating UI. Each key corresponds to one + * of the default UI elements; values can be an `HTMLElement`, a CSS + * selector string, or `null` (= `document.body`). The optional `default` + * key controls where `editor.portalElement` itself is mounted; when + * omitted, the editor's `bn-container` element is used. + * + * Per-element keys override `default` for that one element. Unspecified + * elements fall back to `default` via `editor.portalElement`. + */ + portalElements?: PortalElementsMap; }; export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { @@ -85,18 +101,33 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { ); } + const map = props.portalElements; + const formattingToolbarPortal = resolvePortalTarget(map?.formattingToolbar); + const linkToolbarPortal = resolvePortalTarget(map?.linkToolbar); + const slashMenuPortal = resolvePortalTarget(map?.slashMenu); + const emojiPickerPortal = resolvePortalTarget(map?.emojiPicker); + const sideMenuPortal = resolvePortalTarget(map?.sideMenu); + const filePanelPortal = resolvePortalTarget(map?.filePanel); + const tableHandlesPortal = resolvePortalTarget(map?.tableHandles); + const commentsPortal = resolvePortalTarget(map?.comments); + return ( <> {editor.getExtension(FormattingToolbarExtension) && - props.formattingToolbar !== false && } + props.formattingToolbar !== false && ( + + )} {editor.getExtension(LinkToolbarExtension) && - props.linkToolbar !== false && } + props.linkToolbar !== false && ( + + )} {editor.getExtension(SuggestionMenu) && props.slashMenu !== false && ( !state.selection.$from.parent.type.isInGroup("tableContent") } + portalElement={slashMenuPortal} /> )} {editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && ( @@ -104,20 +135,23 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { triggerCharacter=":" columns={10} minQueryLength={2} + portalElement={emojiPickerPortal} /> )} {editor.getExtension(SideMenuExtension) && props.sideMenu !== false && ( - + )} {editor.getExtension(FilePanelExtension) && props.filePanel !== false && ( - + )} {editor.getExtension(TableHandlesExtension) && - props.tableHandles !== false && } + props.tableHandles !== false && ( + + )} {editor.getExtension(CommentsExtension) && props.comments !== false && ( - - + + )} diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index a4679af611..d6e6f85b8e 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -27,6 +27,7 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; +import { resolvePortalTarget } from "./portalElements.js"; import { BlockNoteViewContext, useBlockNoteViewContext, @@ -93,6 +94,10 @@ export type BlockNoteViewProps< 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"; + function BlockNoteViewComponent< BSchema extends BlockSchema, ISchema extends InlineContentSchema, @@ -121,11 +126,20 @@ function BlockNoteViewComponent< filePanel, tableHandles, comments, + portalElements, autoFocus, renderEditor = true, ...rest } = props; + // Resolved once and handed to `editor.mount()` via context. When omitted, + // `mount()` falls back to `element.parentElement` (i.e. `bn-container`). + // Changing this prop requires remounting the editor (use a `key`). + const portalTarget = useMemo( + () => resolvePortalTarget(portalElements?.default) ?? null, + [portalElements?.default], + ); + // Used so other components (suggestion menu) can set // aria related props to the contenteditable div const [contentEditableProps, setContentEditableProps] = @@ -151,6 +165,7 @@ function BlockNoteViewComponent< tableHandles: componentsContext ? tableHandles : false, emojiPicker: componentsContext ? emojiPicker : false, comments: componentsContext ? comments : false, + portalElements, }), [ comments, @@ -162,6 +177,7 @@ function BlockNoteViewComponent< sideMenu, slashMenu, tableHandles, + portalElements, ], ); @@ -206,10 +222,11 @@ function BlockNoteViewComponent< autoFocus, contentEditableProps, editable, + portalTarget, }, defaultUIProps, }; - }, [autoFocus, contentEditableProps, editable, defaultUIProps]); + }, [autoFocus, contentEditableProps, editable, defaultUIProps, portalTarget]); return ( @@ -289,6 +306,8 @@ 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. @@ -301,12 +320,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); + editor.mount(element, { portalTarget }); } else { editor.unmount(); } }, - [ctx.editorProps.editable, editor, portalManager], + [ctx.editorProps.editable, editor, portalManager, portalTarget], ); return ( @@ -328,6 +347,7 @@ 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 44adcefe12..2b5b7413c8 100644 --- a/packages/react/src/editor/BlockNoteViewContext.ts +++ b/packages/react/src/editor/BlockNoteViewContext.ts @@ -6,6 +6,13 @@ 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; }; diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts new file mode 100644 index 0000000000..c3a2a48dbc --- /dev/null +++ b/packages/react/src/editor/portalElements.ts @@ -0,0 +1,57 @@ +/** + * A portal mount target. + * + * - `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; + +/** + * 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. + */ +export type PortalElementsMap = { + default?: PortalTarget; + formattingToolbar?: PortalTarget; + linkToolbar?: PortalTarget; + slashMenu?: PortalTarget; + emojiPicker?: PortalTarget; + sideMenu?: PortalTarget; + filePanel?: PortalTarget; + tableHandles?: PortalTarget; + comments?: PortalTarget; +}; + +export type PortalElementKey = Exclude; + +export function resolvePortalTarget( + target: PortalTarget | 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; + } + const el = document.querySelector(target); + if (!el) { + // eslint-disable-next-line no-console + console.warn( + `[BlockNote] portalElements selector "${target}" did not match any element`, + ); + return undefined; + } + return el as HTMLElement; + } + return target; +} diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 5c70e29330..b1a5a9c8d9 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -874,6 +874,26 @@ "slug": "ui-components" }, "readme": "In this example, we filter and reorder the default Slash Menu items so that only the \"Basic blocks\" and \"Headings\" groups are shown, with \"Basic blocks\" appearing first.\n\n**Try it out:** Press the \"/\" key to open the Slash Menu and see the reordered groups!\n\n**Relevant Docs:**\n\n- [Item Grouping & Ordering](/docs/react/components/suggestion-menus)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Editor Setup](/docs/getting-started/editor-setup)" + }, + { + "projectSlug": "portal-elements", + "fullSlug": "ui-components/portal-elements", + "pathFromRoot": "examples/03-ui-components/20-portal-elements", + "config": { + "playground": true, + "docs": true, + "author": "nperez0111", + "tags": [ + "UI Components", + "Advanced" + ] + }, + "title": "Configuring Portal Targets per Element", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + }, + "readme": "By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container` element. The `portalElements` prop lets you change that — globally via `default`, or per element by key.\n\nIn this example we deliberately wrap the editor in a small parent with `overflow: hidden` so the global default of `bn-container` would clip the slash menu and the formatting toolbar. We escape only those two to `document.body`, while keeping `tableHandles` inside `.bn-container` so the table handles can never escape the editor's visual boundary.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)" } ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af14b567c3..cfca45f174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2154,6 +2154,49 @@ importers: specifier: ^8.0.8 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/03-ui-components/20-portal-elements: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/04-theming/01-theming-dom-attributes: dependencies: '@blocknote/ariakit':