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 7bca85a434..fa7ff0d347 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -8,6 +8,8 @@ import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; export const BlockPopover = ( props: FloatingUIOptions & { blockId: string | undefined; + ignoreNestingOffset?: boolean; + includeNestedBlocks?: boolean; children: ReactNode; }, ) => { @@ -28,18 +30,52 @@ 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; } - return { - element: node, - }; + const blockContentElement = blockElement.firstElementChild; + if (!(blockContentElement instanceof Element)) { + return undefined; + } + + const element = + props.includeNestedBlocks === false + ? blockContentElement + : blockElement; + + if (props.ignoreNestingOffset) { + return { + element, + getBoundingClientRect: () => { + const boundingClientRect = element.getBoundingClientRect(); + + const outerBlockGroupElement = element.closest( + ".bn-editor > .bn-block-group > .bn-block-outer, .bn-block-column > .bn-block-outer", + ); + if (!(outerBlockGroupElement instanceof Element)) { + return undefined; + } + + const outerBlockGroupBoundingClientRect = + outerBlockGroupElement.getBoundingClientRect(); + + return new DOMRect( + outerBlockGroupBoundingClientRect.x, + boundingClientRect.y, + outerBlockGroupBoundingClientRect.width, + boundingClientRect.height, + ); + }, + }; + } + + return { element }; }), - [editor, blockId], + [editor, blockId, props.includeNestedBlocks, props.ignoreNestingOffset], ); 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..ae0708fdbf 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,6 +1,9 @@ +import { BlockSchema, InlineContentSchema, StyleSchema } 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,12 @@ export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; }) => { + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); + const state = useExtensionState(SideMenuExtension, { selector: (state) => { return state !== undefined @@ -29,6 +38,117 @@ export const SideMenuController = (props: { useFloatingOptions: { 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 + // 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; + } + + const blockElement = + elements.reference instanceof Element + ? elements.reference + : elements.reference.contextElement; + if (blockElement === undefined) { + 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`, + ); + + 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(); + + elements.floating.style.setProperty( + "height", + `${toggleWrapperBoundingClientRect.height}px`, + ); + elements.floating.style.setProperty( + "top", + `${toggleWrapperBoundingClientRect.y - blockContentBoundingClientRect.y}px`, + ); + + 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; + } + + // 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"); + }, + }), + ], }, useDismissProps: { enabled: false, @@ -40,13 +160,17 @@ export const SideMenuController = (props: { }, ...props.floatingUIOptions, }), - [props.floatingUIOptions, show], + [editor, props.floatingUIOptions, show], ); const Component = props.sideMenu || SideMenu; return ( - + {block?.id && } ); 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);