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
4 changes: 2 additions & 2 deletions docs/content/docs/react/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ By default, all floating UI elements (toolbars, menus, table handles, etc.) port
/>
```

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).
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. All keys, including `default`, update reactively. See the [Portal Targets example](/examples/ui-components/portal-elements).

Note: changing `portalElements.default` after mount requires remounting the editor (`editor.mount()` consults it once); per-element keys update reactively.
When a target sits outside the editor's DOM (like `document.body`), BlockNote automatically renders a themed wrapper element inside it, so floating UI keeps the editor's styling and theming wherever it's portalled.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useMobileToolbarPortal,
useEditorPortalElement,
useSelectedBlocks,
} from "@blocknote/react";
import { useCallback, useEffect, useState } from "react";
Expand All @@ -24,7 +24,7 @@ export const FileReplaceButton = () => {
const dict = useDictionary();
const Components = useComponentsContext()!;
// Portal necessary to properly show popover on mobile.
const mobileToolbarPortal = useMobileToolbarPortal();
const editorPortalElement = useEditorPortalElement();

const editor = useBlockNoteEditor<
BlockSchema,
Expand Down Expand Up @@ -68,7 +68,7 @@ export const FileReplaceButton = () => {
open={isOpen}
onOpenChange={setIsOpen}
position={"bottom"}
portalRoot={mobileToolbarPortal ?? undefined}
portalRoot={editorPortalElement}
>
<Components.Generic.Popover.Trigger>
<Components.FormattingToolbar.Button
Expand Down
93 changes: 58 additions & 35 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,34 +719,32 @@ 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.
* @param options.portalTarget An element to register as a portal root — a
* convenience for {@link registerPortalRoot}, for non-React setups that
* render the editor's floating UI outside the editor's DOM tree, so
* {@link isWithinEditor} recognizes it. An ordinary registration like any
* other: release it with {@link unregisterPortalRoot} if ever needed. Not
* needed for UI rendered next to the contenteditable (the mount element's
* parent already counts as within the editor). Prefer a dedicated
* container over e.g. `document.body`, which would make the whole page
* count.
*
* @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
*/
public mount = (
element: HTMLElement,
options?: { portalTarget?: HTMLElement | null },
options?: { portalTarget?: HTMLElement },
) => {
const root = element.getRootNode();
const isInShadowRoot =
typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot;
const target =
options?.portalTarget ??
element.parentElement ??
(isInShadowRoot ? (root as ShadowRoot) : document.body);
target.appendChild(this.portalElement);
if (options?.portalTarget) {
this.registerPortalRoot(options.portalTarget);
}
this._tiptapEditor.mount({ mount: element });
};

/**
* Unmount the editor from the DOM element it is bound to
*/
public unmount = () => {
this.portalElement?.remove();
this._tiptapEditor.unmount();
};

Expand Down Expand Up @@ -774,35 +772,60 @@ export class BlockNoteEditor<
return this.prosemirrorView?.dom as HTMLDivElement | undefined;
}

private _portalElement: HTMLElement | undefined;
// Portal roots registered by the view layer, with reference counts so
// multiple UI elements can share a root (e.g. several popovers portalling
// into the same custom target).
private _portalRoots = new Map<HTMLElement, number>();

/**
* 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.
* Registers an element as a portal root for this editor's floating UI, so
* {@link isWithinEditor} treats its contents as part of the editor. The view
* layer calls this for each portal target it designates (see
* `EditorPortalProvider` in `@blocknote/react`) — without it, UI portalled outside
* the editor's DOM tree would be considered outside the editor.
* Registrations are reference-counted; release with
* {@link unregisterPortalRoot}.
*/
public get portalElement() {
if (typeof document === "undefined") {
throw new Error(
"Portal element accessed, but not available in headless mode",
);
public registerPortalRoot = (element: HTMLElement) => {
this._portalRoots.set(element, (this._portalRoots.get(element) ?? 0) + 1);
};

/**
* Releases a registration made with {@link registerPortalRoot}. The element
* stops counting as part of the editor once every registration for it has
* been released.
*/
public unregisterPortalRoot = (element: HTMLElement) => {
const count = this._portalRoots.get(element);
if (count === undefined) {
return;
}
if (!this._portalElement) {
this._portalElement = document.createElement("div");

if (count <= 1) {
this._portalRoots.delete(element);
} else {
this._portalRoots.set(element, count - 1);
}
return this._portalElement;
}
};

/**
* 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).
* Checks whether a DOM element belongs to this editor — inside the editor's
* DOM tree, or inside any portal root registered via
* {@link registerPortalRoot} (used for floating UI elements like menus and
* toolbars, which may portal outside the editor's DOM tree).
*/
public isWithinEditor = (element: Element): boolean => {
return !!(
this.domElement?.parentElement?.contains(element) ||
this.portalElement?.contains(element)
);
if (this.domElement?.parentElement?.contains(element)) {
return true;
}

for (const root of this._portalRoots.keys()) {
if (root.contains(element)) {
return true;
}
}

return false;
};

public isFocused() {
Expand Down
116 changes: 14 additions & 102 deletions packages/mantine/src/BlockNoteTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,120 +34,32 @@ export type Theme = Partial<{

type NestedObject = { [key: string]: number | string | NestedObject };

const cssVariablesHelper = (
theme: Theme,
editorDOM: HTMLElement,
unset = false,
) => {
const result: string[] = [];
/**
* Converts a {@link Theme} into a map of `--bn-*` CSS custom properties, for
* passing declaratively via a `style` prop (e.g. to `BlockNoteViewRaw`, which
* also forwards it to portal roots).
*/
export function themeToCSSVariables(theme: Theme): Record<string, string> {
const variables: Record<string, string> = {};

function traverse(current: NestedObject, currentKey = "--bn") {
for (const key in current) {
const kebabCaseKey = key
.replace(/([a-z])([A-Z])/g, "$1-$2")
.toLowerCase();
const fullKey = `${currentKey}-${kebabCaseKey}`;
const value = current[key];

if (typeof current[key] !== "object") {
// Convert numbers to px
if (typeof current[key] === "number") {
current[key] = `${current[key]}px`;
}

if (unset) {
editorDOM.style.removeProperty(fullKey);
} else {
editorDOM.style.setProperty(fullKey, current[key].toString());
}
if (typeof value === "object") {
traverse(value, fullKey);
} else {
traverse(current[key] as NestedObject, fullKey);
// Convert numbers to px
variables[fullKey] = typeof value === "number" ? `${value}px` : value;
}
}
}

traverse(theme);

return result;
};

export const applyBlockNoteCSSVariablesFromTheme = (
theme: Theme,
editorDOM: HTMLElement,
) => cssVariablesHelper(theme, editorDOM);

// We don't need a theme to remove the CSS variables, but having access to a
// theme object allows us to use the same logic to set/unset them, so this
// placeholder theme is used.
const placeholderTheme: Theme = {
colors: {
editor: {
text: undefined as any,
background: undefined as any,
},
menu: {
text: undefined as any,
background: undefined as any,
},
tooltip: {
text: undefined as any,
background: undefined as any,
},
hovered: {
text: undefined as any,
background: undefined as any,
},
selected: {
text: undefined as any,
background: undefined as any,
},
disabled: {
text: undefined as any,
background: undefined as any,
},
shadow: undefined as any,
border: undefined as any,
sideMenu: undefined as any,
highlights: {
gray: {
text: undefined as any,
background: undefined as any,
},
brown: {
text: undefined as any,
background: undefined as any,
},
red: {
text: undefined as any,
background: undefined as any,
},
orange: {
text: undefined as any,
background: undefined as any,
},
yellow: {
text: undefined as any,
background: undefined as any,
},
green: {
text: undefined as any,
background: undefined as any,
},
blue: {
text: undefined as any,
background: undefined as any,
},
purple: {
text: undefined as any,
background: undefined as any,
},
pink: {
text: undefined as any,
background: undefined as any,
},
},
},
borderRadius: undefined as any,
fontFamily: undefined as any,
};
export const removeBlockNoteCSSVariables = (editorDOM: HTMLElement) =>
cssVariablesHelper(placeholderTheme, editorDOM, true);
return variables;
}
60 changes: 22 additions & 38 deletions packages/mantine/src/BlockNoteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,8 @@ import {
usePrefersColorScheme,
} from "@blocknote/react";
import { MantineContext, MantineProvider } from "@mantine/core";
import React, { useCallback, useContext, useEffect } from "react";
import {
applyBlockNoteCSSVariablesFromTheme,
removeBlockNoteCSSVariables,
Theme,
} from "./BlockNoteTheme.js";
import React, { useContext, useMemo } from "react";
import { Theme, themeToCSSVariables } from "./BlockNoteTheme.js";
import { components } from "./components.js";

export const BlockNoteView = <
Expand Down Expand Up @@ -52,49 +48,37 @@ export const BlockNoteView = <
? defaultColorScheme
: "light";

const applyThemeVariables = useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
}

removeBlockNoteCSSVariables(node);

if (typeof theme === "object") {
if ("light" in theme && "dark" in theme) {
applyBlockNoteCSSVariablesFromTheme(
theme[defaultColorScheme === "dark" ? "dark" : "light"],
node,
);
return;
}

applyBlockNoteCSSVariablesFromTheme(theme, node);
return;
}
},
[defaultColorScheme, theme],
);
// Mantine's theming for BlockNote's themed root elements (the editor
// container and any portal roots): the color-scheme attribute the
// stylesheet keys off, plus CSS variables for custom object themes.
// `BlockNoteViewRaw` applies these to every root, so they all update in the
// same commit.
const themedRootProps = useMemo(() => {
const themeCSSVariables =
typeof theme !== "object"
? undefined
: "light" in theme && "dark" in theme
? themeToCSSVariables(
theme[defaultColorScheme === "dark" ? "dark" : "light"],
)
: themeToCSSVariables(theme);

useEffect(() => {
if (!editor.portalElement) {
throw new Error("Portal element not found");
}
editor.portalElement.setAttribute("data-mantine-color-scheme", finalTheme);
applyThemeVariables(editor.portalElement);
}, [editor, applyThemeVariables, finalTheme]);
return {
"data-mantine-color-scheme": finalTheme,
style: themeCSSVariables,
};
}, [defaultColorScheme, theme, finalTheme]);

const mantineContext = useContext(MantineContext);

const view = (
<ComponentsContext.Provider value={components}>
<BlockNoteViewRaw
data-mantine-color-scheme={finalTheme}
className={mergeCSSClasses("bn-mantine", className || "")}
themedRootProps={themedRootProps}
theme={typeof theme === "object" ? undefined : theme}
editor={editor}
{...rest}
ref={applyThemeVariables}
/>
</ComponentsContext.Provider>
);
Expand Down
Loading
Loading