Skip to content

Commit 98377cb

Browse files
committed
refactor(rich-md-editor): freeze the formatting toolbar on scroll and extract the shared toolbar button
1 parent ced099a commit 98377cb

2 files changed

Lines changed: 73 additions & 51 deletions

File tree

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

Lines changed: 23 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,53 +15,12 @@ import {
1515
List,
1616
ListChecks,
1717
ListOrdered,
18-
type LucideIcon,
1918
Strikethrough,
2019
TextQuote,
2120
Unlink,
2221
} from 'lucide-react'
23-
import { Tooltip } from '@/components/emcn'
24-
import { cn } from '@/lib/core/utils/cn'
2522
import { normalizeLinkHref } from '../markdown-fidelity'
26-
27-
interface ToolbarButtonProps {
28-
icon: LucideIcon
29-
label: string
30-
shortcut?: string
31-
isActive: boolean
32-
onClick: () => void
33-
}
34-
35-
function ToolbarButton({ icon: Icon, label, shortcut, isActive, onClick }: ToolbarButtonProps) {
36-
return (
37-
<Tooltip.Root>
38-
<Tooltip.Trigger asChild>
39-
<button
40-
type='button'
41-
aria-label={label}
42-
aria-pressed={isActive}
43-
onMouseDown={(event) => event.preventDefault()}
44-
onClick={onClick}
45-
className={cn(
46-
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
47-
isActive
48-
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
49-
: 'hover-hover:bg-[var(--surface-hover)]'
50-
)}
51-
>
52-
<Icon />
53-
</button>
54-
</Tooltip.Trigger>
55-
<Tooltip.Content>
56-
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
57-
</Tooltip.Content>
58-
</Tooltip.Root>
59-
)
60-
}
61-
62-
function ToolbarDivider() {
63-
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
64-
}
23+
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
6524

6625
/**
6726
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
@@ -212,18 +171,31 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
212171
setLinkValue(null)
213172
}
214173

215-
// The default whole-selection anchor pushes the toolbar off-screen when the selection is taller than
216-
// the viewport (e.g. select-all in a long doc). There, anchor to the selection's top edge clamped
217-
// into the viewport so the bar settles at the top of the view; `null` keeps the default otherwise.
174+
// Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
175+
// every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
176+
// the moving text — matching Linear. A new selection recomputes it. A selection taller than the
177+
// viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
178+
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
218179
const resolveAnchor = useCallback(() => {
219180
const { view, state } = editor
220181
if (!view.dom.isConnected) return null
221-
const viewport = scrollContainerRef.current?.getBoundingClientRect()
222-
if (!viewport) return null
223-
const selection = posToDOMRect(view, state.selection.from, state.selection.to)
224-
if (selection.height <= viewport.height) return null
225-
const top = Math.min(Math.max(selection.top, viewport.top), viewport.bottom)
226-
const rect = new DOMRect(selection.left, top, selection.width, 0)
182+
const { from, to } = state.selection
183+
const key = `${from}:${to}`
184+
if (anchorCacheRef.current?.key !== key) {
185+
const selection = posToDOMRect(view, from, to)
186+
const viewport = scrollContainerRef.current?.getBoundingClientRect()
187+
const rect =
188+
viewport && selection.height > viewport.height
189+
? new DOMRect(
190+
selection.left,
191+
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
192+
selection.width,
193+
0
194+
)
195+
: selection
196+
anchorCacheRef.current = { key, rect }
197+
}
198+
const { rect } = anchorCacheRef.current
227199
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
228200
}, [editor, scrollContainerRef])
229201

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { LucideIcon } from 'lucide-react'
2+
import { Tooltip } from '@/components/emcn'
3+
import { cn } from '@/lib/core/utils/cn'
4+
5+
interface ToolbarButtonProps {
6+
icon: LucideIcon
7+
label: string
8+
shortcut?: string
9+
isActive?: boolean
10+
onClick: () => void
11+
}
12+
13+
/** A single icon button for the editor's floating toolbars (bubble menu, link hover card). */
14+
export function ToolbarButton({
15+
icon: Icon,
16+
label,
17+
shortcut,
18+
isActive = false,
19+
onClick,
20+
}: ToolbarButtonProps) {
21+
return (
22+
<Tooltip.Root>
23+
<Tooltip.Trigger asChild>
24+
<button
25+
type='button'
26+
aria-label={label}
27+
aria-pressed={isActive}
28+
onMouseDown={(event) => event.preventDefault()}
29+
onClick={onClick}
30+
className={cn(
31+
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
32+
isActive
33+
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
34+
: 'hover-hover:bg-[var(--surface-hover)]'
35+
)}
36+
>
37+
<Icon />
38+
</button>
39+
</Tooltip.Trigger>
40+
<Tooltip.Content>
41+
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
42+
</Tooltip.Content>
43+
</Tooltip.Root>
44+
)
45+
}
46+
47+
/** Thin vertical separator between groups of {@link ToolbarButton}s. */
48+
export function ToolbarDivider() {
49+
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
50+
}

0 commit comments

Comments
 (0)