diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 37022b4d16..cb650d85cf 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,5 +1,10 @@ import { SideMenuExtension } from "@blocknote/core/extensions"; -import { autoUpdate, ReferenceElement } from "@floating-ui/react"; +import { + autoUpdate, + offset, + OffsetOptions, + ReferenceElement, +} from "@floating-ui/react"; import { FC, useCallback, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -9,6 +14,36 @@ import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; import { SideMenuProps } from "./SideMenuProps.js"; +/** + * Pass to Floating UI's `offset` middleware to keep the side menu aligned + * with table blocks: + * middleware: [offset(tableWrapperOffset), ...other] + * + * Compensates for the top padding on `.tableWrapper` (the in-block container + * that reserves space for row/column handles) — without this, the side menu + * floats above the visible top of the table. + * + * Assumes `placement: "left-start"`. Other placements map `crossAxis` to a + * different visual axis. + */ +export const tableWrapperOffset: OffsetOptions = (state) => { + const { reference } = state.elements; + const refEl = + reference instanceof Element ? reference : reference.contextElement; + // The side menu's reference is the block's outer `.bn-block` element. + // For tables, the `.tableWrapper` lives one level deeper inside + // `.bn-block-content`. Match that exact path so unrelated descendants + // (e.g. a nested table inside a multi-column block) don't trigger. + const wrapper = refEl?.querySelector( + ":scope > .bn-block-content > .tableWrapper", + ); + if (!wrapper) { + return 0; + } + const padding = parseFloat(getComputedStyle(wrapper).paddingTop); + return padding > 0 ? { mainAxis: 0, crossAxis: padding } : 0; +}; + export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; @@ -67,6 +102,10 @@ export const SideMenuController = (props: { placement: "left-start", whileElementsMounted, ...props.floatingUIOptions?.useFloatingOptions, + middleware: [ + offset(tableWrapperOffset), + ...(props.floatingUIOptions?.useFloatingOptions?.middleware ?? []), + ], }, useDismissProps: { enabled: false, diff --git a/tests/src/end-to-end/draghandle/draghandle.test.ts b/tests/src/end-to-end/draghandle/draghandle.test.ts index f77c414d2b..26c3fc712d 100644 --- a/tests/src/end-to-end/draghandle/draghandle.test.ts +++ b/tests/src/end-to-end/draghandle/draghandle.test.ts @@ -10,6 +10,7 @@ import { H_TWO_BLOCK_SELECTOR, PARAGRAPH_SELECTOR, SLASH_MENU_SELECTOR, + TABLE_SELECTOR, } from "../../utils/const.js"; import { insertHeading } from "../../utils/copypaste.js"; import { @@ -45,6 +46,20 @@ test.describe("Check Draghandle functionality", () => { await page.waitForSelector(DRAG_HANDLE_SELECTOR); }); + test("Draghandle should align vertically with the top of a table block", async () => { + await executeSlashCommand(page, "table"); + await page.waitForSelector(TABLE_SELECTOR); + + const handleY = await getDragHandleYCoord(page, TABLE_SELECTOR); + const tableY = (await page.locator(`${TABLE_SELECTOR} table`).boundingBox())!.y; + + // Regression test for #2604: prior to the fix the drag handle floated + // ~8px above the visible top of the table because it anchored to the + // bn-block-content wrapper, ignoring the tableWrapper's top padding (the + // space reserved for in-block row/column handles). + expect(Math.abs(handleY - tableY)).toBeLessThanOrEqual(2); + }); + test("Draghandle should display next to correct block", async () => { await insertHeading(page, 1); await insertHeading(page, 2);