Skip to content

Commit eb50d71

Browse files
committed
fix(files): anchor the editor bubble menus to the selection on scroll
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.
1 parent 4f5ad20 commit eb50d71

3 files changed

Lines changed: 71 additions & 60 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
22
import {
33
Blimp,
44
Bold,
@@ -16,13 +16,13 @@ import {
1616
TextQuote,
1717
Unlink,
1818
} from '@sim/emcn/icons'
19-
import { posToDOMRect } from '@tiptap/core'
2019
import { PluginKey } from '@tiptap/pm/state'
2120
import type { Editor } from '@tiptap/react'
2221
import { useEditorState } from '@tiptap/react'
2322
import { BubbleMenu } from '@tiptap/react/menus'
2423
import { applyLink, LinkUrlInput } from './link-editing'
2524
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
25+
import { useBubbleMenuFloating } from './use-bubble-menu-floating'
2626

2727
/**
2828
* 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 {
4545
editor.commands.setMeta(key, 'updatePosition')
4646
}
4747

48-
/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
49-
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
50-
51-
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
52-
const APPEND_TO_BODY = () => document.body
53-
5448
interface EditorBubbleMenuProps {
5549
editor: Editor
56-
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
50+
/** The editor's scrollable viewport, so the toolbar repositions with the selection as the pane scrolls. */
5751
scrollContainerRef: React.RefObject<HTMLDivElement | null>
5852
/** Adds the current selection to Chat as a reference. Omit to hide the action. */
5953
onAddToChat?: () => void
@@ -185,37 +179,15 @@ export function EditorBubbleMenu({
185179
setLinkValue(null)
186180
}
187181

188-
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
189-
const resolveAnchor = useCallback(() => {
190-
const { view, state } = editor
191-
if (!view.dom.isConnected) return null
192-
const { from, to } = state.selection
193-
const key = `${from}:${to}`
194-
if (anchorCacheRef.current?.key !== key) {
195-
const selection = posToDOMRect(view, from, to)
196-
const viewport = scrollContainerRef.current?.getBoundingClientRect()
197-
const rect =
198-
viewport && selection.height > viewport.height
199-
? new DOMRect(
200-
selection.left,
201-
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
202-
selection.width,
203-
0
204-
)
205-
: selection
206-
anchorCacheRef.current = { key, rect }
207-
}
208-
const { rect } = anchorCacheRef.current
209-
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
210-
}, [editor, scrollContainerRef])
182+
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
211183

212184
return (
213185
<BubbleMenu
214186
editor={editor}
215187
pluginKey={bubbleMenuKey}
216188
getReferencedVirtualElement={resolveAnchor}
217-
options={FLOATING_OPTIONS}
218-
appendTo={APPEND_TO_BODY}
189+
options={options}
190+
appendTo={appendTo}
219191
role='toolbar'
220192
aria-label='Text formatting'
221193
updateDelay={0}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from 'react'
1+
import { useState } from 'react'
22
import {
33
ArrowDown,
44
ArrowLeft,
@@ -9,22 +9,16 @@ import {
99
Table as TableIcon,
1010
Trash,
1111
} from '@sim/emcn/icons'
12-
import { posToDOMRect } from '@tiptap/core'
1312
import { PluginKey } from '@tiptap/pm/state'
1413
import type { Editor } from '@tiptap/react'
1514
import { useEditorState } from '@tiptap/react'
1615
import { BubbleMenu } from '@tiptap/react/menus'
1716
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
18-
19-
/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */
20-
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
21-
22-
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
23-
const APPEND_TO_BODY = () => document.body
17+
import { useBubbleMenuFloating } from './use-bubble-menu-floating'
2418

2519
interface TableBubbleMenuProps {
2620
editor: Editor
27-
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
21+
/** The editor's scrollable viewport, so the toolbar repositions with the cell as the pane scrolls. */
2822
scrollContainerRef: React.RefObject<HTMLDivElement | null>
2923
}
3024

@@ -44,29 +38,15 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP
4438
}),
4539
})
4640

