Skip to content
Open
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
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"]
}
20 changes: 20 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,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)
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">
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 | 🔴 Critical | ⚡ Quick win

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<html lang="en">
<!DOCTYPE html>
<html lang="en">
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🤖 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 `@examples/03-ui-components/20-portal-elements/index.html` at line 1, The
document lacks a DOCTYPE declaration which can trigger quirks mode; add the
HTML5 DOCTYPE declaration <!DOCTYPE html> as the very first line above the
existing <html lang="en"> element in the index.html file so browsers use
standards mode and ensure consistent rendering.

<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";
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 | 🔴 Critical | ⚡ Quick win

🧩 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.tsx

Repository: TypeCellOS/BlockNote

Length of output: 156


Fix App import extension mismatch.

main.tsx line 4 imports ./src/App.jsx, but the actual file is App.tsx. This mismatch can cause module resolution failures depending on build configuration.

💡 Proposed fix
-import App from "./src/App.jsx";
+import App from "./src/App";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import App from "./src/App.jsx";
import App from "./src/App";
🤖 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 `@examples/03-ui-components/20-portal-elements/main.tsx` at line 4, The import
in main.tsx references the wrong file extension: update the import statement
that currently points to "./src/App.jsx" so it resolves to the actual component
file "./src/App.tsx" (i.e., change the module specifier used in the import of
App in main.tsx to match the App.tsx filename) to avoid module resolution
failures.


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/"
),
Comment on lines +17 to +29
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 | 🟠 Major | ⚡ Quick win

Alias base path appears one level too shallow.

From this file’s location, ../../packages/... likely resolves under examples/, so the source aliases won’t activate as intended in dev mode.

💡 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
!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/"
),
!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/"
),
🤖 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 `@examples/03-ui-components/20-portal-elements/vite.config.ts` around lines 17
- 29, Aliases for "@blocknote/core" and "@blocknote/react" are using
"../../packages/..." which is one level too shallow from this file; update the
path.resolve calls (the values assigned to the "@blocknote/core" and
"@blocknote/react" aliases inside the ternary branch that checks fs.existsSync)
to use "../../../packages/core/src/" and "../../../packages/react/src/"
respectively so the dev-time source aliases point to the correct package
directories.

} 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 },
) => {
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
12 changes: 11 additions & 1 deletion packages/react/src/components/FilePanel/FilePanelController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { FilePanelProps } from "./FilePanelProps.js";
export const FilePanelController = (props: {
filePanel?: FC<FilePanelProps>;
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 @@ -54,7 +60,11 @@ export const FilePanelController = (props: {
const Component = props.filePanel || FilePanel;

return (
<BlockPopover blockId={blockId} {...floatingUIOptions}>
<BlockPopover
blockId={blockId}
portalElement={props.portalElement}
{...floatingUIOptions}
>
{blockId && <Component blockId={blockId} />}
</BlockPopover>
);
Expand Down
Loading
Loading