From e358836e5c1ea45ff6d3c4347be46f9a4bfa4067 Mon Sep 17 00:00:00 2001 From: Betsy Schultz Date: Mon, 27 Apr 2026 00:36:54 -0400 Subject: [PATCH 1/2] fix: align side menu drag handle with table top (#2604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side menu's drag handle floated ~8px above the visible top of table blocks because Floating UI anchored to the block's outer `.bn-block` element, ignoring the `.tableWrapper` child's intentional top padding (~9px) — that padding reserves space for in-block row/column handles. Add a `tableWrapperOffset` Floating UI offset middleware that locates the wrapper at its known DOM path (`.bn-block-content > .tableWrapper`) and shifts the popover down by the wrapper's computed `padding-top`. The middleware is a no-op when no wrapper is present, so paragraph, heading, list, image, code alignment is unchanged. The helper is exported from `@blocknote/react` so consumers who supply their own `middleware` array via `floatingUIOptions.useFloatingOptions` can re-compose it: `middleware: [offset(tableWrapperOffset), ...other]`. Includes a regression test asserting the handle's y-coord stays within 5px of the table element's y-coord. --- .../SideMenu/SideMenuController.tsx | 38 ++++++++++++++++++- .../end-to-end/draghandle/draghandle.test.ts | 15 ++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 37022b4d16..baefb9056f 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; @@ -66,6 +101,7 @@ export const SideMenuController = (props: { open: show, placement: "left-start", whileElementsMounted, + middleware: [offset(tableWrapperOffset)], ...props.floatingUIOptions?.useFloatingOptions, }, useDismissProps: { diff --git a/tests/src/end-to-end/draghandle/draghandle.test.ts b/tests/src/end-to-end/draghandle/draghandle.test.ts index f77c414d2b..47bc50d33b 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(5); + }); + test("Draghandle should display next to correct block", async () => { await insertHeading(page, 1); await insertHeading(page, 2); From 84b7cc9f398a46de39c973d9a2f784ed1d49707c Mon Sep 17 00:00:00 2001 From: Betsy Schultz Date: Mon, 27 Apr 2026 08:13:58 -0400 Subject: [PATCH 2/2] test: address review feedback on drag handle alignment fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose default offset middleware with consumer-provided middleware in SideMenuController so the tableWrapperOffset isn't silently overwritten when consumers pass their own floatingUIOptions.middleware. Tighten regression test tolerance from 5px to 2px — post-fix delta is sub-pixel by construction (offset compensates the exact padding value). Skipped CodeRabbit's suggestion to replace the boundingBox non-null assertion with an explicit expect().not.toBeNull() — the inline `!` pattern is consistent across the test suite (colors, images, copypaste, indentation, ai-selection, keyboardhandlers all use it). --- .../react/src/components/SideMenu/SideMenuController.tsx | 5 ++++- tests/src/end-to-end/draghandle/draghandle.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index baefb9056f..cb650d85cf 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -101,8 +101,11 @@ export const SideMenuController = (props: { open: show, placement: "left-start", whileElementsMounted, - middleware: [offset(tableWrapperOffset)], ...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 47bc50d33b..26c3fc712d 100644 --- a/tests/src/end-to-end/draghandle/draghandle.test.ts +++ b/tests/src/end-to-end/draghandle/draghandle.test.ts @@ -57,7 +57,7 @@ test.describe("Check Draghandle functionality", () => { // ~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(5); + expect(Math.abs(handleY - tableY)).toBeLessThanOrEqual(2); }); test("Draghandle should display next to correct block", async () => {