1- import { useEffect , useRef , useState } from 'react'
1+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
2+ import { posToDOMRect } from '@tiptap/core'
3+ import { PluginKey } from '@tiptap/pm/state'
24import type { Editor } from '@tiptap/react'
35import { useEditorState } from '@tiptap/react'
46import { BubbleMenu } from '@tiptap/react/menus'
@@ -61,8 +63,20 @@ function ToolbarDivider() {
6163 return < div className = 'mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
6264}
6365
66+ /**
67+ * Whether the formatting toolbar may show for the given range: the editor is editable, the range
68+ * isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by
69+ * `shouldShow` and the pointer-release reveal so the two can't drift apart.
70+ */
71+ function hasFormattableSelection ( editor : Editor , from : number , to : number ) : boolean {
72+ if ( ! editor . isEditable || editor . isActive ( 'codeBlock' ) ) return false
73+ return editor . state . doc . textBetween ( from , to , ' ' ) . trim ( ) . length > 0
74+ }
75+
6476interface EditorBubbleMenuProps {
6577 editor : Editor
78+ /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
79+ scrollContainerRef : React . RefObject < HTMLDivElement | null >
6680}
6781
6882/**
@@ -71,12 +85,16 @@ interface EditorBubbleMenuProps {
7185 * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
7286 * stays correct without re-rendering the editor on every transaction.
7387 */
74- export function EditorBubbleMenu ( { editor } : EditorBubbleMenuProps ) {
88+ export function EditorBubbleMenu ( { editor, scrollContainerRef } : EditorBubbleMenuProps ) {
7589 const [ linkValue , setLinkValue ] = useState < string | null > ( null )
7690 const linkInputRef = useRef < HTMLInputElement > ( null )
7791 const linkRangeRef = useRef < { from : number ; to : number } | null > ( null )
7892 const isEditingLink = linkValue !== null
7993
94+ // Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
95+ const bubbleMenuKey = useMemo ( ( ) => new PluginKey ( 'markdownBubbleMenu' ) , [ ] )
96+ const isPointerDownRef = useRef ( false )
97+
8098 const active = useEditorState ( {
8199 editor,
82100 selector : ( { editor : e } ) => ( {
@@ -109,6 +127,38 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
109127 }
110128 } , [ editor ] )
111129
130+ // Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
131+ // while the pointer is down. Keyboard selection has no pointer, so it still shows live.
132+ useEffect ( ( ) => {
133+ const dom = editor . view . dom
134+ const onPointerDown = ( ) => {
135+ isPointerDownRef . current = true
136+ }
137+ const onPointerUp = ( ) => {
138+ if ( ! isPointerDownRef . current || editor . isDestroyed ) return
139+ isPointerDownRef . current = false
140+ const { from, to } = editor . state . selection
141+ if ( hasFormattableSelection ( editor , from , to ) ) {
142+ // `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
143+ // so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
144+ editor . commands . setMeta ( bubbleMenuKey , 'show' )
145+ editor . commands . setMeta ( bubbleMenuKey , 'updatePosition' )
146+ }
147+ }
148+ // A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
149+ const onWindowBlur = ( ) => {
150+ isPointerDownRef . current = false
151+ }
152+ dom . addEventListener ( 'mousedown' , onPointerDown )
153+ window . addEventListener ( 'mouseup' , onPointerUp )
154+ window . addEventListener ( 'blur' , onWindowBlur )
155+ return ( ) => {
156+ dom . removeEventListener ( 'mousedown' , onPointerDown )
157+ window . removeEventListener ( 'mouseup' , onPointerUp )
158+ window . removeEventListener ( 'blur' , onWindowBlur )
159+ }
160+ } , [ editor , bubbleMenuKey ] )
161+
112162 const openLinkEditor = ( ) => {
113163 if ( editor . isActive ( 'codeBlock' ) || editor . isActive ( 'code' ) ) return
114164 const { from, to } = editor . state . selection
@@ -158,9 +208,26 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
158208 setLinkValue ( null )
159209 }
160210
211+ // The default whole-selection anchor pushes the toolbar off-screen when the selection is taller than
212+ // the viewport (e.g. select-all in a long doc). There, anchor to the selection's top edge clamped
213+ // into the viewport so the bar settles at the top of the view; `null` keeps the default otherwise.
214+ const resolveAnchor = useCallback ( ( ) => {
215+ const { view, state } = editor
216+ if ( ! view . dom . isConnected ) return null
217+ const viewport = scrollContainerRef . current ?. getBoundingClientRect ( )
218+ if ( ! viewport ) return null
219+ const selection = posToDOMRect ( view , state . selection . from , state . selection . to )
220+ if ( selection . height <= viewport . height ) return null
221+ const top = Math . min ( Math . max ( selection . top , viewport . top ) , viewport . bottom )
222+ const rect = new DOMRect ( selection . left , top , selection . width , 0 )
223+ return { getBoundingClientRect : ( ) => rect , getClientRects : ( ) => [ rect ] }
224+ } , [ editor , scrollContainerRef ] )
225+
161226 return (
162227 < BubbleMenu
163228 editor = { editor }
229+ pluginKey = { bubbleMenuKey }
230+ getReferencedVirtualElement = { resolveAnchor }
164231 role = 'toolbar'
165232 aria-label = 'Text formatting'
166233 updateDelay = { 0 }
@@ -169,8 +236,9 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
169236 // can't be applied to a doc that must not mutate.
170237 if ( ! e . isEditable ) return false
171238 if ( isEditingLink ) return true
172- if ( e . isActive ( 'codeBlock' ) ) return false
173- return e . state . doc . textBetween ( from , to , ' ' ) . trim ( ) . length > 0
239+ // Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
240+ if ( isPointerDownRef . current ) return false
241+ return hasFormattableSelection ( e , from , to )
174242 } }
175243 className = 'fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none'
176244 >
0 commit comments