element such as
-Tippy popups that are appended to document.body directly
-*/
-.bn-root {
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
-}
-
-.bn-root *,
-.bn-root *::before,
-.bn-root *::after {
- -webkit-box-sizing: inherit;
- -moz-box-sizing: inherit;
- box-sizing: inherit;
-}
-
/* reset styles, they will be set on blockContent */
.bn-default-styles p,
.bn-default-styles h1,
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 45f0acf8e5..e61069c2bb 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -71,6 +71,7 @@ export function getDefaultTiptapExtensions(
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
types: ["blockContainer", "columnList", "column"],
setIdAttribute: options.setIdAttribute,
+ isWithinEditor: editor.isWithinEditor,
}),
HardBreak,
Text,
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index e65d4b1d99..4186939d2c 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -612,9 +612,6 @@ export class SideMenuView<
this.mousePos.y > editorOuterBoundingBox.top &&
this.mousePos.y < editorOuterBoundingBox.bottom;
- // TODO: remove parentElement, but then we need to remove padding from boundingbox or find a different solution
- const editorWrapper = this.pmView.dom!.parentElement!;
-
// Doesn't update if the mouse hovers an element that's over the editor but
// isn't a part of it or the side menu.
if (
@@ -623,11 +620,8 @@ export class SideMenuView<
// An element is hovered
event &&
event.target &&
- // Element is outside the editor
- !(
- editorWrapper === event.target ||
- editorWrapper.contains(event.target as HTMLElement)
- )
+ // Element is outside this editor and its portaled UI
+ !this.editor.isWithinEditor(event.target as HTMLElement)
) {
if (this.state?.show) {
this.state.show = false;
diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
index 23f6591256..2f19981f89 100644
--- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
+++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
@@ -51,6 +51,9 @@ const UniqueID = Extension.create({
attributeName: "id",
types: [],
setIdAttribute: false,
+ isWithinEditor: undefined as
+ | ((element: Element) => boolean)
+ | undefined,
generateID: () => {
// Use mock ID if tests are running.
if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) {
@@ -128,6 +131,7 @@ const UniqueID = Extension.create({
// view.dispatch(tr);
// },
addProseMirrorPlugins() {
+ const { isWithinEditor } = this.options;
let dragSourceElement: any = null;
let transformPasted = false;
return [
@@ -228,14 +232,11 @@ const UniqueID = Extension.create({
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: any) => {
- let _a;
- dragSourceElement = (
- (_a = view.dom.parentElement) === null || _a === void 0
- ? void 0
- : _a.contains(event.target)
- )
- ? view.dom.parentElement
- : null;
+ const editorParent = view.dom.parentElement;
+ const isFromEditor =
+ editorParent?.contains(event.target) ||
+ isWithinEditor?.(event.target);
+ dragSourceElement = isFromEditor ? editorParent : null;
};
window.addEventListener("dragstart", handleDragstart);
return {
diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx
index 29564585ce..844daab5df 100644
--- a/packages/mantine/src/popover/Popover.tsx
+++ b/packages/mantine/src/popover/Popover.tsx
@@ -11,18 +11,19 @@ import { forwardRef } from "react";
export const Popover = (
props: ComponentProps["Generic"]["Popover"]["Root"],
) => {
- const { open, onOpenChange, position, children, ...rest } = props;
+ const { open, onOpenChange, position, portalRoot, children, ...rest } = props;
assertEmpty(rest);
return (
{children}
diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx
index 9b685e71e4..959e91e923 100644
--- a/packages/react/src/components/Comments/EmojiPicker.tsx
+++ b/packages/react/src/components/Comments/EmojiPicker.tsx
@@ -3,8 +3,6 @@ import { ReactNode, useState } from "react";
import { useComponentsContext } from "../../editor/ComponentsContext.js";
import { useBlockNoteContext } from "../../editor/BlockNoteContext.js";
import Picker from "./EmojiMartPicker.js";
-import { createPortal } from "react-dom";
-import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
export const EmojiPicker = (props: {
onEmojiSelect: (emoji: { native: string }) => void;
@@ -14,11 +12,10 @@ export const EmojiPicker = (props: {
const [open, setOpen] = useState(false);
const Components = useComponentsContext()!;
- const editor = useBlockNoteEditor();
const blockNoteContext = useBlockNoteContext();
return (
-
+
{
@@ -39,28 +36,24 @@ export const EmojiPicker = (props: {
{props.children}
- {editor.domElement?.parentElement &&
- createPortal(
-
- {
- setOpen(false);
- props.onOpenChange?.(false);
- }}
- onEmojiSelect={(emoji: { native: string }) => {
- props.onEmojiSelect(emoji);
- setOpen(false);
- props.onOpenChange?.(false);
- }}
- theme={blockNoteContext?.colorSchemePreference}
- />
- ,
- editor.domElement.parentElement,
- )}
+
+ {
+ setOpen(false);
+ props.onOpenChange?.(false);
+ }}
+ onEmojiSelect={(emoji: { native: string }) => {
+ props.onEmojiSelect(emoji);
+ setOpen(false);
+ props.onOpenChange?.(false);
+ }}
+ theme={blockNoteContext?.colorSchemePreference}
+ />
+
);
};
diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx
index c5e40c88d4..459c765e14 100644
--- a/packages/react/src/components/Popovers/GenericPopover.tsx
+++ b/packages/react/src/components/Popovers/GenericPopover.tsx
@@ -1,6 +1,7 @@
import {
autoUpdate,
FloatingFocusManager,
+ FloatingPortal,
useDismiss,
useFloating,
useHover,
@@ -11,6 +12,7 @@ import {
} from "@floating-ui/react";
import { HTMLAttributes, ReactNode, useEffect, useRef } from "react";
+import { useBlockNoteContext } from "../../editor/BlockNoteContext.js";
import { FloatingUIOptions } from "./FloatingUIOptions.js";
export type GenericPopoverReference =
@@ -83,6 +85,9 @@ export const GenericPopover = (
children: ReactNode;
},
) => {
+ const blockNoteContext = useBlockNoteContext();
+ const portalRoot = blockNoteContext?.portalRoot ?? undefined;
+
const { refs, floatingStyles, context } = useFloating({
whileElementsMounted: autoUpdate,
...props.useFloatingOptions,
@@ -152,7 +157,7 @@ export const GenericPopover = (
style: {
display: "flex",
...props.elementProps?.style,
- zIndex: `calc(var(--bn-ui-base-z-index) + ${props.elementProps?.style?.zIndex || 0})`,
+ zIndex: `calc(var(--bn-ui-base-z-index, 0) + ${props.elementProps?.style?.zIndex || 0})`,
...floatingStyles,
...styles,
},
@@ -169,27 +174,33 @@ export const GenericPopover = (
// should be open. So without this fix, the popover just won't transition
// out and will instead appear to hide instantly.
return (
-
+
+
+
);
}
if (!props.focusManagerProps?.disabled) {
return (
-
-
- {props.children}
-
-
+
+
+
+ {props.children}
+
+
+
);
}
return (
-
- {props.children}
-
+
+
+ {props.children}
+
+
);
};
diff --git a/packages/react/src/editor/BlockNoteContext.ts b/packages/react/src/editor/BlockNoteContext.ts
index 5ee613e5dc..5e3384d3ea 100644
--- a/packages/react/src/editor/BlockNoteContext.ts
+++ b/packages/react/src/editor/BlockNoteContext.ts
@@ -18,6 +18,12 @@ export type BlockNoteContextValue<
setContentEditableProps?: ReturnType>>[1]; // copy type of setXXX from useState
editor?: BlockNoteEditor;
colorSchemePreference?: "light" | "dark";
+ /**
+ * A portal container element rendered at `document.body` level, used by
+ * floating UI elements (menus, toolbars) to escape `overflow: hidden`
+ * ancestors. Has the same theming classes as the editor container.
+ */
+ portalRoot?: HTMLDivElement | null;
};
export const BlockNoteContext = createContext<
diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx
index d810fafcbe..2962612da1 100644
--- a/packages/react/src/editor/BlockNoteView.tsx
+++ b/packages/react/src/editor/BlockNoteView.tsx
@@ -10,9 +10,11 @@ import React, {
ReactNode,
Ref,
useCallback,
+ useEffect,
useMemo,
useState,
} from "react";
+import { createPortal } from "react-dom";
import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js";
import { useEditorChange } from "../hooks/useEditorChange.js";
import { useEditorSelectionChange } from "../hooks/useEditorSelectionChange.js";
@@ -147,6 +149,22 @@ function BlockNoteViewComponent<
[editor],
);
+ // Portal container at document.body level for floating UI elements (menus,
+ // toolbars) to render into, escaping any overflow:hidden ancestors. Gets the
+ // same theming classes as the editor container (bn-root + color scheme), but
+ // not bn-container (which is for layout targeting only).
+ const [portalRoot, setPortalRoot] = useState(null);
+
+ // Register the portal element on the editor so core extensions (SideMenu,
+ // UniqueID) can identify portaled elements as belonging to this editor.
+ // (through editor.isWithinEditor)
+ useEffect(() => {
+ editor.portalElement = portalRoot ?? undefined;
+ return () => {
+ editor.portalElement = undefined;
+ };
+ }, [portalRoot, editor]);
+
// The BlockNoteContext makes sure the editor and some helper methods
// are always available to nesteed compoenents
const blockNoteContext: BlockNoteContextValue = useMemo(() => {
@@ -155,8 +173,9 @@ function BlockNoteViewComponent<
editor,
setContentEditableProps,
colorSchemePreference: editorColorScheme,
+ portalRoot,
};
- }, [existingContext, editor, editorColorScheme]);
+ }, [existingContext, editor, editorColorScheme, portalRoot]);
// We set defaultUIProps and editorProps on a different context, the BlockNoteViewContext.
// This BlockNoteViewContext is used to render the editor and the default UI.
@@ -205,6 +224,14 @@ function BlockNoteViewComponent<
>
{children}
+ {createPortal(
+ ,
+ document.body,
+ )}
);
@@ -226,7 +253,12 @@ const BlockNoteViewContainer = React.forwardRef<
>
>(({ className, renderEditor, editorColorScheme, children, ...rest }, ref) => (
(
+ undefined,
+);
+
export const Popover = (
props: ComponentProps["Generic"]["Popover"]["Root"],
) => {
@@ -13,6 +18,7 @@ export const Popover = (
open,
onOpenChange,
position, // unused
+ portalRoot,
...rest
} = props;
@@ -22,7 +28,9 @@ export const Popover = (
return (
- {children}
+
+ {children}
+
);
};
@@ -52,13 +60,14 @@ export const PopoverContent = forwardRef<
assertEmpty(rest);
const ShadCNComponents = useShadCNComponentsContext()!;
+ const portalRoot = useContext(PortalRootContext);
- return (
+ const content = (
);
+
+ if (portalRoot) {
+ return createPortal(content, portalRoot);
+ }
+
+ return content;
});
diff --git a/playground/src/style.css b/playground/src/style.css
index f69602d9f3..7ce5324f7c 100644
--- a/playground/src/style.css
+++ b/playground/src/style.css
@@ -37,6 +37,9 @@ body {
padding-top: 8px;
margin: 0 auto;
max-width: 731px;
+}
+
+.bn-root {
--bn-ui-base-z-index: 100;
}