Skip to content

fix: align side menu drag handle with table top (#2604)#2679

Open
betsyschultz wants to merge 2 commits intoTypeCellOS:mainfrom
betsyschultz:fix/2604-drag-handle-table-alignment
Open

fix: align side menu drag handle with table top (#2604)#2679
betsyschultz wants to merge 2 commits intoTypeCellOS:mainfrom
betsyschultz:fix/2604-drag-handle-table-alignment

Conversation

@betsyschultz
Copy link
Copy Markdown

@betsyschultz betsyschultz commented Apr 27, 2026

PR Draft — fix: align side menu drag handle with table top (#2604)

Open against TypeCellOS/BlockNote:main from fix/2604-drag-handle-table-alignment.
Title: fix: align side menu drag handle with table top (#2604)


What

Closes #2604. The side menu drag handle (and the + add button next to it) now align with the visible top of a table block instead of floating ~8px above it.

Why it was broken

SideMenuController uses Floating UI with placement: "left-start", which anchors the popover's top edge to the reference element's top edge. The reference is the block's outer wrapper (bn-block-content).

For text blocks (paragraph, heading, list, code) this looks correct because the inline content sits flush at the top of the wrapper.

For table blocks the DOM is:

.bn-block-content[data-content-type="table"]   ← Floating UI reference
  .tableWrapper                                ← padding-top: 9px (--bn-table-handle-size)
    .tableWrapper-inner
      <table>                                  ← what the user actually sees

.tableWrapper's top padding is intentional — it reserves vertical space for the in-block row handles (the small drag dots that appear above each row on hover). That padding pushes the visible <table> element 9px below the wrapper top, but the side menu had no awareness of it.

Measured before fix (initial empty editor, fresh table): drag handle top = 12px, table element top = 20px → 8px gap.

The fix

Add a Floating UI offset middleware that, when the reference element contains a direct .tableWrapper child, shifts the popover down by that wrapper's computed padding-top. The middleware is a no-op for any reference without a .tableWrapper child, so non-table blocks are unaffected.

The padding value is read from getComputedStyle rather than hardcoded so that the fix tracks the --bn-table-handle-size CSS variable automatically.

After fix: drag handle top = 21px, table element top = 20px → 1px gap (matches paragraph alignment, where the handle SVG is intentionally 1px below the inline content top).

Files changed

  • packages/react/src/components/SideMenu/SideMenuController.tsx (+22 / −0)
  • tests/src/end-to-end/draghandle/draghandle.test.ts (+24 / −1)

Test plan

  • Added regression test in draghandle.test.ts — asserts Math.abs(handleY - tableY) <= 5. Fails on main (8px gap), passes with the fix (1px gap).
  • pnpm exec tsc --noEmit clean for @blocknote/react
  • nx lint @blocknote/react clean (--max-warnings 0)
  • nx build @blocknote/react succeeds
  • Manual repro: paragraph hover → handle still aligned (no regression). Table hover → handle now aligned with table top.

What was NOT run locally

The full E2E Playwright suite (tests/) was not run locally — relies on CI for the cross-browser snapshot suite. The added regression test follows the same conventions as the other tests in draghandle.test.ts (uses executeSlashCommand, moveMouseOverElement, boundingBox()).

Edge cases considered

Case Status
Paragraph / heading / list (no regression) Verified — middleware is no-op without .tableWrapper
Table as the only block Verified visually
Table after a paragraph Same code path, same result
Multi-column block containing a table :scope > .tableWrapper (direct child only) prevents an outer block's reference from picking up a nested table's wrapper
Future block types adding their own widget padding Pattern is reusable — any block whose direct child is .tableWrapper (or by extension a similarly named wrapper) gets correct alignment automatically

Screenshots

After fix (drag handle aligned with top of table):

after-fix

(Run the playground at pnpm dev/basic/minimal/table Enter, then hover the table to reproduce.)

Notes for reviewer

  • The middleware uses a CSS-attribute-driven approach rather than a per-block-type switch, so it stays correct if --bn-table-handle-size is themed.
  • tableWrapperOffset is exported from @blocknote/react so consumers who pass their own middleware: [...] via floatingUIOptions.useFloatingOptions can re-compose it: middleware: [offset(tableWrapperOffset), ...other]. Without this, customising middleware would silently overwrite the fix (this matches the existing spread pattern in FloatingComposerController etc., so the footgun isn't new — but the helper makes it recoverable).
  • The fix assumes placement: "left-start" (the default for the side menu). Consumers who change placement keep the offset, but crossAxis may map to a different visual axis depending on placement; documented in the helper's JSDoc.
  • If you'd rather solve this at the layout layer (e.g., move tableWrapper's widget-spacing from padding to absolute-positioned widgets), that's a larger architectural change — let me know and I can spin a separate PR.

Summary by CodeRabbit

  • Bug Fixes
    • Side menu now aligns correctly with the visible top of table content, accounting for padding so the menu appears adjacent to table rows.
    • Improved drag-handle positioning accuracy on table elements; drag handles are placed to match the table’s visible top within a tight tolerance.

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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

Someone is attempting to deploy a commit to the TypeCell Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

Adds a Floating UI middleware tableWrapperOffset exported from SideMenuController that computes an offset from a table wrapper's paddingTop to align the side menu with table content; also adds an end-to-end regression test that verifies drag-handle vertical alignment with table top (≤2px).

Changes

Cohort / File(s) Summary
Side Menu Offset Middleware
packages/react/src/components/SideMenu/SideMenuController.tsx
Adds and exports tableWrapperOffset: OffsetOptions. Middleware locates a .tableWrapper descendant of the block reference, reads computed paddingTop, and returns a cross-axis/main-axis offset (or zero) which is prepended to useFloatingOptions.middleware when placement: "left-start".
Drag Handle Alignment Test
tests/src/end-to-end/draghandle/draghandle.test.ts
Adds an E2E regression assertion for table blocks: after inserting a table, measures drag-handle Y and table visible-top Y and asserts the absolute difference is ≤ 2px; imports TABLE_SELECTOR for locating table DOM.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • matthewlipski
  • nperez0111

Poem

🐰 I hopped along the code tonight,
paddings measured, offsets right.
Tables and handles now align,
pixels counted — all is fine.
A rabbit's tweak, precise and bright. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: aligning the side menu drag handle with the table top, matching the primary objective of the changeset.
Description check ✅ Passed The description comprehensively covers all template sections: Summary, Rationale, Changes, Impact, Testing, and edge cases, with detailed technical explanation and verification steps.
Linked Issues check ✅ Passed The PR implementation fully addresses issue #2604 by adding a Floating UI offset middleware that detects table wrappers and adjusts the drag handle position to align with the visible table top.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the drag handle alignment: the offset middleware in SideMenuController and a regression test verifying the fix, with no unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
tests/src/end-to-end/draghandle/draghandle.test.ts (2)

60-60: 5px tolerance is reasonable but loose — consider tightening if it proves stable.

The pre-fix delta was ~8px, so <= 5 correctly distinguishes fixed vs. broken. Post-fix the actual delta should be sub-pixel. If the test proves stable across browsers in CI, tightening to e.g. <= 2 would catch future drift earlier; otherwise leave as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/end-to-end/draghandle/draghandle.test.ts` at line 60, The assertion
tolerance in the test uses a 5px threshold which is looser than needed; update
the expectation expression that compares handleY and tableY (the
expect(Math.abs(handleY - tableY)).toBeLessThanOrEqual(...) call) to use a
tighter threshold (e.g., 2) by replacing 5 with 2 so the line becomes
toBeLessThanOrEqual(2); if this fails intermittently in CI after the change,
revert to the original 5 until stability is achieved.

49-61: Minor: clarify the boundingBox null-handling with an explicit check.

Line 54 uses (...)!.y — a non-null assertion. If boundingBox() ever returns null (element detached/invisible), the failure surface is a cryptic TypeError instead of a clear assertion. An explicit expect(box).not.toBeNull() reads better in CI logs.

Clearer null check
-    const tableY = (await page.locator(`${TABLE_SELECTOR} table`).boundingBox())!.y;
+    const tableBox = await page.locator(`${TABLE_SELECTOR} table`).boundingBox();
+    expect(tableBox).not.toBeNull();
+    const tableY = tableBox!.y;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/end-to-end/draghandle/draghandle.test.ts` around lines 49 - 61, The
test "Draghandle should align vertically with the top of a table block" uses a
non-null assertion on the result of page.locator(`${TABLE_SELECTOR}
table`).boundingBox() — replace that with an explicit null check: capture the
boundingBox into a variable (e.g., box), assert expect(box).not.toBeNull() and
then use box!.y (or destructure after the assertion) when computing tableY; this
change should be applied around the code that calls boundingBox() in the test
and references TABLE_SELECTOR so failures show a clear assertion rather than a
TypeError.
packages/react/src/components/SideMenu/SideMenuController.tsx (1)

104-104: Confirm consumers passing custom middleware still get tableWrapperOffset applied.

The spread on Line 105 (...props.floatingUIOptions?.useFloatingOptions) means a consumer passing their own middleware array will fully replace the [offset(tableWrapperOffset)] default and lose the table-alignment fix. The exported tableWrapperOffset lets them re-compose it, but this is an easy footgun. Consider either:

  • documenting the requirement to re-include offset(tableWrapperOffset) near the export, or
  • merging arrays so the default is preserved.
Optional preserve-default approach
-      useFloatingOptions: {
-        open: show,
-        placement: "left-start",
-        whileElementsMounted,
-        middleware: [offset(tableWrapperOffset)],
-        ...props.floatingUIOptions?.useFloatingOptions,
-      },
+      useFloatingOptions: {
+        open: show,
+        placement: "left-start",
+        whileElementsMounted,
+        ...props.floatingUIOptions?.useFloatingOptions,
+        middleware: [
+          offset(tableWrapperOffset),
+          ...(props.floatingUIOptions?.useFloatingOptions?.middleware ?? []),
+        ],
+      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/components/SideMenu/SideMenuController.tsx` at line 104,
The current middleware assignment in SideMenuController overwrites the default
offset(tableWrapperOffset) when a consumer provides
floatingUIOptions.useFloatingOptions.middleware; update the middleware
composition so the default offset(tableWrapperOffset) is merged with any
consumer-provided array (e.g., combine
props.floatingUIOptions?.useFloatingOptions?.middleware with
offset(tableWrapperOffset) instead of replacing it) ensuring tableWrapperOffset
remains applied; touch SideMenuController where middleware is set and use
tableWrapperOffset symbol to compose the final middleware array (or
alternatively add a clear comment near the exported tableWrapperOffset
documenting that consumers must include it).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react/src/components/SideMenu/SideMenuController.tsx`:
- Line 104: The current middleware assignment in SideMenuController overwrites
the default offset(tableWrapperOffset) when a consumer provides
floatingUIOptions.useFloatingOptions.middleware; update the middleware
composition so the default offset(tableWrapperOffset) is merged with any
consumer-provided array (e.g., combine
props.floatingUIOptions?.useFloatingOptions?.middleware with
offset(tableWrapperOffset) instead of replacing it) ensuring tableWrapperOffset
remains applied; touch SideMenuController where middleware is set and use
tableWrapperOffset symbol to compose the final middleware array (or
alternatively add a clear comment near the exported tableWrapperOffset
documenting that consumers must include it).

In `@tests/src/end-to-end/draghandle/draghandle.test.ts`:
- Line 60: The assertion tolerance in the test uses a 5px threshold which is
looser than needed; update the expectation expression that compares handleY and
tableY (the expect(Math.abs(handleY - tableY)).toBeLessThanOrEqual(...) call) to
use a tighter threshold (e.g., 2) by replacing 5 with 2 so the line becomes
toBeLessThanOrEqual(2); if this fails intermittently in CI after the change,
revert to the original 5 until stability is achieved.
- Around line 49-61: The test "Draghandle should align vertically with the top
of a table block" uses a non-null assertion on the result of
page.locator(`${TABLE_SELECTOR} table`).boundingBox() — replace that with an
explicit null check: capture the boundingBox into a variable (e.g., box), assert
expect(box).not.toBeNull() and then use box!.y (or destructure after the
assertion) when computing tableY; this change should be applied around the code
that calls boundingBox() in the test and references TABLE_SELECTOR so failures
show a clear assertion rather than a TypeError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eb7c2902-6054-4c8e-bb42-3d1010b1536d

📥 Commits

Reviewing files that changed from the base of the PR and between 210b499 and e358836.

📒 Files selected for processing (2)
  • packages/react/src/components/SideMenu/SideMenuController.tsx
  • tests/src/end-to-end/draghandle/draghandle.test.ts

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).
@betsyschultz
Copy link
Copy Markdown
Author

Thanks @coderabbitai — addressed two of the three nitpicks in 84b7cc9:

1. Middleware override (SideMenuController.tsx:104) — fixed. The default offset(tableWrapperOffset) is now composed with consumer-provided middleware instead of being silently overwritten:

useFloatingOptions: {
  ...
  ...props.floatingUIOptions?.useFloatingOptions,
  middleware: [
    offset(tableWrapperOffset),
    ...(props.floatingUIOptions?.useFloatingOptions?.middleware ?? []),
  ],
}

Worth noting the same latent bug exists in FloatingThreadController, FloatingComposerController, FilePanelController, LinkToolbarController, and FormattingToolbarController — happy to open a follow-up PR if maintainers want it fixed across the board, or leave it scoped to this fix.

2. Test tolerance (draghandle.test.ts:60) — tightened from 5px → 2px. The fix subtracts the exact tableWrapper padding value, so the post-fix delta is sub-pixel by construction; 2px is comfortable headroom for cross-browser rounding without inviting flake.

3. Non-null assertion on boundingBox() — skipped. The inline (await el.boundingBox())! pattern is the established idiom across this test suite (colors.test.ts, images.test.ts, copypaste.test.ts, indentation.test.ts, ai-selection.test.ts, keyboardhandlers.test.ts, tests/src/utils/mouse.ts, tests/src/utils/draghandle.ts). Adding an explicit expect().not.toBeNull() only here breaks pattern consistency without functional benefit — the ! already encodes the assertion. Happy to revisit if you'd prefer the broader test suite move to explicit checks.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Only users with a collaborator, contributor, member, or owner role can interact with CodeRabbit.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/react/src/components/SideMenu/SideMenuController.tsx (2)

105-108: Consumer-provided offset middleware will stack, not override.

Because tableWrapperOffset is hardcoded as the first entry, a consumer who passes their own offset(...) via props.floatingUIOptions.useFloatingOptions.middleware will end up with both running (Floating UI stacks multiple offset middlewares additively). Per the PR description, the intent is that consumers re-compose using the exported tableWrapperOffset, but there's no way to opt out of the prepended default if they're unaware. Consider documenting on SideMenuController/floatingUIOptions that consumers overriding middleware should explicitly include offset(tableWrapperOffset) and that a top-level offset they pass will compound.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/components/SideMenu/SideMenuController.tsx` around lines
105 - 108, The current middleware array always prepends
offset(tableWrapperOffset), causing consumer-provided offset(...) to stack;
update SideMenuController so you first pull consumer middleware from
props.floatingUIOptions.useFloatingOptions.middleware, check whether that array
already contains an offset middleware (e.g., detect by middleware.name ===
'offset' or equivalent), and only prepend offset(tableWrapperOffset) when no
offset is present; reference the symbols tableWrapperOffset and
props.floatingUIOptions.useFloatingOptions.middleware when making this change
and add a short comment or docs note on SideMenuController that consumers who
want the default behavior should include offset(tableWrapperOffset) themselves
if they override middleware.

29-45: Consider adding a placement-aware guard for defensive extensibility.

The :scope > .bn-block-content > .tableWrapper selector and VirtualElement.contextElement fallback are correct. parseFloat on paddingTop is safe, and the padding > 0 guard handles the NaN edge case implicitly.

Minor consideration: The function assumes placement: "left-start" (documented in lines 26–27), and while the placement is hardcoded in the default config, consumers who export and reuse tableWrapperOffset with different placements would encounter misaligned offsets. Since crossAxis maps to the vertical axis only for left/right placements, adding a placement check would make this middleware more robust:

- return padding > 0 ? { mainAxis: 0, crossAxis: padding } : 0;
+ if (!(padding > 0)) {
+   return 0;
+ }
+ // crossAxis is vertical only for left/right placements
+ const side = state.placement.split("-")[0];
+ return (side === "left" || side === "right") ? { mainAxis: 0, crossAxis: padding } : 0;

This prevents silent misalignment if the middleware is reused outside its intended placement context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/components/SideMenu/SideMenuController.tsx` around lines
29 - 45, tableWrapperOffset currently assumes a left/right placement mapping (it
returns crossAxis as vertical padding) which can misalign if reused for other
placements; update the tableWrapperOffset(state) function to read
state.placement (or accept placement) and short-circuit/return 0 for placements
where crossAxis is not the vertical axis (e.g., "top", "bottom" or any placement
that doesn't map crossAxis to vertical), otherwise compute and return the
existing { mainAxis: 0, crossAxis: padding } result; reference the
tableWrapperOffset function and the state.placement property when making this
defensive placement-aware guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react/src/components/SideMenu/SideMenuController.tsx`:
- Around line 105-108: The current middleware array always prepends
offset(tableWrapperOffset), causing consumer-provided offset(...) to stack;
update SideMenuController so you first pull consumer middleware from
props.floatingUIOptions.useFloatingOptions.middleware, check whether that array
already contains an offset middleware (e.g., detect by middleware.name ===
'offset' or equivalent), and only prepend offset(tableWrapperOffset) when no
offset is present; reference the symbols tableWrapperOffset and
props.floatingUIOptions.useFloatingOptions.middleware when making this change
and add a short comment or docs note on SideMenuController that consumers who
want the default behavior should include offset(tableWrapperOffset) themselves
if they override middleware.
- Around line 29-45: tableWrapperOffset currently assumes a left/right placement
mapping (it returns crossAxis as vertical padding) which can misalign if reused
for other placements; update the tableWrapperOffset(state) function to read
state.placement (or accept placement) and short-circuit/return 0 for placements
where crossAxis is not the vertical axis (e.g., "top", "bottom" or any placement
that doesn't map crossAxis to vertical), otherwise compute and return the
existing { mainAxis: 0, crossAxis: padding } result; reference the
tableWrapperOffset function and the state.placement property when making this
defensive placement-aware guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 793abc7e-5dc9-4f60-9386-b8ccd90848d4

📥 Commits

Reviewing files that changed from the base of the PR and between e358836 and 84b7cc9.

📒 Files selected for processing (2)
  • packages/react/src/components/SideMenu/SideMenuController.tsx
  • tests/src/end-to-end/draghandle/draghandle.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drag handle is not aligned on tables

1 participant