1- import { memo , useRef , useState , useEffect , useCallback , useLayoutEffect } from 'react' ;
1+ import { memo , useRef , useEffect , useCallback , useLayoutEffect } from 'react' ;
22import { createPortal } from 'react-dom' ;
33import { TextQuote } from 'lucide-react' ;
44import { useSetRecoilState } from 'recoil' ;
@@ -244,7 +244,7 @@ const resolveTop = (anchor: Anchor, height: number, preferBelow: boolean): numbe
244244 */
245245function QuoteButton ( { conversationId } : { conversationId : string } ) {
246246 const localize = useLocalize ( ) ;
247- const [ selection , setSelection ] = useState < SelectionState | null > ( null ) ;
247+ const selectionRef = useRef < SelectionState | null > ( null ) ;
248248 const buttonRef = useRef < HTMLButtonElement > ( null ) ;
249249 const rangeRef = useRef < Range | null > ( null ) ;
250250 const clippersRef = useRef < HTMLElement [ ] > ( [ ] ) ;
@@ -255,6 +255,32 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
255255 const hideRef = useRef < ( ) => void > ( ( ) => undefined ) ;
256256 const setQuotes = useSetRecoilState ( store . pendingQuotesByConvoId ( conversationId ) ) ;
257257
258+ /** Selection and scroll events must not enter React's commit/selection-restoration
259+ * path. Keep the portal mounted and update only its transient presentation. */
260+ const presentSelection = useCallback ( ( next : SelectionState | null ) => {
261+ selectionRef . current = next ;
262+ const button = buttonRef . current ;
263+ if ( ! button ) {
264+ return ;
265+ }
266+ if ( ! next ) {
267+ button . style . display = 'none' ;
268+ return ;
269+ }
270+ const touch = String ( next . viaTouch ) ;
271+ if ( button . dataset . touch !== touch ) {
272+ button . dataset . touch = touch ;
273+ }
274+ if ( button . style . display === 'none' ) {
275+ button . style . display = 'inline-flex' ;
276+ }
277+ const { width, height } = button . getBoundingClientRect ( ) ;
278+ const maxLeft = Math . max ( EDGE_MARGIN , window . innerWidth - width - EDGE_MARGIN ) ;
279+ const left = Math . min ( Math . max ( anchorCenterX ( next . anchor ) - width / 2 , EDGE_MARGIN ) , maxLeft ) ;
280+ button . style . top = `${ resolveTop ( next . anchor , height , next . viaTouch ) } px` ;
281+ button . style . left = `${ left } px` ;
282+ } , [ ] ) ;
283+
258284 useEffect ( ( ) => {
259285 let settleTimer : ReturnType < typeof setTimeout > | undefined ;
260286 let frame = 0 ;
@@ -279,7 +305,7 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
279305 }
280306 rangeRef . current = null ;
281307 clippersRef . current = [ ] ;
282- setSelection ( null ) ;
308+ presentSelection ( null ) ;
283309 } ;
284310
285311 const hide = ( ) => {
@@ -299,18 +325,18 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
299325 hide ( ) ;
300326 return ;
301327 }
302- rangeRef . current = reading . range ;
328+ /** Native handle drags can mutate the Selection's Range in place. */
329+ rangeRef . current = reading . range . cloneRange ( ) ;
303330 clippersRef . current = reading . clippers ;
304- /** Reuse the previous state object when nothing moved so a redundant
305- * settle pass costs no render. */
306- setSelection ( ( prev ) =>
307- prev &&
308- prev . text === reading . text &&
309- prev . viaTouch === touch &&
310- sameAnchor ( prev . anchor , reading . anchor )
311- ? prev
312- : { text : reading . text , anchor : reading . anchor , viaTouch : touch } ,
313- ) ;
331+ const previous = selectionRef . current ;
332+ if (
333+ ! previous ||
334+ previous . text !== reading . text ||
335+ previous . viaTouch !== touch ||
336+ ! sameAnchor ( previous . anchor , reading . anchor )
337+ ) {
338+ presentSelection ( { text : reading . text , anchor : reading . anchor , viaTouch : touch } ) ;
339+ }
314340 } ;
315341
316342 const handlePointerDown = ( event : PointerEvent ) => {
@@ -382,9 +408,10 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
382408 hide ( ) ;
383409 return ;
384410 }
385- setSelection ( ( prev ) =>
386- prev && ! sameAnchor ( prev . anchor , anchor ) ? { ...prev , anchor } : prev ,
387- ) ;
411+ const previous = selectionRef . current ;
412+ if ( previous && ! sameAnchor ( previous . anchor , anchor ) ) {
413+ presentSelection ( { ...previous , anchor } ) ;
414+ }
388415 } ;
389416
390417 const scheduleReanchor = ( ) => {
@@ -422,29 +449,17 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
422449 document . removeEventListener ( 'scroll' , scheduleReanchor , true ) ;
423450 window . removeEventListener ( 'resize' , scheduleReanchor ) ;
424451 } ;
425- } , [ ] ) ;
452+ } , [ presentSelection ] ) ;
426453
427- /** Clamp using the button's real size so it never lands off-screen. Apply the
428- * measured layout directly before paint: feeding it back through state adds
429- * a synchronous render to every selection update and can exhaust React's
430- * nested-update limit while the browser is still changing the selection. */
454+ /** A parent render (for example a locale change) can change the button size. */
431455 useLayoutEffect ( ( ) => {
432- const button = buttonRef . current ;
433- if ( ! selection || ! button ) {
434- return ;
435- }
436- const { width, height } = button . getBoundingClientRect ( ) ;
437- const maxLeft = Math . max ( EDGE_MARGIN , window . innerWidth - width - EDGE_MARGIN ) ;
438- const left = Math . min (
439- Math . max ( anchorCenterX ( selection . anchor ) - width / 2 , EDGE_MARGIN ) ,
440- maxLeft ,
441- ) ;
442- const top = resolveTop ( selection . anchor , height , selection . viaTouch ) ;
456+ presentSelection ( selectionRef . current ) ;
457+ } ) ;
443458
444- button . style . top = ` ${ top } px` ;
445- button . style . left = ` ${ left } px` ;
446- button . style . visibility = 'visible' ;
447- } , [ selection ] ) ;
459+ useLayoutEffect ( ( ) => {
460+ pressedTextRef . current = null ;
461+ hideRef . current ( ) ;
462+ } , [ conversationId ] ) ;
448463
449464 const commitQuote = useCallback (
450465 ( text : string ) => {
@@ -454,18 +469,19 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
454469 rangeRef . current = null ;
455470 clippersRef . current = [ ] ;
456471 pressedTextRef . current = null ;
457- setSelection ( null ) ;
472+ presentSelection ( null ) ;
458473 window . getSelection ( ) ?. removeAllRanges ( ) ;
459474 document . getElementById ( mainTextareaId ) ?. focus ( ) ;
460475 } ,
461- [ setQuotes ] ,
476+ [ setQuotes , presentSelection ] ,
462477 ) ;
463478
464479 const addQuote = useCallback ( ( ) => {
480+ const selection = selectionRef . current ;
465481 if ( selection ) {
466482 commitQuote ( selection . text ) ;
467483 }
468- } , [ selection , commitQuote ] ) ;
484+ } , [ commitQuote ] ) ;
469485
470486 /**
471487 * End a touch press that did not commit. While a press is in flight the
@@ -482,16 +498,12 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
482498 }
483499 } , [ ] ) ;
484500
485- if ( ! selection ) {
486- return null ;
487- }
488-
489501 return createPortal (
490502 < button
491503 ref = { buttonRef }
492504 type = "button"
493505 /** A tap is also the gesture that dismisses a selection, so `click` can
494- * never be relied on here: the button is already unmounted by the time it
506+ * never be relied on here: the button can be hidden by the time it
495507 * would fire. The excerpt is captured on the press and committed on the
496508 * release instead, which keeps a button's normal escape hatches — drag
497509 * off the button, or have the gesture stolen by a scroll, and nothing is
@@ -501,7 +513,7 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
501513 return ;
502514 }
503515 event . preventDefault ( ) ;
504- pressedTextRef . current = selection . text ;
516+ pressedTextRef . current = selectionRef . current ?. text ?? null ;
505517 try {
506518 event . currentTarget . setPointerCapture ( event . pointerId ) ;
507519 } catch {
@@ -539,13 +551,12 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
539551 style = { {
540552 top : 0 ,
541553 left : 0 ,
542- /** Hidden until measured so it never flashes at an unclamped position. */
543- visibility : 'hidden' ,
554+ display : 'none' ,
544555 } }
545556 className = { cn (
546557 'fixed z-50 inline-flex items-center gap-1.5 rounded-full border border-border-light bg-surface-secondary text-sm font-medium text-text-primary shadow-lg transition-colors hover:bg-surface-tertiary' ,
547558 /** Comfortable tap target when the selection came from a finger. */
548- selection . viaTouch ? 'min-h-11 px-4 py-2.5' : ' px-3 py-1 .5',
559+ ' px-3 py-1.5 data-[touch=true]:min-h-11 data-[touch=true]: px-4 data-[touch=true]: py-2 .5',
549560 ) }
550561 >
551562 < TextQuote className = "h-4 w-4" aria-hidden = "true" />
0 commit comments