@@ -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'
2522import { 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
0 commit comments