1- import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
1+ import { useCallback , useEffect , useRef , useState } from 'react'
22import { posToDOMRect } from '@tiptap/core'
33import { PluginKey } from '@tiptap/pm/state'
44import type { Editor } from '@tiptap/react'
@@ -32,12 +32,21 @@ function hasFormattableSelection(editor: Editor, from: number, to: number): bool
3232 return editor . state . doc . textBetween ( from , to , ' ' ) . trim ( ) . length > 0
3333}
3434
35- // Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for
36- // a selection it stays put while the document scrolls instead of tracking the text — matching Linear.
35+ /**
36+ * Reveals the bubble menu for the current selection. Both calls are required and must stay in order:
37+ * `show` alone leaves the bar visible but unpositioned (its internal `updatePosition` no-ops until the
38+ * menu is shown), so the follow-up `updatePosition` anchors it. Both are step-free transactions, so
39+ * neither marks the document dirty.
40+ */
41+ function revealBubbleMenu ( editor : Editor , key : PluginKey ) : void {
42+ editor . commands . setMeta ( key , 'show' )
43+ editor . commands . setMeta ( key , 'updatePosition' )
44+ }
45+
46+ /** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
3747const FLOATING_OPTIONS = { strategy : 'fixed' } as const
3848
39- // Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent
40- // the fixed-positioned toolbar and shift it off the selection.
49+ /** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
4150const APPEND_TO_BODY = ( ) => document . body
4251
4352interface EditorBubbleMenuProps {
@@ -58,8 +67,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
5867 const linkRangeRef = useRef < { from : number ; to : number } | null > ( null )
5968 const isEditingLink = linkValue !== null
6069
61- // Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
62- const bubbleMenuKey = useMemo ( ( ) => new PluginKey ( 'markdownBubbleMenu' ) , [ ] )
70+ const [ bubbleMenuKey ] = useState ( ( ) => new PluginKey ( 'markdownBubbleMenu' ) )
6371 const isPointerDownRef = useRef ( false )
6472
6573 const active = useEditorState ( {
@@ -94,8 +102,12 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
94102 }
95103 } , [ editor ] )
96104
97- // Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
98- // while the pointer is down. Keyboard selection has no pointer, so it still shows live.
105+ /**
106+ * Linear-style reveal: the toolbar stays hidden while the pointer is down (the drag gate in
107+ * `shouldShow`) and surfaces on release. `mouseup`/`blur` listen on `window` so a release outside
108+ * the editor — or off-screen, where no `mouseup` fires — still clears the drag flag; otherwise it
109+ * could wedge `true` and suppress the toolbar for later keyboard selections.
110+ */
99111 useEffect ( ( ) => {
100112 const dom = editor . view . dom
101113 const onPointerDown = ( ) => {
@@ -105,14 +117,8 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
105117 if ( ! isPointerDownRef . current || editor . isDestroyed ) return
106118 isPointerDownRef . current = false
107119 const { from, to } = editor . state . selection
108- if ( hasFormattableSelection ( editor , from , to ) ) {
109- // `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
110- // so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
111- editor . commands . setMeta ( bubbleMenuKey , 'show' )
112- editor . commands . setMeta ( bubbleMenuKey , 'updatePosition' )
113- }
120+ if ( hasFormattableSelection ( editor , from , to ) ) revealBubbleMenu ( editor , bubbleMenuKey )
114121 }
115- // A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
116122 const onWindowBlur = ( ) => {
117123 isPointerDownRef . current = false
118124 }
@@ -175,10 +181,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
175181 setLinkValue ( null )
176182 }
177183
178- // Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
179- // every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
180- // the moving text — matching Linear. A new selection recomputes it. A selection taller than the
181- // viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
182184 const anchorCacheRef = useRef < { key : string ; rect : DOMRect } | null > ( null )
183185 const resolveAnchor = useCallback ( ( ) => {
184186 const { view, state } = editor
@@ -218,7 +220,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
218220 // can't be applied to a doc that must not mutate.
219221 if ( ! e . isEditable ) return false
220222 if ( isEditingLink ) return true
221- // Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
222223 if ( isPointerDownRef . current ) return false
223224 return hasFormattableSelection ( e , from , to )
224225 } }
0 commit comments