@@ -14,7 +14,7 @@ import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/tok
1414import { useArcPaths } from '../hooks/useArcPaths' ;
1515import { usePhraseHoverState } from '../hooks/usePhraseHoverState' ;
1616import MemoizedArcOverlay from './ArcOverlay' ;
17- import { RECENTER_FADE_EASING , RECENTER_FADE_MS } from './recenter-fade' ;
17+ import { RECENTER_FADE_MS , RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade' ;
1818
1919/**
2020 * Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero.
@@ -29,19 +29,6 @@ function clampIndex(index: number, len: number): number {
2929 return Math . max ( 0 , Math . min ( index , len - 1 ) ) ;
3030}
3131
32- /**
33- * CSS easing for the strip opacity fade-in/out animation. Aliased to the shared
34- * {@link RECENTER_FADE_EASING} so the strip and the segment list fade on the same curve.
35- */
36- const STRIP_FADE_EASING = RECENTER_FADE_EASING ;
37-
38- /**
39- * Duration of the strip fade animation in milliseconds. Aliased to the shared
40- * {@link RECENTER_FADE_MS} so the strip and the segment list fade as one; must match the
41- * `setTimeout` in the pending-jump effect.
42- */
43- const STRIP_FADE_MS = RECENTER_FADE_MS ;
44-
4532/**
4633 * Backstop, in milliseconds, for committing the deferred inactive-link relayout after an
4734 * internal-nav smooth scroll. The relayout normally fires on the scroll container's `scrollend`
@@ -292,6 +279,24 @@ export default function ContinuousView({
292279 /** DOM ref array indexed by group index; used to scroll the focused phrase box into view. */
293280 const phraseRefs = useRef < ( HTMLSpanElement | null ) [ ] > ( [ ] ) ;
294281
282+ /**
283+ * Scrolls the phrase group at `groupIndex` to horizontal center of the strip. Every centering
284+ * call site shares the `block: 'nearest', inline: 'center'` options and differs only in
285+ * `behavior`, so they route through here. Stable identity (reads `phraseRefs` and takes the index
286+ * explicitly) so the effects that center a snapshot index keep their intentionally-narrow dep
287+ * arrays.
288+ *
289+ * @param groupIndex - Index into `phraseRefs` of the group to center.
290+ * @param behavior - `'auto'` for an instant jump, `'smooth'` for an animated glide.
291+ */
292+ const centerGroup = useCallback ( ( groupIndex : number , behavior : ScrollBehavior ) => {
293+ phraseRefs . current [ groupIndex ] ?. scrollIntoView ( {
294+ behavior,
295+ block : 'nearest' ,
296+ inline : 'center' ,
297+ } ) ;
298+ } , [ ] ) ;
299+
295300 /** Ref to the token-strip row; the content row and mouse-leave target. */
296301 // eslint-disable-next-line no-null/no-null
297302 const stripRowRef = useRef < HTMLDivElement | null > ( null ) ;
@@ -483,7 +488,7 @@ export default function ContinuousView({
483488 setIsVisible ( false ) ;
484489 const timeout = setTimeout ( ( ) => {
485490 setDisplayFocusedTokenRef ( focusedTokenRef ) ;
486- } , STRIP_FADE_MS ) ;
491+ } , RECENTER_FADE_MS ) ;
487492 return ( ) => clearTimeout ( timeout ) ;
488493 } , [ focusedTokenRef , displayFocusedTokenRef ] ) ;
489494
@@ -501,11 +506,7 @@ export default function ContinuousView({
501506 // to disturb — commit the active segment now alongside the instant scroll.
502507 setSkipSlotTransitionForJump ( true ) ;
503508 commitPendingActiveSegment ( ) ;
504- phraseRefs . current [ focusPhraseIndex ] ?. scrollIntoView ( {
505- behavior : 'auto' ,
506- block : 'nearest' ,
507- inline : 'center' ,
508- } ) ;
509+ centerGroup ( focusPhraseIndex , 'auto' ) ;
509510 }
510511
511512 if ( isInternal && ! isInitialLoad ) {
@@ -514,11 +515,7 @@ export default function ContinuousView({
514515 // Scrolling synchronously here animates toward a position that then shifts, producing a visible
515516 // overshoot-and-return ("yank") when crossing a verse boundary.
516517 const navRafId = requestAnimationFrame ( ( ) => {
517- phraseRefs . current [ focusPhraseIndex ] ?. scrollIntoView ( {
518- behavior : 'smooth' ,
519- block : 'nearest' ,
520- inline : 'center' ,
521- } ) ;
518+ centerGroup ( focusPhraseIndex , 'smooth' ) ;
522519 } ) ;
523520 // Commit the active-segment change (which toggles inactive link-icon visibility, re-laying out
524521 // the strip) only once the smooth scroll has actually settled. Updating it mid-scroll would
@@ -562,7 +559,7 @@ export default function ContinuousView({
562559 cancelAnimationFrame ( rafId ) ;
563560 setIsVisible ( true ) ;
564561 } ;
565- } , [ focusPhraseIndex , commitPendingActiveSegment ] ) ;
562+ } , [ focusPhraseIndex , commitPendingActiveSegment , centerGroup ] ) ;
566563
567564 // Keep the focused group pinned dead-center after the deferred active-segment flip. When
568565 // `committedActiveSegmentId` flips (after an internal-nav scroll settles), inactive link icons
@@ -580,13 +577,7 @@ export default function ContinuousView({
580577 return undefined ;
581578 }
582579 /** Re-centers the focused group; called synchronously now and each `rAF` until the deadline. */
583- const recenter = ( ) => {
584- phraseRefs . current [ focusPhraseIndex ] ?. scrollIntoView ( {
585- behavior : 'auto' ,
586- block : 'nearest' ,
587- inline : 'center' ,
588- } ) ;
589- } ;
580+ const recenter = ( ) => centerGroup ( focusPhraseIndex , 'auto' ) ;
590581 recenter ( ) ;
591582 const deadline = performance . now ( ) + LINK_SLOT_TRANSITION_MS ;
592583 let rafId = requestAnimationFrame ( function recenterFrame ( ) {
@@ -605,13 +596,9 @@ export default function ContinuousView({
605596 // when hidden (`opacity: 0`; clickability is guarded at the button level), so toggling it does
606597 // not shift the layout.
607598 useEffect ( ( ) => {
608- phraseRefs . current [ focusPhraseIndex ] ?. scrollIntoView ( {
609- behavior : 'auto' ,
610- block : 'nearest' ,
611- inline : 'center' ,
612- } ) ;
599+ centerGroup ( focusPhraseIndex , 'auto' ) ;
613600 // focusPhraseIndex is intentionally excluded: it has its own scroll effect above. This effect
614- // only re-centers in response to layout-affecting option toggles.
601+ // only re-centers in response to layout-affecting option toggles. centerGroup is stable.
615602 // eslint-disable-next-line react-hooks/exhaustive-deps
616603 } , [ simplifyPhrases ] ) ;
617604
@@ -909,10 +896,7 @@ export default function ContinuousView({
909896 data-testid = "strip-fade-wrapper"
910897 ref = { arcContainerRef }
911898 className = { `tw:arc-container tw:transition-opacity ${ stripOpacityClass } ` }
912- style = { {
913- transitionDuration : `${ STRIP_FADE_MS } ms` ,
914- transitionTimingFunction : STRIP_FADE_EASING ,
915- } }
899+ style = { RECENTER_FADE_TRANSITION_STYLE }
916900 >
917901 < MemoizedArcOverlay
918902 arcPaths = { arcPaths }
0 commit comments