47-
// Recomputed on every call (not cached by selection key) — the same table cell can land at a
48-
// different screen position purely from scrolling with no selection change, and Floating UI's
49-
// `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time.
50-
const resolveAnchor = useCallback(() => {
51-
const { view, state } = editor
52-
if (!view.dom.isConnected) return null
53-
const { from, to } = state.selection
54-
const selection = posToDOMRect(view, from, to)
55-
const viewport = scrollContainerRef.current?.getBoundingClientRect()
56-
const rect =
57-
viewport && selection.top < viewport.top
58-
? new DOMRect(selection.left, viewport.top, selection.width, 0)
59-
: selection
60-
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
61-
}, [editor, scrollContainerRef])
41+
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
6242

6343
return (
6444
<BubbleMenu
6545
editor={editor}
6646
pluginKey={menuKey}
6747
getReferencedVirtualElement={resolveAnchor}
68-
options={FLOATING_OPTIONS}
69-
appendTo={APPEND_TO_BODY}
48+
options={options}
49+
appendTo={appendTo}
7050
role='toolbar'
7151
aria-label='Table editing'
7252
updateDelay={0}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
import { posToDOMRect } from '@tiptap/core'
3+
import type { Editor } from '@tiptap/react'
4+
5+
/**
6+
* A Floating UI virtual element anchored to the current selection. The rect is recomputed on every
7+
* call rather than cached by selection: the same `from`/`to` maps to a different screen position as
8+
* the pane scrolls, so a cached rect would freeze the toolbar in place. `contextElement` is the
9+
* editor DOM so Floating UI resolves clipping (and the `hide` middleware) against the editor's
10+
* scroll container, hiding the toolbar once the selection leaves the pane — not just the viewport.
11+
* Returns `null` before the editor mounts so the caller skips positioning.
12+
*/
13+
function selectionVirtualElement(editor: Editor) {
14+
const { view, state } = editor
15+
if (!view.dom.isConnected) return null
16+
const { from, to } = state.selection
17+
const rect = posToDOMRect(view, from, to)
18+
return {
19+
getBoundingClientRect: () => rect,
20+
getClientRects: () => [rect],
21+
contextElement: view.dom,
22+
}
23+
}
24+
25+
/**
26+
* BubbleMenu Floating UI options. `scrollTarget` is load-bearing: TipTap's reposition listener
27+
* defaults to `window`, but the editor scrolls inside an inner overflow container that never fires a
28+
* window scroll — passing the container makes the toolbar track the selection as the pane scrolls.
29+
* `hide` removes the toolbar once the selection scrolls out of view; `fixed` positions it relative
30+
* to the viewport so an overflow ancestor can't clip it.
31+
*/
32+
function bubbleMenuFloatingOptions(scrollTarget: HTMLElement | null) {
33+
return { strategy: 'fixed' as const, scrollTarget: scrollTarget ?? undefined, hide: true }
34+
}
35+
36+
/** Renders the toolbar into `<body>` so a clipping or transformed ancestor can't reparent or shift it. */
37+
const appendTo = () => document.body
38+
39+
/**
40+
* Wires a BubbleMenu's Floating UI concerns — the selection anchor, positioning options, and the
41+
* body portal — so the text and table toolbars share one source of truth and can't drift. Captures
42+
* the parent-owned scroll container into state so `options` gains a new identity once the element
43+
* resolves, which is what makes the BubbleMenu bind its scroll listener to the pane.
44+
*/
45+
export function useBubbleMenuFloating(
46+
editor: Editor,
47+
scrollContainerRef: React.RefObject<HTMLDivElement | null>
48+
) {
49+
const [scrollTarget, setScrollTarget] = useState<HTMLElement | null>(null)
50+
51+
useEffect(() => {
52+
setScrollTarget(scrollContainerRef.current)
53+
}, [scrollContainerRef])
54+
55+
const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor])
56+
const options = useMemo(() => bubbleMenuFloatingOptions(scrollTarget), [scrollTarget])
57+
58+
return { resolveAnchor, options, appendTo }
59+
}

0 commit comments

Comments
 (0)