-
-
Notifications
You must be signed in to change notification settings - Fork 729
feat(react): configurable portal targets for floating UI #2729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "playground": true, | ||
| "docs": true, | ||
| "author": "nperez0111", | ||
| "tags": ["UI Components", "Advanced"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| <BlockNoteView | ||
| editor={editor} | ||
| portalElements={{ | ||
| slashMenu: document.body, | ||
| formattingToolbar: document.body, | ||
| tableHandles: ".bn-container", | ||
| }} | ||
| /> | ||
| ``` | ||
|
|
||
| **Relevant Docs:** | ||
|
|
||
| - [UI Components](/docs/react/components) |
| 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> | ||
| 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"; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -i '^App\.(tsx|jsx)$' examples/03-ui-components/20-portal-elements/src
rg -n 'import App from' examples/03-ui-components/20-portal-elements/main.tsxRepository: TypeCellOS/BlockNote Length of output: 156 Fix App import extension mismatch.
💡 Proposed fix-import App from "./src/App.jsx";
+import App from "./src/App";📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| const root = createRoot(document.getElementById("root")!); | ||||||
| root.render( | ||||||
| <React.StrictMode> | ||||||
| <App /> | ||||||
| </React.StrictMode> | ||||||
| ); | ||||||
| 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" | ||
| } | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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; | ||
| } |
| 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/" | ||
| } | ||
| ] | ||
| } |
| 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/" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alias base path appears one level too shallow. From this file’s location, 💡 Proposed fix- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
@@
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/"
+ "../../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/"
+ "../../../packages/react/src/"
),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } as any), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }); | ||
| }; | ||
|
Comment on lines
+743
to
756
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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" -lRepository: TypeCellOS/BlockNote Length of output: 11131 Handle or remove The Option A — Handle 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 Diff- public mount = (
- element: HTMLElement,
- options?: { portalTarget?: HTMLElement | null },
- ) => {
+ public mount = (
+ element: HTMLElement,
+ options?: { portalTarget?: HTMLElement },
+ ) => {🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add DOCTYPE declaration.
The HTML file is missing the DOCTYPE declaration, which can cause browsers to render the page in quirks mode rather than standards mode. This affects layout consistency and validation.
🔧 Proposed fix
+<!DOCTYPE html> <html lang="en">📝 Committable suggestion
🧰 Tools
🪛 HTMLHint (1.9.2)
[error] 1-1: Doctype must be declared before any non-comment content.
(doctype-first)
🤖 Prompt for AI Agents