From 1918cbf4843e2ee491c8414a63a6cb4acebc919f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 3 Dec 2025 16:57:29 +0100 Subject: [PATCH 1/5] Moved side menu height setting to FloatingUI --- .../src/components/Popovers/BlockPopover.tsx | 41 +++++++++-- .../src/components/SideMenu/SideMenu.tsx | 40 +--------- .../SideMenu/SideMenuController.tsx | 73 ++++++++++++++++++- packages/react/src/editor/styles.css | 29 -------- 4 files changed, 110 insertions(+), 73 deletions(-) diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 7bca85a434..03cf01a40d 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -8,6 +8,7 @@ import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; export const BlockPopover = ( props: FloatingUIOptions & { blockId: string | undefined; + includeNestedBlocks?: boolean; children: ReactNode; }, ) => { @@ -28,18 +29,48 @@ export const BlockPopover = ( return undefined; } - const { node } = editor.prosemirrorView.domAtPos( + const blockElement = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, - ); - if (!(node instanceof Element)) { + ).node; + if (!(blockElement instanceof Element)) { + return undefined; + } + + if (props.includeNestedBlocks) { + return { + element: blockElement, + }; + } + + const blockContentNode = blockElement.firstElementChild; + if (blockContentNode === null) { return undefined; } return { - element: node, + element: blockContentNode, + getBoundingClientRect: () => { + const outerBlockGroupClientRect = + editor.domElement?.firstElementChild?.getBoundingClientRect(); + if (outerBlockGroupClientRect === undefined) { + throw new Error( + "Root blockGroup element doesn't exist, yet descendant blockContent element does.", + ); + } + + const blockContentBoundingClientRect = + blockContentNode.getBoundingClientRect(); + + return new DOMRect( + outerBlockGroupClientRect.x, + blockContentBoundingClientRect.y, + outerBlockGroupClientRect.width, + blockContentBoundingClientRect.height, + ); + }, }; }), - [editor, blockId], + [editor, blockId, props.includeNestedBlocks], ); return ( diff --git a/packages/react/src/components/SideMenu/SideMenu.tsx b/packages/react/src/components/SideMenu/SideMenu.tsx index 3e40c6ade6..62336e3c21 100644 --- a/packages/react/src/components/SideMenu/SideMenu.tsx +++ b/packages/react/src/components/SideMenu/SideMenu.tsx @@ -1,9 +1,6 @@ -import { SideMenuExtension } from "@blocknote/core/extensions"; -import { ReactNode, useMemo } from "react"; +import { ReactNode } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useExtensionState } from "../../hooks/useExtension.js"; import { AddBlockButton } from "./DefaultButtons/AddBlockButton.js"; import { DragHandleButton } from "./DefaultButtons/DragHandleButton.js"; import { SideMenuProps } from "./SideMenuProps.js"; @@ -20,41 +17,8 @@ import { SideMenuProps } from "./SideMenuProps.js"; export const SideMenu = (props: SideMenuProps & { children?: ReactNode }) => { const Components = useComponentsContext()!; - const editor = useBlockNoteEditor(); - - const block = useExtensionState(SideMenuExtension, { - editor, - selector: (state) => state?.block, - }); - - const dataAttributes = useMemo(() => { - if (block === undefined) { - return {}; - } - - const attrs: Record = { - "data-block-type": block.type, - }; - - if (block.type === "heading") { - attrs["data-level"] = (block.props as any).level.toString(); - } - - if ( - editor.schema.blockSpecs[block.type].implementation.meta?.fileBlockAccept - ) { - if (block.props.url) { - attrs["data-url"] = "true"; - } else { - attrs["data-url"] = "false"; - } - } - - return attrs; - }, [block, editor.schema.blockSpecs]); - return ( - + {props.children || ( <> diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 3ceecc5db7..024df37366 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,6 +1,9 @@ +import { blockHasType } from "@blocknote/core"; import { SideMenuExtension } from "@blocknote/core/extensions"; +import { size } from "@floating-ui/react"; import { FC, useMemo } from "react"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; @@ -11,6 +14,8 @@ export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; }) => { + const editor = useBlockNoteEditor(); + const state = useExtensionState(SideMenuExtension, { selector: (state) => { return state !== undefined @@ -29,6 +34,72 @@ export const SideMenuController = (props: { useFloatingOptions: { open: show, placement: "left-start", + middleware: [ + size({ + apply({ elements }) { + // TODO: Need to fetch the block from extension, else it's + // always `undefined` for some reason? Shouldn't the `apply` + // function get recreated with the updated `block` object each + // time it changes? + const block = + editor.getExtension(SideMenuExtension)?.store.state?.block; + if (block === undefined) { + return; + } + + if (block.type === "heading") { + if (!block.props.level || block.props.level === 1) { + elements.floating.style.setProperty("height", "78px"); + return; + } + + if (block.props.level === 2) { + elements.floating.style.setProperty("height", "54px"); + return; + } + + if (block.props.level === 2) { + elements.floating.style.setProperty("height", "37px"); + return; + } + } + + if ( + editor.schema.blockSpecs[block.type].implementation.meta + ?.fileBlockAccept + ) { + if ( + blockHasType(block, editor, block.type, { + url: "string", + }) && + block.props.url === "" + ) { + elements.floating.style.setProperty("height", "54px"); + return; + } + + if ( + block.type === "file" || + (blockHasType(block, editor, block.type, { + showPreview: "boolean", + }) && + !block.props.showPreview) + ) { + elements.floating.style.setProperty("height", "38px"); + return; + } + + if (block.type === "audio") { + elements.floating.style.setProperty("height", "60px"); + return; + } + } + + elements.floating.style.setProperty("height", "30px"); + elements.floating.style.height = "30px"; + }, + }), + ], }, useDismissProps: { enabled: false, @@ -40,7 +111,7 @@ export const SideMenuController = (props: { }, ...props.floatingUIOptions, }), - [props.floatingUIOptions, show], + [editor, props.floatingUIOptions, show], ); const Component = props.sideMenu || SideMenu; diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 3862946986..3999cdc1f4 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -237,35 +237,6 @@ inline styles, it is added to the base z-index. */ --bn-ui-base-z-index: 0; } -/* Matches Side Menu height to block line height */ -.bn-side-menu { - height: 30px; -} - -.bn-side-menu[data-block-type="heading"][data-level="1"] { - height: 78px; -} - -.bn-side-menu[data-block-type="heading"][data-level="2"] { - height: 54px; -} - -.bn-side-menu[data-block-type="heading"][data-level="3"] { - height: 37px; -} - -.bn-side-menu[data-block-type="file"] { - height: 38px; -} - -.bn-side-menu[data-block-type="audio"] { - height: 60px; -} - -.bn-side-menu[data-url="false"] { - height: 54px; -} - /* Thread sidebar styling */ .bn-threads-sidebar { border-radius: var(--bn-border-radius-medium); From 47c9629bbd87ce7bee6e2757e8a125e023b9aefb Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 3 Dec 2025 17:11:39 +0100 Subject: [PATCH 2/5] Rename --- packages/react/src/components/Popovers/BlockPopover.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 03cf01a40d..08a4422721 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -42,13 +42,13 @@ export const BlockPopover = ( }; } - const blockContentNode = blockElement.firstElementChild; - if (blockContentNode === null) { + const blockContentElement = blockElement.firstElementChild; + if (blockContentElement === null) { return undefined; } return { - element: blockContentNode, + element: blockContentElement, getBoundingClientRect: () => { const outerBlockGroupClientRect = editor.domElement?.firstElementChild?.getBoundingClientRect(); @@ -59,7 +59,7 @@ export const BlockPopover = ( } const blockContentBoundingClientRect = - blockContentNode.getBoundingClientRect(); + blockContentElement.getBoundingClientRect(); return new DOMRect( outerBlockGroupClientRect.x, From 785bc45789db562c6bc809fde0831a6034cea263 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 3 Dec 2025 17:45:27 +0100 Subject: [PATCH 3/5] Made `BlockPopover` more configurable --- .../FilePanel/FilePanelController.tsx | 6 +- .../src/components/Popovers/BlockPopover.tsx | 60 ++++++++++--------- .../SideMenu/SideMenuController.tsx | 6 +- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index da330242ab..6ca4693067 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -48,7 +48,11 @@ export const FilePanelController = (props: { const Component = props.filePanel || FilePanel; return ( - + {blockId && } ); diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 08a4422721..7c4c2ba579 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -8,6 +8,7 @@ import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; export const BlockPopover = ( props: FloatingUIOptions & { blockId: string | undefined; + spanEditorWidth?: boolean; includeNestedBlocks?: boolean; children: ReactNode; }, @@ -36,41 +37,44 @@ export const BlockPopover = ( return undefined; } - if (props.includeNestedBlocks) { - return { - element: blockElement, - }; - } - const blockContentElement = blockElement.firstElementChild; - if (blockContentElement === null) { + if (!(blockContentElement instanceof Element)) { return undefined; } - return { - element: blockContentElement, - getBoundingClientRect: () => { - const outerBlockGroupClientRect = - editor.domElement?.firstElementChild?.getBoundingClientRect(); - if (outerBlockGroupClientRect === undefined) { - throw new Error( - "Root blockGroup element doesn't exist, yet descendant blockContent element does.", - ); - } + const element = + props.includeNestedBlocks === false + ? blockContentElement + : blockElement; + + if (props.spanEditorWidth) { + return { + element, + getBoundingClientRect: () => { + const boundingClientRect = element.getBoundingClientRect(); + + const outerBlockGroupElement = + editor.domElement?.firstElementChild; + if (!(outerBlockGroupElement instanceof Element)) { + return undefined; + } + + const outerBlockGroupBoundingClientRect = + outerBlockGroupElement.getBoundingClientRect(); - const blockContentBoundingClientRect = - blockContentElement.getBoundingClientRect(); + return new DOMRect( + outerBlockGroupBoundingClientRect.x, + boundingClientRect.y, + outerBlockGroupBoundingClientRect.width, + boundingClientRect.height, + ); + }, + }; + } - return new DOMRect( - outerBlockGroupClientRect.x, - blockContentBoundingClientRect.y, - outerBlockGroupClientRect.width, - blockContentBoundingClientRect.height, - ); - }, - }; + return { element }; }), - [editor, blockId, props.includeNestedBlocks], + [editor, blockId, props.includeNestedBlocks, props.spanEditorWidth], ); return ( diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 024df37366..1945b2a529 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -117,7 +117,11 @@ export const SideMenuController = (props: { const Component = props.sideMenu || SideMenu; return ( - + {block?.id && } ); From fd8a1b5840b48359b1a32fffb377b8d983c30b67 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 12 Dec 2025 15:09:22 +0100 Subject: [PATCH 4/5] Changed from hardcoded height values to ones derived from reference element height --- .../SideMenu/SideMenuController.tsx | 133 ++++++++++++------ 1 file changed, 91 insertions(+), 42 deletions(-) diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 1945b2a529..f547a1857e 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,4 +1,4 @@ -import { blockHasType } from "@blocknote/core"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { SideMenuExtension } from "@blocknote/core/extensions"; import { size } from "@floating-ui/react"; import { FC, useMemo } from "react"; @@ -14,7 +14,11 @@ export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; }) => { - const editor = useBlockNoteEditor(); + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); const state = useExtensionState(SideMenuExtension, { selector: (state) => { @@ -35,6 +39,10 @@ export const SideMenuController = (props: { open: show, placement: "left-start", middleware: [ + // Adjusts the side menu height to align it vertically with the + // block's content. In some cases, like file blocks with captions, + // the height and top offset is adjusted to align it with a specific + // element within the block's content instead. size({ apply({ elements }) { // TODO: Need to fetch the block from extension, else it's @@ -47,56 +55,97 @@ export const SideMenuController = (props: { return; } - if (block.type === "heading") { - if (!block.props.level || block.props.level === 1) { - elements.floating.style.setProperty("height", "78px"); - return; - } + const blockElement = + elements.reference instanceof Element + ? elements.reference + : elements.reference.contextElement; + if (blockElement === undefined) { + return; + } - if (block.props.level === 2) { - elements.floating.style.setProperty("height", "54px"); - return; - } + const blockContentElement = + blockElement.querySelector(".bn-block-content"); + if (blockContentElement === null) { + return; + } + + const blockContentBoundingClientRect = + blockContentElement.getBoundingClientRect(); + + // Special case for file blocks with captions. In this case, we + // align the side menu with the first sibling of the file caption + // element. + const fileCaptionParentElement = + blockContentElement.querySelector(".bn-file-caption") + ?.parentElement || null; + if (fileCaptionParentElement !== null) { + const fileElement = fileCaptionParentElement.firstElementChild; + if (fileElement) { + const fileBoundingClientRect = + fileElement.getBoundingClientRect(); + + elements.floating.style.setProperty( + "height", + `${fileBoundingClientRect.height}px`, + ); + elements.floating.style.setProperty( + "top", + `${fileBoundingClientRect.y - blockContentBoundingClientRect.y}px`, + ); - if (block.props.level === 2) { - elements.floating.style.setProperty("height", "37px"); return; } } - if ( - editor.schema.blockSpecs[block.type].implementation.meta - ?.fileBlockAccept - ) { - if ( - blockHasType(block, editor, block.type, { - url: "string", - }) && - block.props.url === "" - ) { - elements.floating.style.setProperty("height", "54px"); - return; - } + // Special case for toggleable blocks. In this case, we align the + // side menu with the element containing the toggle button and + // rich text. + const toggleWrapperElement = + blockContentElement.querySelector(".bn-toggle-wrapper"); + if (toggleWrapperElement !== null) { + const toggleWrapperBoundingClientRect = + toggleWrapperElement.getBoundingClientRect(); - if ( - block.type === "file" || - (blockHasType(block, editor, block.type, { - showPreview: "boolean", - }) && - !block.props.showPreview) - ) { - elements.floating.style.setProperty("height", "38px"); - return; - } + elements.floating.style.setProperty( + "height", + `${toggleWrapperBoundingClientRect.height}px`, + ); + elements.floating.style.setProperty( + "top", + `${toggleWrapperBoundingClientRect.y - blockContentBoundingClientRect.y}px`, + ); - if (block.type === "audio") { - elements.floating.style.setProperty("height", "60px"); - return; - } + return; + } + + // Special case for table blocks. In this case, we align the side + // menu with the table element inside the block. + const tableElement = blockContentElement.querySelector( + ".tableWrapper table", + ); + if (tableElement !== null) { + const tableBoundingClientRect = + tableElement.getBoundingClientRect(); + + elements.floating.style.setProperty( + "height", + `${tableBoundingClientRect.height}px`, + ); + elements.floating.style.setProperty( + "top", + `${tableBoundingClientRect.y - blockContentBoundingClientRect.y}px`, + ); + + return; } - elements.floating.style.setProperty("height", "30px"); - elements.floating.style.height = "30px"; + // Regular case, in which the side menu is aligned with the block + // content element. + elements.floating.style.setProperty( + "height", + `${blockContentBoundingClientRect.height}px`, + ); + elements.floating.style.setProperty("top", "0"); }, }), ], From ddf257a41f24f639d726592e98fc97a06882d7c8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 12 Dec 2025 16:39:21 +0100 Subject: [PATCH 5/5] Fixed multi column issue --- .../react/src/components/Popovers/BlockPopover.tsx | 11 ++++++----- .../src/components/SideMenu/SideMenuController.tsx | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 7c4c2ba579..fa7ff0d347 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -8,7 +8,7 @@ import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; export const BlockPopover = ( props: FloatingUIOptions & { blockId: string | undefined; - spanEditorWidth?: boolean; + ignoreNestingOffset?: boolean; includeNestedBlocks?: boolean; children: ReactNode; }, @@ -47,14 +47,15 @@ export const BlockPopover = ( ? blockContentElement : blockElement; - if (props.spanEditorWidth) { + if (props.ignoreNestingOffset) { return { element, getBoundingClientRect: () => { const boundingClientRect = element.getBoundingClientRect(); - const outerBlockGroupElement = - editor.domElement?.firstElementChild; + const outerBlockGroupElement = element.closest( + ".bn-editor > .bn-block-group > .bn-block-outer, .bn-block-column > .bn-block-outer", + ); if (!(outerBlockGroupElement instanceof Element)) { return undefined; } @@ -74,7 +75,7 @@ export const BlockPopover = ( return { element }; }), - [editor, blockId, props.includeNestedBlocks, props.spanEditorWidth], + [editor, blockId, props.includeNestedBlocks, props.ignoreNestingOffset], ); return ( diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index f547a1857e..ae0708fdbf 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -168,7 +168,7 @@ export const SideMenuController = (props: { return ( {block?.id && }