Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/content/docs/react/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can
{/* - [Image Toolbar](/docs/react/components/image-toolbar) */}

<CardTable path="/react/components" />

## Configuring Portal Targets

By default, all floating UI elements (toolbars, menus, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. If your layout needs them to escape — e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context — pass a `portalElements` prop to `BlockNoteView`:

```tsx
<BlockNoteView
editor={editor}
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).
tableHandles: ".bn-container",
}}
/>
```

Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu`, `emojiPicker`, `sideMenu`, `filePanel`, `tableHandles`, `comments`). Manually-mounted Controllers also accept a `portalElement` prop that takes precedence over the map. See the [Portal Targets example](/examples/ui-components/portal-elements).

Note: changing `portalElements.default` after mount requires remounting the editor (`editor.mount()` consults it once); per-element keys update reactively.
6 changes: 6 additions & 0 deletions examples/03-ui-components/20-portal-elements/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"playground": true,
"docs": true,
"author": "nperez0111",
"tags": ["UI Components", "Advanced"]
}
16 changes: 16 additions & 0 deletions examples/03-ui-components/20-portal-elements/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Configuring Portal Targets

By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container`. The `portalElements` prop on `BlockNoteView` lets you change that — globally via `default`, or per element by key.

This example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default — the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so floating UI escapes the wrapper and renders fully.

```tsx
<BlockNoteView
editor={editor}
portalElements={{ default: document.body }}
/>
```

**Relevant Docs:**

- [UI Components](/docs/react/components)
14 changes: 14 additions & 0 deletions examples/03-ui-components/20-portal-elements/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuring Portal Targets per Element</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/03-ui-components/20-portal-elements/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
30 changes: 30 additions & 0 deletions examples/03-ui-components/20-portal-elements/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
58 changes: 58 additions & 0 deletions examples/03-ui-components/20-portal-elements/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="view-wrapper">
<div className="view-label">{label}</div>
<div className="view-description">{description}</div>
<div className="view">
<BlockNoteView editor={editor} portalElements={portalElements} />
</div>
</div>
);
}

export default function App() {
return (
<div className="views">
<PortalDemoEditor
label="Default — clipped"
description="No portalElements prop. Floating UI mounts inside .bn-container — the slash menu is clipped by the editor's bounds."
/>
<PortalDemoEditor
label="portalElements={{ default: document.body }} — escapes"
description="Every floating UI element escapes the editor container and renders directly under <body>."
portalElements={{ default: document.body }}
/>
</div>
);
}
68 changes: 68 additions & 0 deletions examples/03-ui-components/20-portal-elements/src/styles.css
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions examples/03-ui-components/20-portal-elements/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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/"
}
]
}
32 changes: 32 additions & 0 deletions examples/03-ui-components/20-portal-elements/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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),
},
}));
24 changes: 18 additions & 6 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just reiterating coderabbit comment, but | null is probably not necessary when portalTarget is already optional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hate APIs that force you to have the value completely empty. If I want to null it, let me null it

) => {
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 });
};
Comment on lines +743 to 756
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Inspect resolvePortalTarget in portalElements.ts to see what it returns for null
fd -i "portalElements" --extension ts --extension tsx | xargs grep -n "resolvePortalTarget\|null\|document\.body" -A3

# 2. Check how BlockNoteView passes portalTarget into editor.mount
fd -i "BlockNoteView" --extension tsx --extension ts | xargs grep -n "mount\|portalTarget\|resolvePortalTarget" -A3

# 3. Verify which controllers listed in the PR still lack portalElement prop
rg -n "portalElement" packages/react/src/components --include="*.tsx" -l

Repository: TypeCellOS/BlockNote

Length of output: 11131


Handle or remove null from the portalTarget type signature to clarify semantics

The ?? operator treats both null and undefined as absent, so mount(el, { portalTarget: null }) silently falls through to element.parentElement rather than document.body. However, in the React layer (resolvePortalTarget), null is explicitly converted to document.body before being passed to mount. This creates a semantic gap: the type signature allows null, but calling mount directly with { portalTarget: null } does not behave as the React-layer convention suggests.

Option A — Handle null explicitly in mount so it aligns with the React layer's semantics (null = document.body):

Diff
-  const target =
-    options?.portalTarget ??
-    element.parentElement ??
-    (isInShadowRoot ? (root as ShadowRoot) : document.body);
+  // null is treated as an explicit "use document.body" opt-out;
+  // undefined means "derive from context" (parentElement → shadow root → body).
+  const target =
+    options?.portalTarget !== undefined
+      ? (options.portalTarget ?? document.body)
+      : (element.parentElement ??
+          (isInShadowRoot ? (root as ShadowRoot) : document.body));

Option B — Remove null from the type if it has no distinct meaning at the mount level:

Diff
-  public mount = (
-    element: HTMLElement,
-    options?: { portalTarget?: HTMLElement | null },
-  ) => {
+  public mount = (
+    element: HTMLElement,
+    options?: { portalTarget?: HTMLElement },
+  ) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/editor/BlockNoteEditor.ts` around lines 743 - 756,
BlockNoteEditor.mount currently treats null the same as undefined due to the
nullish coalescing, causing mount(element, { portalTarget: null }) to pick
element.parentElement instead of document.body; update the mount implementation
(in BlockNoteEditor.mount) to explicitly handle a null portalTarget (e.g., if
options?.portalTarget === null use document.body) so behavior matches the React
resolvePortalTarget convention, then append this.portalElement to the resolved
target and proceed to call this._tiptapEditor.mount({ mount: element });
alternatively, if you prefer the other approach remove null from the options
type signature so portalTarget cannot be null.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export default function FloatingComposerController<
>(props: {
floatingComposer?: FC<ComponentProps<typeof FloatingComposer>>;
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<B, I, S>();

Expand Down Expand Up @@ -82,7 +88,11 @@ export default function FloatingComposerController<
const Component = props.floatingComposer || FloatingComposer;

return (
<PositionPopover position={position} {...floatingUIOptions}>
<PositionPopover
position={position}
portalElement={props.portalElement}
{...floatingUIOptions}
>
<Component />
</PositionPopover>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import { useThreads } from "./useThreads.js";
export default function FloatingThreadController(props: {
floatingThread?: FC<ComponentProps<typeof Thread>>;
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<any, any, any>();

Expand Down Expand Up @@ -78,7 +84,11 @@ export default function FloatingThreadController(props: {
const Component = props.floatingThread || Thread;

return (
<PositionPopover position={selectedThread?.position} {...floatingUIOptions}>
<PositionPopover
position={selectedThread?.position}
portalElement={props.portalElement}
{...floatingUIOptions}
>
{thread && <Component thread={thread} selected={true} />}
</PositionPopover>
);
Expand Down
Loading
Loading