Skip to content

Commit 241933e

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 6c6d8a5 commit 241933e

3 files changed

Lines changed: 63 additions & 60 deletions

File tree

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

Lines changed: 16 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import {
33
Blimp,
44
Bold,
@@ -16,11 +16,15 @@ 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'
23+
import {
24+
BUBBLE_MENU_APPEND_TO,
25+
bubbleMenuFloatingOptions,
26+
selectionVirtualElement,
27+
} from './floating-anchor'
2428
import { applyLink, LinkUrlInput } from './link-editing'
2529
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
2630

@@ -45,15 +49,9 @@ function revealBubbleMenu(editor: Editor, key: PluginKey): void {
4549
editor.commands.setMeta(key, 'updatePosition')
4650
}
4751

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-
5452
interface EditorBubbleMenuProps {
5553
editor: Editor
56-
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
54+
/** The editor's scrollable viewport, so the toolbar repositions with the selection as the pane scrolls. */
5755
scrollContainerRef: React.RefObject<HTMLDivElement | null>
5856
/** Adds the current selection to Chat as a reference. Omit to hide the action. */
5957
onAddToChat?: () => void
@@ -185,37 +183,21 @@ export function EditorBubbleMenu({
185183
setLinkValue(null)
186184
}
187185

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])
186+
const [scrollTarget, setScrollTarget] = useState<HTMLElement | null>(null)
187+
useEffect(() => {
188+
setScrollTarget(scrollContainerRef.current)
189+
}, [scrollContainerRef])
190+
191+
const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor])
192+
const options = useMemo(() => bubbleMenuFloatingOptions(scrollTarget), [scrollTarget])
211193

212194
return (
213195
<BubbleMenu
214196
editor={editor}
215197
pluginKey={bubbleMenuKey}
216198
getReferencedVirtualElement={resolveAnchor}
217-
options={FLOATING_OPTIONS}
218-
appendTo={APPEND_TO_BODY}
199+
options={options}
200+
appendTo={BUBBLE_MENU_APPEND_TO}
219201
role='toolbar'
220202
aria-label='Text formatting'
221203
updateDelay={0}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { posToDOMRect } from '@tiptap/core'
2+
import type { Editor } from '@tiptap/react'
3+
4+
/**
5+
* Virtual anchor tracking the current selection's on-screen rect, for a Floating-UI-positioned bubble
6+
* menu. Recomputed on every call and never cached by selection: the same selection lands at a new screen
7+
* position purely from scrolling, and TipTap re-invokes this to reposition, so a cached rect would freeze
8+
* the menu in place on scroll.
9+
*/
10+
export function selectionVirtualElement(editor: Editor) {
11+
const { view, state } = editor
12+
if (!view.dom.isConnected) return null
13+
const { from, to } = state.selection
14+
const rect = posToDOMRect(view, from, to)
15+
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
16+
}
17+
18+
/**
19+
* Floating options shared by the editor's bubble menus. `scrollTarget` is load-bearing: TipTap's
20+
* reposition listener defaults to `window`, but the editor scrolls inside an inner overflow container, so
21+
* without pointing it at that element the menu freezes at its last viewport position on scroll instead of
22+
* following the selection. `hide` lets Floating UI hide the menu once its anchored selection scrolls out
23+
* of view. `fixed` + rendering into `<body>` ({@link BUBBLE_MENU_APPEND_TO}) keeps a clipping or
24+
* transformed ancestor from shifting it.
25+
*/
26+
export function bubbleMenuFloatingOptions(scrollTarget: HTMLElement | null) {
27+
return { strategy: 'fixed' as const, scrollTarget: scrollTarget ?? undefined, hide: true }
28+
}
29+
30+
/** Render the menu into `<body>` so a clipping/transformed ancestor can't reparent or shift it. */
31+
export const BUBBLE_MENU_APPEND_TO = () => document.body

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

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
22
import {
33
ArrowDown,
44
ArrowLeft,
@@ -9,22 +9,20 @@ 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'
16+
import {
17+
BUBBLE_MENU_APPEND_TO,
18+
bubbleMenuFloatingOptions,
19+
selectionVirtualElement,
20+
} from './floating-anchor'
1721
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
1822

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
24-
2523
interface TableBubbleMenuProps {
2624
editor: Editor
27-
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
25+
/** The editor's scrollable viewport, so the toolbar repositions with the cell as the pane scrolls. */
2826
scrollContainerRef: React.RefObject<HTMLDivElement | null>
2927
}
3028

@@ -36,6 +34,7 @@ interface TableBubbleMenuProps {
3634
*/
3735
export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) {
3836
const [menuKey] = useState(() => new PluginKey('markdownTableMenu'))
37+
const [scrollTarget, setScrollTarget] = useState<HTMLElement | null>(null)
3938

4039
const active = useEditorState({
4140
editor,
@@ -44,29 +43,20 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP
4443
}),
4544
})
4645

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])
46+
useEffect(() => {
47+
setScrollTarget(scrollContainerRef.current)
48+
}, [scrollContainerRef])
49+
50+
const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor])
51+
const options = useMemo(() => bubbleMenuFloatingOptions(scrollTarget), [scrollTarget])
6252

6353
return (
6454
<BubbleMenu
6555
editor={editor}
6656
pluginKey={menuKey}
6757
getReferencedVirtualElement={resolveAnchor}
68-
options={FLOATING_OPTIONS}
69-
appendTo={APPEND_TO_BODY}
58+
options={options}
59+
appendTo={BUBBLE_MENU_APPEND_TO}
7060
role='toolbar'
7161
aria-label='Table editing'
7262
updateDelay={0}

0 commit comments

Comments
 (0)