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 } +}