From a904e58aea9588f77212c174421c7d316afbd841 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 7 Apr 2026 13:56:08 +0200 Subject: [PATCH 1/3] fix: hide side menu on scroll instead of overflow hacks Reverts the overflow/positioning workarounds from #2043 and instead hides the side menu when the user scrolls, preventing it from overflowing outside the editor's scroll container. Co-Authored-By: Claude Opus 4.6 --- docs/app/styles.css | 8 ---- .../core/src/extensions/SideMenu/SideMenu.ts | 12 ++++++ .../components/Popovers/GenericPopover.tsx | 38 ++++++++++++++++++- .../SideMenu/SideMenuController.tsx | 38 ++++++++++++++++++- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/app/styles.css b/docs/app/styles.css index 7aa8d6bbc7..c1f4c85362 100644 --- a/docs/app/styles.css +++ b/docs/app/styles.css @@ -53,14 +53,6 @@ body { box-shadow: unset !important; } -.demo { - overflow: none; -} - -.demo .bn-container { - position: relative; -} - .demo .bn-container:not(.bn-comment-editor), .demo .bn-editor { height: 100%; diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index e65d4b1d99..9323d99be9 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -784,5 +784,17 @@ export const SideMenuExtension = createExtension(({ editor }) => { view!.state!.show = false; view!.emitUpdate(view!.state!); }, + + /** + * Hides the side menu unless it is currently frozen (e.g. the drag + * handle menu is open). Used to dismiss the menu on scroll without + * interfering with open submenus. + */ + hideMenuIfNotFrozen() { + if (!view!.menuFrozen) { + view!.state!.show = false; + view!.emitUpdate(view!.state!); + } + }, } as const; }); diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index c5e40c88d4..eda9ce3b5e 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -3,6 +3,7 @@ import { FloatingFocusManager, useDismiss, useFloating, + UseFloatingOptions, useHover, useInteractions, useMergeRefs, @@ -77,15 +78,48 @@ export function getMountedBoundingClientRectCache( }; } +/** + * Merges two `whileElementsMounted` handlers into one. Both run when elements + * mount, and both cleanup functions are called on unmount. + */ +function mergeWhileElementsMounted( + a: UseFloatingOptions["whileElementsMounted"], + b: UseFloatingOptions["whileElementsMounted"], +): UseFloatingOptions["whileElementsMounted"] { + if (!a) { + return b; + } + if (!b) { + return a; + } + + return (reference, floating, update) => { + const cleanupA = a(reference, floating, update); + const cleanupB = b(reference, floating, update); + return () => { + cleanupA?.(); + cleanupB?.(); + }; + }; +} + export const GenericPopover = ( props: FloatingUIOptions & { reference?: GenericPopoverReference; children: ReactNode; }, ) => { + const { + whileElementsMounted: _whileElementsMounted, + ...restFloatingOptions + } = props.useFloatingOptions ?? {}; + const { refs, floatingStyles, context } = useFloating({ - whileElementsMounted: autoUpdate, - ...props.useFloatingOptions, + whileElementsMounted: mergeWhileElementsMounted( + autoUpdate, + props.useFloatingOptions?.whileElementsMounted, + ), + ...restFloatingOptions, }); const { isMounted, styles } = useTransitionStyles( diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 7237239b59..a845e31afc 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,6 +1,8 @@ import { SideMenuExtension } from "@blocknote/core/extensions"; -import { FC, useMemo } from "react"; +import { autoUpdate, ReferenceElement } from "@floating-ui/react"; +import { FC, useCallback, 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 +13,7 @@ export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; }) => { + const editor = useBlockNoteEditor(); const state = useExtensionState(SideMenuExtension, { selector: (state) => { return state !== undefined @@ -24,12 +27,43 @@ export const SideMenuController = (props: { const { show, block } = state || {}; + // Hides the side menu on ancestor scroll so it doesn't overflow outside + // the editor's scroll container. + const whileElementsMounted = useCallback( + ( + reference: ReferenceElement, + floating: HTMLElement, + _update: () => void, + ) => { + let initialized = false; + return autoUpdate( + reference, + floating, + () => { + if (!initialized) { + initialized = true; + return; + } + editor.getExtension(SideMenuExtension)?.hideMenuIfNotFrozen(); + }, + { + ancestorScroll: true, + ancestorResize: false, + elementResize: false, + layoutShift: false, + }, + ); + }, + [editor], + ); + const floatingUIOptions = useMemo( () => ({ ...props.floatingUIOptions, useFloatingOptions: { open: show, placement: "left-start", + whileElementsMounted, ...props.floatingUIOptions?.useFloatingOptions, }, useDismissProps: { @@ -47,7 +81,7 @@ export const SideMenuController = (props: { ...props.floatingUIOptions?.elementProps, }, }), - [props.floatingUIOptions, show], + [props.floatingUIOptions, show, whileElementsMounted], ); const Component = props.sideMenu || SideMenu; From de5f4c6964d2c473e54343f70f0790de844f3b3d Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 7 Apr 2026 14:17:41 +0200 Subject: [PATCH 2/3] comments and check --- packages/core/src/extensions/SideMenu/SideMenu.ts | 2 +- packages/react/src/components/SideMenu/SideMenuController.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index 9323d99be9..d9a3191c49 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -791,7 +791,7 @@ export const SideMenuExtension = createExtension(({ editor }) => { * interfering with open submenus. */ hideMenuIfNotFrozen() { - if (!view!.menuFrozen) { + if (!view!.menuFrozen && view!.state!.show) { view!.state!.show = false; view!.emitUpdate(view!.state!); } diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index a845e31afc..37022b4d16 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -41,6 +41,8 @@ export const SideMenuController = (props: { floating, () => { if (!initialized) { + // autoUpdate calls this function once when the floating element is mounted + // we don't want to hide the menu in that case initialized = true; return; } From d63e8f73420ed3761b0d12112f42d16ba0ae49b5 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 7 Apr 2026 15:54:29 +0200 Subject: [PATCH 3/3] fix: hide table handles on scroll Apply the same scroll-hide pattern to table handles. Co-Authored-By: Claude Opus 4.6 --- .../extensions/TableHandles/TableHandles.ts | 14 +++++++ .../TableHandles/TableHandlesController.tsx | 41 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 30637742d5..7f3cf774b5 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -908,6 +908,20 @@ export const TableHandlesExtension = createExtension(({ editor }) => { view!.menuFrozen = false; }, + /** + * Hides the table handles unless they are currently frozen (e.g. a + * handle menu is open). Used to dismiss the handles on scroll without + * interfering with open submenus. + */ + hideHandlesIfNotFrozen() { + if (!view!.menuFrozen && view!.state?.show) { + view!.state.show = false; + view!.state.showAddOrRemoveRowsButton = false; + view!.state.showAddOrRemoveColumnsButton = false; + view!.emitUpdate(); + } + }, + getCellsAtRowHandle( block: BlockFromConfigNoChildren, relativeRowIndex: RelativeCellIndices["row"], diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index d898991c64..e88a605f3b 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -7,9 +7,9 @@ import { StyleSchema, } from "@blocknote/core"; import { TableHandlesExtension } from "@blocknote/core/extensions"; -import { FC, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; -import { offset, size } from "@floating-ui/react"; +import { autoUpdate, offset, ReferenceElement, size } from "@floating-ui/react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; @@ -137,6 +137,36 @@ export const TableHandlesController = < return references; }, [editor, state]); + // Hides the table handles on ancestor scroll so they don't overflow + // outside the editor's scroll container. + const whileElementsMounted = useCallback( + ( + reference: ReferenceElement, + floating: HTMLElement, + _update: () => void, + ) => { + let initialized = false; + return autoUpdate( + reference, + floating, + () => { + if (!initialized) { + initialized = true; + return; + } + editor.getExtension(TableHandlesExtension)?.hideHandlesIfNotFrozen(); + }, + { + ancestorScroll: true, + ancestorResize: false, + elementResize: false, + layoutShift: false, + }, + ); + }, + [editor], + ); + const floatingUIOptions = useMemo< | { rowTableHandle: FloatingUIOptions; @@ -158,6 +188,7 @@ export const TableHandlesController = < (!onlyShownElement || onlyShownElement === "rowTableHandle"), placement: "left", middleware: [offset(-10)], + whileElementsMounted, }, focusManagerProps: { disabled: true, @@ -177,6 +208,7 @@ export const TableHandlesController = < onlyShownElement === "columnTableHandle"), placement: "top", middleware: [offset(-12)], + whileElementsMounted, }, focusManagerProps: { disabled: true, @@ -196,6 +228,7 @@ export const TableHandlesController = < (!onlyShownElement || onlyShownElement === "tableCellHandle"), placement: "top-end", middleware: [offset({ mainAxis: -15, crossAxis: -1 })], + whileElementsMounted, }, focusManagerProps: { disabled: true, @@ -214,6 +247,7 @@ export const TableHandlesController = < (!onlyShownElement || onlyShownElement === "extendRowsButton"), placement: "bottom", + whileElementsMounted, middleware: [ size({ apply({ rects, elements }) { @@ -241,6 +275,7 @@ export const TableHandlesController = < (!onlyShownElement || onlyShownElement === "extendColumnsButton"), placement: "right", + whileElementsMounted, middleware: [ size({ apply({ rects, elements }) { @@ -262,7 +297,7 @@ export const TableHandlesController = < }, } : undefined, - [onlyShownElement, state], + [onlyShownElement, state, whileElementsMounted], ); if (!state) {