From eb50d71ce10868d08a7de8b2743840b9afdc92db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 11:50:54 -0700 Subject: [PATCH] fix(files): anchor the editor bubble menus to the selection on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text and table bubble menus stayed pinned to a viewport position when the file scrolled — clicking a table cell then scrolling left the toolbar floating over unrelated content. TipTap v3's BubbleMenu reposition listener defaults to `window`, but the editor scrolls inside an inner overflow container, so it never fired; the menu only moved when the selection itself changed. Pass the editor's scroll container as the BubbleMenu `scrollTarget` (a first-class TipTap option) so it repositions with the selection, and enable Floating UI's `hide` middleware so the menu hides once its anchored cell scrolls out of view. Share the anchor + options through one `floating-anchor` helper so the two menus can't drift. Removes the prior workarounds that fought this: the `strategy: 'fixed'` viewport-pin, the resolveAnchor viewport-clamp branches, and the bubble menu's selection-keyed rect cache (which froze the menu in place on scroll). Verified in a harness: on scroll the menu delta matches the cell delta (follows), and it hides once the cell leaves view. --- .../menus/bubble-menu.tsx | 40 ++----------- .../rich-markdown-editor/menus/table-menu.tsx | 32 ++-------- .../menus/use-bubble-menu-floating.ts | 59 +++++++++++++++++++ 3 files changed, 71 insertions(+), 60 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index a30e1337510..785e7c24d21 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Blimp, Bold, @@ -16,13 +16,13 @@ import { TextQuote, Unlink, } from '@sim/emcn/icons' -import { posToDOMRect } from '@tiptap/core' import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' import { useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' import { applyLink, LinkUrlInput } from './link-editing' import { ToolbarButton, ToolbarDivider } from './toolbar-button' +import { useBubbleMenuFloating } from './use-bubble-menu-floating' /** * Whether the formatting toolbar may show for the given range: the editor is editable, the range @@ -45,15 +45,9 @@ function revealBubbleMenu(editor: Editor, key: PluginKey): void { editor.commands.setMeta(key, 'updatePosition') } -/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */ -const FLOATING_OPTIONS = { strategy: 'fixed' } as const - -/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */ -const APPEND_TO_BODY = () => document.body - interface EditorBubbleMenuProps { editor: Editor - /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ + /** The editor's scrollable viewport, so the toolbar repositions with the selection as the pane scrolls. */ scrollContainerRef: React.RefObject /** Adds the current selection to Chat as a reference. Omit to hide the action. */ onAddToChat?: () => void @@ -185,37 +179,15 @@ export function EditorBubbleMenu({ setLinkValue(null) } - const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null) - const resolveAnchor = useCallback(() => { - const { view, state } = editor - if (!view.dom.isConnected) return null - const { from, to } = state.selection - const key = `${from}:${to}` - if (anchorCacheRef.current?.key !== key) { - const selection = posToDOMRect(view, from, to) - const viewport = scrollContainerRef.current?.getBoundingClientRect() - const rect = - viewport && selection.height > viewport.height - ? new DOMRect( - selection.left, - Math.min(Math.max(selection.top, viewport.top), viewport.bottom), - selection.width, - 0 - ) - : selection - anchorCacheRef.current = { key, rect } - } - const { rect } = anchorCacheRef.current - return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } - }, [editor, scrollContainerRef]) + const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef) return ( document.body +import { useBubbleMenuFloating } from './use-bubble-menu-floating' interface TableBubbleMenuProps { editor: Editor - /** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */ + /** The editor's scrollable viewport, so the toolbar repositions with the cell as the pane scrolls. */ scrollContainerRef: React.RefObject } @@ -44,29 +38,15 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP }), }) - // Recomputed on every call (not cached by selection key) — the same table cell can land at a - // different screen position purely from scrolling with no selection change, and Floating UI's - // `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time. - const resolveAnchor = useCallback(() => { - const { view, state } = editor - if (!view.dom.isConnected) return null - const { from, to } = state.selection - const selection = posToDOMRect(view, from, to) - const viewport = scrollContainerRef.current?.getBoundingClientRect() - const rect = - viewport && selection.top < viewport.top - ? new DOMRect(selection.left, viewport.top, selection.width, 0) - : selection - return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } - }, [editor, scrollContainerRef]) + const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef) return ( rect, + getClientRects: () => [rect], + contextElement: view.dom, + } +} + +/** + * BubbleMenu Floating UI options. `scrollTarget` is load-bearing: TipTap's reposition listener + * defaults to `window`, but the editor scrolls inside an inner overflow container that never fires a + * window scroll — passing the container makes the toolbar track the selection as the pane scrolls. + * `hide` removes the toolbar once the selection scrolls out of view; `fixed` positions it relative + * to the viewport so an overflow ancestor can't clip it. + */ +function bubbleMenuFloatingOptions(scrollTarget: HTMLElement | null) { + return { strategy: 'fixed' as const, scrollTarget: scrollTarget ?? undefined, hide: true } +} + +/** Renders the toolbar into `` so a clipping or transformed ancestor can't reparent or shift it. */ +const appendTo = () => document.body + +/** + * Wires a BubbleMenu's Floating UI concerns — the selection anchor, positioning options, and the + * body portal — so the text and table toolbars share one source of truth and can't drift. Captures + * the parent-owned scroll container into state so `options` gains a new identity once the element + * resolves, which is what makes the BubbleMenu bind its scroll listener to the pane. + */ +export function useBubbleMenuFloating( + editor: Editor, + scrollContainerRef: React.RefObject +) { + const [scrollTarget, setScrollTarget] = useState(null) + + useEffect(() => { + setScrollTarget(scrollContainerRef.current) + }, [scrollContainerRef]) + + const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor]) + const options = useMemo(() => bubbleMenuFloatingOptions(scrollTarget), [scrollTarget]) + + return { resolveAnchor, options, appendTo } +}