Skip to content

Commit f8b2ef3

Browse files
Cleanup
1 parent fb2a7df commit f8b2ef3

7 files changed

Lines changed: 71 additions & 104 deletions

File tree

src/components/ContinuousView.tsx

Lines changed: 27 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/tok
1414
import { useArcPaths } from '../hooks/useArcPaths';
1515
import { usePhraseHoverState } from '../hooks/usePhraseHoverState';
1616
import 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}

src/components/Interlinearizer.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,19 @@ import { isWordToken } from '../types/type-guards';
1010
import SegmentListView from './SegmentListView';
1111
import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm';
1212
import { useInterlinearNav } from './InterlinearNavContext';
13-
import { RECENTER_FADE_EASING, RECENTER_FADE_MS } from './recenter-fade';
13+
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
14+
15+
/**
16+
* Returns the ref of the first word token in `segment`, or `undefined` when the segment has none.
17+
* The seed value for `focusedTokenRef` whenever focus must fall back to the active verse's leading
18+
* word (initial mount, book change, and external verse reseed).
19+
*
20+
* @param segment - The segment to read, or `undefined` when no active segment is resolved.
21+
* @returns The first word token's ref, or `undefined`.
22+
*/
23+
function firstWordTokenRefOf(segment: Segment | undefined): string | undefined {
24+
return segment?.tokens.find(isWordToken)?.ref;
25+
}
1426

