Skip to content

Commit 2e2d225

Browse files
authored
feat(react): configurable portal targets for floating UI (#2729)
* 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. * docs(react): document portalElements config Adds a short "Configuring Portal Targets" section to the UI Components overview page and updates the example README to match the side-by-side demo.
1 parent 932e3ab commit 2e2d225

29 files changed

Lines changed: 624 additions & 29 deletions

docs/content/docs/react/components/index.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,23 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can
1414
{/* - [Image Toolbar](/docs/react/components/image-toolbar) */}
1515

1616
<CardTable path="/react/components" />
17+
18+
## Configuring Portal Targets
19+
20+
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`:
21+
22+
```tsx
23+
<BlockNoteView
24+
editor={editor}
25+
portalElements={{
26+
// Global default for any element not listed below.
27+
default: document.body,
28+
// Per-element overrides. Values can be HTMLElement, a CSS selector, or null (= document.body).
29+
tableHandles: ".bn-container",
30+
}}
31+
/>
32+
```
33+
34+
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).
35+
36+
Note: changing `portalElements.default` after mount requires remounting the editor (`editor.mount()` consults it once); per-element keys update reactively.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"playground": true,
3+
"docs": true,
4+
"author": "nperez0111",
5+
"tags": ["UI Components", "Advanced"]
6+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Configuring Portal Targets
2+
3+
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.
4+
5+
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.
6+
7+
```tsx
8+
<BlockNoteView
9+
editor={editor}
10+
portalElements={{ default: document.body }}
11+
/>
12+
```
13+
14+
**Relevant Docs:**
15+
16+
- [UI Components](/docs/react/components)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html lang="en">
2+
<head>
3+
<meta charset="UTF-8" />
4+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
5+
<title>Configuring Portal Targets per Element</title>
6+
<script>
7+
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
8+
</script>
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import React from "react";
3+
import { createRoot } from "react-dom/client";
4+
import App from "./src/App.jsx";
5+
6+
const root = createRoot(document.getElementById("root")!);
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@blocknote/example-ui-components-portal-elements",
3+
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
4+
"type": "module",
5+
"private": true,
6+
"version": "0.12.4",
7+
"scripts": {
8+
"start": "vite",
9+
"dev": "vite",
10+
"build:prod": "tsc && vite build",
11+
"preview": "vite preview"
12+
},
13+
"dependencies": {
14+
"@blocknote/ariakit": "latest",
15+
"@blocknote/core": "latest",
16+
"@blocknote/mantine": "latest",
17+
"@blocknote/react": "latest",
18+
"@blocknote/shadcn": "latest",
19+
"@mantine/core": "^9.0.2",
20+
"@mantine/hooks": "^9.0.2",
21+
"react": "^19.2.3",
22+
"react-dom": "^19.2.3"
23+
},
24+
"devDependencies": {
25+
"@types/react": "^19.2.3",
26+
"@types/react-dom": "^19.2.3",
27+
"@vitejs/plugin-react": "^6.0.1",
28+
"vite": "^8.0.8"
29+
}
30+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import "@blocknote/core/fonts/inter.css";
2+
import { BlockNoteView } from "@blocknote/mantine";
3+
import "@blocknote/mantine/style.css";
4+
import { useCreateBlockNote, type PortalElementsMap } from "@blocknote/react";
5+
6+
import "./styles.css";
7+
8+
const initialContent = [
9+
{
10+
type: "paragraph" as const,
11+
content: "Click in this editor and press / to open the slash menu.",
12+
},
13+
{
14+
type: "paragraph" as const,
15+
content:
16+
"Notice whether the menu fits inside the box or escapes it.",
17+
},
18+
{
19+
type: "paragraph" as const,
20+
},
21+
];
22+
23+
function PortalDemoEditor({
24+
label,
25+
description,
26+
portalElements,
27+
}: {
28+
label: string;
29+
description: string;
30+
portalElements?: PortalElementsMap;
31+
}) {
32+
const editor = useCreateBlockNote({ initialContent });
33+
return (
34+
<div className="view-wrapper">
35+
<div className="view-label">{label}</div>
36+
<div className="view-description">{description}</div>
37+
<div className="view">
38+
<BlockNoteView editor={editor} portalElements={portalElements} />
39+
</div>
40+
</div>
41+
);
42+
}
43+
44+
export default function App() {
45+
return (
46+
<div className="views">
47+
<PortalDemoEditor
48+
label="Default — clipped"
49+
description="No portalElements prop. Floating UI mounts inside .bn-container — the slash menu is clipped by the editor's bounds."
50+
/>
51+
<PortalDemoEditor
52+
label="portalElements={{ default: document.body }} — escapes"
53+
description="Every floating UI element escapes the editor container and renders directly under <body>."
54+
portalElements={{ default: document.body }}
55+
/>
56+
</div>
57+
);
58+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
.views {
2+
container-name: views;
3+
container-type: inline-size;
4+
display: flex;
5+
flex-direction: row;
6+
flex-wrap: wrap;
7+
gap: 8px;
8+
padding: 8px;
9+
}
10+
11+
/*
12+
* Each view is intentionally shorter than the slash menu so the clipping
13+
* vs escaping behaviour is visible at a glance.
14+
*/
15+
.view-wrapper {
16+
display: flex;
17+
flex-direction: column;
18+
height: 260px;
19+
width: 100%;
20+
}
21+
22+
@container views (width > 1024px) {
23+
.view-wrapper {
24+
width: calc(50% - 4px);
25+
}
26+
}
27+
28+
.view-label {
29+
color: #0090ff;
30+
display: flex;
31+
font-size: 12px;
32+
font-weight: bold;
33+
justify-content: space-between;
34+
margin-inline: 16px;
35+
}
36+
37+
.view-description {
38+
color: #0090ff;
39+
font-size: 12px;
40+
margin: 2px 16px 0;
41+
}
42+
43+
/*
44+
* `position: relative` is what actually makes `overflow: hidden` clip the
45+
* absolutely-positioned floating UI. Without it the popover's containing
46+
* block is the viewport and the clip is bypassed.
47+
*/
48+
.view {
49+
border: solid #0090ff 1px;
50+
border-radius: 16px;
51+
flex: 1;
52+
height: 0;
53+
padding: 8px;
54+
position: relative;
55+
overflow: hidden;
56+
}
57+
58+
.view .bn-container {
59+
height: 100%;
60+
margin: 0;
61+
max-width: none;
62+
padding: 0;
63+
}
64+
65+
.view .bn-editor {
66+
height: 100%;
67+
overflow: auto;
68+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
3+
"compilerOptions": {
4+
"target": "ESNext",
5+
"useDefineForClassFields": true,
6+
"lib": [
7+
"DOM",
8+
"DOM.Iterable",
9+
"ESNext"
10+
],
11+
"allowJs": false,
12+
"skipLibCheck": true,
13+
"esModuleInterop": false,
14+
"allowSyntheticDefaultImports": true,
15+
"strict": true,
16+
"forceConsistentCasingInFileNames": true,
17+
"module": "ESNext",
18+
"moduleResolution": "bundler",
19+
"resolveJsonModule": true,
20+
"isolatedModules": true,
21+
"noEmit": true,
22+
"jsx": "react-jsx",
23+
"composite": true
24+
},
25+
"include": [
26+
"."
27+
],
28+
"__ADD_FOR_LOCAL_DEV_references": [
29+
{
30+
"path": "../../../packages/core/"
31+
},
32+
{
33+
"path": "../../../packages/react/"
34+
}
35+
]
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import react from "@vitejs/plugin-react";
3+
import * as fs from "fs";
4+
import * as path from "path";
5+
import { defineConfig } from "vite";
6+
// import eslintPlugin from "vite-plugin-eslint";
7+
// https://vitejs.dev/config/
8+
export default defineConfig((conf) => ({
9+
plugins: [react()],
10+
optimizeDeps: {},
11+
build: {
12+
sourcemap: true,
13+
},
14+
resolve: {
15+
alias:
16+
conf.command === "build" ||
17+
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
18+
? {}
19+
: ({
20+
// Comment out the lines below to load a built version of blocknote
21+
// or, keep as is to load live from sources with live reload working
22+
"@blocknote/core": path.resolve(
23+
__dirname,
24+
"../../packages/core/src/"
25+
),
26+
"@blocknote/react": path.resolve(
27+
__dirname,
28+
"../../packages/react/src/"
29+
),
30+
} as any),
31+
},
32+
}));

0 commit comments

Comments
 (0)