diff --git a/.claude/worktrees/emoji-fixes b/.claude/worktrees/emoji-fixes new file mode 160000 index 0000000000..c9234debd6 --- /dev/null +++ b/.claude/worktrees/emoji-fixes @@ -0,0 +1 @@ +Subproject commit c9234debd656a48163bc309ca102c2a05599aa88 diff --git a/.claude/worktrees/fix-pr-2553-port b/.claude/worktrees/fix-pr-2553-port new file mode 160000 index 0000000000..da22c7eb7e --- /dev/null +++ b/.claude/worktrees/fix-pr-2553-port @@ -0,0 +1 @@ +Subproject commit da22c7eb7ed1266f9ac9353c0e2a116533d70580 diff --git a/.claude/worktrees/toggle-block-bugs-blo-1018 b/.claude/worktrees/toggle-block-bugs-blo-1018 new file mode 160000 index 0000000000..7b7762322f --- /dev/null +++ b/.claude/worktrees/toggle-block-bugs-blo-1018 @@ -0,0 +1 @@ +Subproject commit 7b7762322fea934ecfd879c0acf6723233edb5fa diff --git a/docs/content/docs/react/styling-theming/themes.mdx b/docs/content/docs/react/styling-theming/themes.mdx index a0b0631e3e..5965818ca0 100644 --- a/docs/content/docs/react/styling-theming/themes.mdx +++ b/docs/content/docs/react/styling-theming/themes.mdx @@ -67,7 +67,7 @@ Here are each of the theme CSS variables you can set, with values from the defau --bn-border-radius: 6px; ``` -Setting these variables on the `.bn-container[data-color-scheme]` selector will overwrite them for both default light & dark themes. To overwrite variables separately for light & dark themes, use the `.bn-container[data-color-scheme="light"]` and `.bn-container[data-color-scheme="dark"]` selectors. +Setting these variables on the `.bn-root[data-color-scheme]` selector will overwrite them for both default light & dark themes. To overwrite variables separately for light & dark themes, use the `.bn-root[data-color-scheme="light"]` and `.bn-root[data-color-scheme="dark"]` selectors. ## Programmatic Configuration diff --git a/examples/01-basic/12-multi-editor/src/App.tsx b/examples/01-basic/12-multi-editor/src/App.tsx index bf891fecbf..0aa2b11810 100644 --- a/examples/01-basic/12-multi-editor/src/App.tsx +++ b/examples/01-basic/12-multi-editor/src/App.tsx @@ -5,14 +5,19 @@ import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; // Component that creates & renders a BlockNote editor. -function Editor(props: { initialContent?: PartialBlock[] }) { +function Editor(props: { + initialContent?: PartialBlock[]; + theme: "dark" | "light"; +}) { // Creates a new editor instance. const editor = useCreateBlockNote({ initialContent: props.initialContent, }); // Renders the editor instance using a React component. - return ; + return ( + + ); } export default function App() { @@ -20,6 +25,7 @@ export default function App() { return (
+
diff --git a/examples/05-interoperability/10-static-html-render/src/App.tsx b/examples/05-interoperability/10-static-html-render/src/App.tsx index 9d04f5d9a5..0094c677cb 100644 --- a/examples/05-interoperability/10-static-html-render/src/App.tsx +++ b/examples/05-interoperability/10-static-html-render/src/App.tsx @@ -1,7 +1,6 @@ import "@blocknote/core/fonts/inter.css"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote, usePrefersColorScheme } from "@blocknote/react"; -import { useRef, useEffect } from "react"; export default function App() { // Creates a new editor instance. @@ -158,7 +157,7 @@ export default function App() { // Renders the exported static HTML from the editor. return (
diff --git a/packages/ariakit/src/comments/Comment.tsx b/packages/ariakit/src/comments/Comment.tsx index efc1746f20..55ebfe2dba 100644 --- a/packages/ariakit/src/comments/Comment.tsx +++ b/packages/ariakit/src/comments/Comment.tsx @@ -58,7 +58,7 @@ export const Comment = forwardRef< actions, children, edited, - emojiPickerOpen, // Unused + emojiPickerOpen, ...rest } = props; @@ -72,7 +72,8 @@ export const Comment = forwardRef< (showActions === true || showActions === undefined || (showActions === "hover" && hovered) || - focused); + focused || + emojiPickerOpen); return ( ( + undefined, +); export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -27,6 +31,8 @@ export const PopoverContent = forwardRef< assertEmpty(rest); + const portalRoot = useContext(PortalRootContext); + return ( {children} @@ -44,7 +51,7 @@ export const PopoverContent = forwardRef< export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { children, open, onOpenChange, position, ...rest } = props; + const { children, open, onOpenChange, position, portalRoot, ...rest } = props; assertEmpty(rest); @@ -54,7 +61,9 @@ export const Popover = ( setOpen={onOpenChange} placement={position} > - {children} + + {children} + ); }; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 2a6648f04b..8e21ddf7d3 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -714,6 +714,25 @@ export class BlockNoteEditor< return this.prosemirrorView?.dom as HTMLDivElement | undefined; } + /** + * The portal container element at `document.body` used by floating UI + * elements (menus, toolbars) to escape overflow:hidden ancestors. + * Set by BlockNoteView; undefined in headless mode. + */ + public portalElement: HTMLElement | undefined; + + /** + * Checks whether a DOM element belongs to this editor — either inside the + * editor's DOM tree or inside its portal container (used for floating UI + * elements like menus and toolbars). + */ + public isWithinEditor = (element: Element): boolean => { + return !!( + this.domElement?.parentElement?.contains(element) || + this.portalElement?.contains(element) + ); + }; + public isFocused() { if (this.headless) { return false; diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index e85a7165d3..11b0e66841 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -20,26 +20,6 @@ padding: 0; } -/* -bn-root should be applied to all top-level elements - -This includes the Prosemirror editor, but also
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; }