1527
/** Props for {@link Interlinearizer}. */
1628
type InterlinearizerProps = Readonly<{
@@ -105,14 +117,14 @@ function InterlinearizerInner({
105117
// Seed focusedTokenRef from the active verse on first render so the views always see a defined
106118
// value. An undefined focusedTokenRef would disable all link buttons (isSameSegmentAsFocus checks
107119
// focus.focusedSegmentId), so we never want it unset while there's a valid seed available.
108-
const [focusedTokenRef, setFocusedTokenRef] = useState<string | undefined>(
109-
() => findActiveSegment()?.tokens.find((t) => t.type === 'word')?.ref,
120+
const [focusedTokenRef, setFocusedTokenRef] = useState<string | undefined>(() =>
121+
firstWordTokenRefOf(findActiveSegment()),
110122
);
111123

112124
// Reseed when the book changes — the previous focusedTokenRef refers to a token from another
113125
// book and would never resolve in the new book's maps.
114126
useEffect(() => {
115-
setFocusedTokenRef(findActiveSegment()?.tokens.find((t) => t.type === 'word')?.ref);
127+
setFocusedTokenRef(firstWordTokenRefOf(findActiveSegment()));
116128
// findActiveSegment changes with scrRef too; only re-seed on book change.
117129
// eslint-disable-next-line react-hooks/exhaustive-deps
118130
}, [book]);
@@ -213,7 +225,7 @@ function InterlinearizerInner({
213225
const activeSeg = findActiveSegment();
214226
if (focusedTokenRef && tokenSegmentMap.get(focusedTokenRef) === activeSeg?.id) return;
215227
/* v8 ignore next -- activeSeg is always defined when the book includes the active verse */
216-
setFocusedTokenRef(activeSeg?.tokens.find((t) => t.type === 'word')?.ref);
228+
setFocusedTokenRef(firstWordTokenRefOf(activeSeg));
217229
// findActiveSegment is intentionally excluded: the verse-coordinate deps already capture the
218230
// change we care about, and it changes identity on every scrRef update.
219231
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -292,11 +304,7 @@ function InterlinearizerInner({
292304
)}
293305
<div
294306
className="tw:flex tw:flex-col tw:flex-1 tw:min-h-0 tw:transition-opacity"
295-
style={{
296-
opacity: isModeToggleFading ? 0 : 1,
297-
transitionDuration: `${RECENTER_FADE_MS}ms`,
298-
transitionTimingFunction: RECENTER_FADE_EASING,
299-
}}
307+
style={{ opacity: isModeToggleFading ? 0 : 1, ...RECENTER_FADE_TRANSITION_STYLE }}
300308
>
301309
{displayContinuousScroll && (
302310
<div className="tw:shrink-0 tw:border-b tw:border-border tw:bg-background tw:py-2">

src/components/InterlinearizerLoader.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { PhraseMode } from '../types/phrase-mode';
1515
import ProjectModals, { type ModalState } from './modals/ProjectModals';
1616
import ScriptureNavControls from './controls/ScriptureNavControls';
1717
import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext';
18-
import { RECENTER_FADE_EASING, RECENTER_FADE_MS } from './recenter-fade';
18+
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
1919

2020
/**
2121
* Root component for the Interlinearizer WebView. Mounts the {@link InterlinearNavProvider} so the
@@ -349,11 +349,7 @@ function InterlinearizerLoaderInner({
349349
<div
350350
data-testid="book-fade-wrapper"
351351
className="tw:flex tw:flex-col tw:flex-1 tw:min-h-0 tw:transition-opacity"
352-
style={{
353-
opacity: fadePhase === 'out' ? 0 : 1,
354-
transitionDuration: `${RECENTER_FADE_MS}ms`,
355-
transitionTimingFunction: RECENTER_FADE_EASING,
356-
}}
352+
style={{ opacity: fadePhase === 'out' ? 0 : 1, ...RECENTER_FADE_TRANSITION_STYLE }}
357353
>
358354
{hasError || showLoading || !book ? (
359355
<div className="tw:flex tw:flex-col tw:gap-4 tw:p-4">

src/components/SegmentListView.tsx

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Dispatch, SetStateAction } from 'react';
66
import type { PhraseMode } from '../types/phrase-mode';
77
import MemoizedSegmentView from './SegmentView';
88
import useSegmentWindow from '../hooks/useSegmentWindow';
9-
import { RECENTER_FADE_EASING, RECENTER_FADE_MS } from './recenter-fade';
9+
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
1010

1111
/** Props for {@link SegmentListView}. */
1212
type SegmentListViewProps = Readonly<{
@@ -168,33 +168,18 @@ export default function SegmentListView({
168168
onSettled: reportSettled,
169169
});
170170

171-
/**
172-
* Recenters the segment list on the active verse with the same fade-and-rebuild used for external
173-
* navigation. Used by the LocateFixed button and the continuous-scroll mode switch. Always fades
174-
* (even when the verse is already on screen) so the active verse is guaranteed to land in view —
175-
* a plain `scrollIntoView` of an `aria-current` element silently no-ops when the verse is outside
176-
* the render window, leaving the list parked wherever it was.
177-
*
178-
* `recenterOnActive` is captured via ref so this callback's identity stays stable.
179-
*/
180-
const recenterOnActiveRef = useRef<() => void>(() => undefined);
181-
recenterOnActiveRef.current = recenterOnActive;
182-
const snapToActive = useCallback(() => {
183-
recenterOnActiveRef.current();
184-
}, []);
185-
186171
// Recenter the segment list on the active verse when switching between continuous and segment
187172
// modes. Skips the initial mount: the window is already built centered on the anchor there, so a
188173
// recenter would needlessly fade. Only an actual mode toggle should fade-and-recenter.
174+
// `recenterOnActive` has a stable identity, so listing it as a dep doesn't re-fire this.
189175
const didMountModeSwitchRef = useRef(false);
190176
useEffect(() => {
191177
if (!didMountModeSwitchRef.current) {
192178
didMountModeSwitchRef.current = true;
193179
return;
194180
}
195-
snapToActive();
196-
// eslint-disable-next-line react-hooks/exhaustive-deps
197-
}, [continuousScroll]);
181+
recenterOnActive();
182+
}, [continuousScroll, recenterOnActive]);
198183

199184
return (
200185
<div
@@ -214,7 +199,7 @@ export default function SegmentListView({
214199
aria-label="Scroll to active verse"
215200
className="tw:rounded tw:p-1 tw:text-foreground tw:bg-background tw:hover:bg-muted/50 tw:pointer-events-auto"
216201
tabIndex={-1}
217-
onClick={snapToActive}
202+
onClick={recenterOnActive}
218203
type="button"
219204
>
220205
<LocateFixed className="tw:h-4 tw:w-4" />
@@ -223,11 +208,7 @@ export default function SegmentListView({
223208

224209
<div
225210
className="tw:flex tw:flex-col tw:gap-2 tw:transition-opacity"
226-
style={{
227-
opacity: isFaded ? 0 : 1,
228-
transitionDuration: `${RECENTER_FADE_MS}ms`,
229-
transitionTimingFunction: RECENTER_FADE_EASING,
230-
}}
211+
style={{ opacity: isFaded ? 0 : 1, ...RECENTER_FADE_TRANSITION_STYLE }}
231212
>
232213
<div ref={topSentinelRef} aria-hidden="true" className="tw:h-px tw:w-full" />
233214
{windowSegments.map((seg) => (

src/components/SegmentView.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,6 @@ export function SegmentView({
204204
</span>
205205
);
206206

207-
/** Ref to the flex token row; used by mouse-leave handling. */
208-
// eslint-disable-next-line no-null/no-null
209-
const tokenRowRef = useRef<HTMLSpanElement | null>(null);
210-
211207
/**
212208
* `false` until just after the first paint, then `true`. Gates the link-slot fade transition: the
213209
* initial visibility state must snap into place before paint (fading in on mount would flash),
@@ -483,7 +479,6 @@ export function SegmentView({
483479
<PhraseStripProvider value={stripContext}>
484480
<span
485481
className="tw:token-row tw:pointer-events-none"
486-
ref={tokenRowRef}
487482
style={{
488483
paddingTop: `${tokenRowTopPadding}px`,
489484
paddingLeft: `${stripLeftPadding}px`,

src/components/recenter-fade.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,15 @@ export const RECENTER_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)';
1616
* transition and for the `setTimeout` that swaps content at the midpoint, so they fade as one.
1717
*/
1818
export const RECENTER_FADE_MS = 500;
19+
20+
/**
21+
* Inline `style` for any element whose opacity fades on the shared recenter clock. Pairs the
22+
* duration and easing so the four fade wrappers (loader curtain, mode-toggle wrapper, segment list,
23+
* continuous strip) can't drift onto different timings — set `opacity` alongside this and add the
24+
* `tw:transition-opacity` class. Frozen so the shared reference is safe to spread into any style
25+
* object.
26+
*/
27+
export const RECENTER_FADE_TRANSITION_STYLE = Object.freeze({
28+
transitionDuration: `${RECENTER_FADE_MS}ms`,
29+
transitionTimingFunction: RECENTER_FADE_EASING,
30+
});

src/hooks/useSegmentWindow.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ export interface UseSegmentWindowResult {
153153
bottomSentinelRef: (el: HTMLElement | null) => void;
154154
/**
155155
* Imperatively recenters the window on the active verse with a fade. Intended for the LocateFixed
156-
* button: when the active verse is outside the render window `snapToActive` finds no
157-
* `aria-current` element, so the button calls this instead.
156+
* button and the continuous-scroll mode switch: always fades and rebuilds, so the active verse is
157+
* brought into view even when it sits outside the render window (where a plain `scrollIntoView`
158+
* of the `aria-current` element would find nothing and silently no-op). Stable identity.
158159
*/
159160
recenterOnActive: () => void;
160161
}
@@ -487,8 +488,8 @@ export default function useSegmentWindow({
487488
}, [recenterEpoch, snapActiveToTop, scrollContainerRef]);
488489

489490
/**
490-
* Rebuilds the window centered on the active verse and fades it into view. Shared by both the
491-
* external-navigation effect and the imperative `recenterOnActive` callback.
491+
* Rebuilds the window centered on the active verse and fades it into view. Used by the
492+
* external-navigation effect and returned directly as the imperative `recenterOnActive`.
492493
*
493494
* Reads `anchorIndex` / `total` / `scrRef` from refs so its identity is stable across renders,
494495
* and owns its timer through `recenterTimeoutRef`: a fresh call supersedes any in-flight fade
@@ -688,16 +689,6 @@ export default function useSegmentWindow({
688689
[segments, range.start, range.end],
689690
);
690691

691-
/**
692-
* Imperative recenter for the LocateFixed button (and the continuous-scroll mode switch).
693-
* Rebuilds the window centered on the active verse and fades it in, so the verse is brought into
694-
* view even when it sits outside the current render window. Always fades — see the parent's
695-
* `snapToActive`.
696-
*/
697-
const recenterOnActive = useCallback(() => {
698-
triggerRecenter();
699-
}, [triggerRecenter]);
700-
701692
// Clear any in-flight recenter fade on unmount so the deferred range/snap/state updates don't run
702693
// against a torn-down tree.
703694
useEffect(
@@ -715,6 +706,6 @@ export default function useSegmentWindow({
715706
displayContinuousScroll,
716707
topSentinelRef,
717708
bottomSentinelRef,
718-
recenterOnActive,
709+
recenterOnActive: triggerRecenter,
719710
};
720711
}

0 commit comments

Comments
 (0)