Skip to content

Commit a18e2c8

Browse files
Adjust animation/fade timing
1 parent b2c9103 commit a18e2c8

5 files changed

Lines changed: 158 additions & 12 deletions

File tree

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,29 @@ describe('ContinuousView scroll behavior', () => {
820820
expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' }));
821821
});
822822

823+
it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => {
824+
const book = makeBook();
825+
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
826+
const { container, rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
827+
828+
act(() => {
829+
jest.useFakeTimers();
830+
});
831+
// External nav into the other verse: the active segment commits instantly behind the fade, so
832+
// the slots must snap to their new widths rather than animating (which would slide the boxes for
833+
// ~200ms after the strip fades back in).
834+
rerender(<ContinuousView {...{ ...props, focusedTokenRef: 'tok-3' }} />);
835+
836+
const slotWrapper = container.querySelector('[data-link-slot] > span');
837+
if (!(slotWrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span');
838+
expect(slotWrapper.style.transitionDuration).toBe('0ms');
839+
840+
act(() => {
841+
jest.advanceTimersByTime(600);
842+
jest.useRealTimers();
843+
});
844+
});
845+
823846
it('smooth-scrolls for internal nav once the parent echoes the ref back synchronously', async () => {
824847
// The smooth-scroll path requires the displayed focus to already agree with the prop and the
825848
// strip to be visible when the scroll effect runs. That only happens when a real (stateful)

src/__tests__/hooks/useSegmentWindow.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,26 @@ function makeBook(chapter1Count: number, chapter2Count: number): Book {
6666
* @param scrRef - The scripture reference whose verse anchors the window.
6767
* @returns The render-hook result plus the scroll container element.
6868
*/
69-
function renderSegmentWindow(book: Book, scrRef: SerializedVerseRef) {
69+
function renderSegmentWindow(book: Book, scrRef: SerializedVerseRef, focusedTokenRef?: string) {
7070
const container = document.createElement('div');
7171
document.body.appendChild(container);
7272
// Shared across renders so a test can stamp it (mimicking an internal nav) before a rerender.
7373
const internalNavRef: { current: string | undefined } = { current: undefined };
74-
const hook = renderHook(
75-
({ b, ref }: { b: Book; ref: SerializedVerseRef }) => {
74+
const hook = renderHook<
75+
ReturnType<typeof useSegmentWindow>,
76+
{ b: Book; ref: SerializedVerseRef; focus?: string | undefined }
77+
>(
78+
({ b, ref, focus }) => {
7679
const scrollContainerRef = useRef<HTMLElement | undefined>(container);
77-
return useSegmentWindow({ book: b, scrRef: ref, scrollContainerRef, internalNavRef });
80+
return useSegmentWindow({
81+
book: b,
82+
scrRef: ref,
83+
focusedTokenRef: focus,
84+
scrollContainerRef,
85+
internalNavRef,
86+
});
7887
},
79-
{ initialProps: { b: book, ref: scrRef } },
88+
{ initialProps: { b: book, ref: scrRef, focus: focusedTokenRef } },
8089
);
8190
return { ...hook, container, internalNavRef };
8291
}
@@ -585,4 +594,52 @@ describe('useSegmentWindow', () => {
585594
unmount();
586595
expect(global.ioInstances).toHaveLength(0);
587596
});
597+
598+
it('initializes displayFocusedTokenRef from the initial focused token', () => {
599+
const book = makeBook(40, 0);
600+
const { result } = renderSegmentWindow(
601+
book,
602+
{ book: 'GEN', chapterNum: 1, verseNum: 15 },
603+
'tok-initial',
604+
);
605+
expect(result.current.displayFocusedTokenRef).toBe('tok-initial');
606+
});
607+
608+
it('defers displayFocusedTokenRef to the recenter midpoint on external nav', () => {
609+
const book = makeBook(60, 0);
610+
const { result, rerender } = renderSegmentWindow(
611+
book,
612+
{ book: 'GEN', chapterNum: 1, verseNum: 5 },
613+
'tok-old',
614+
);
615+
616+
// External nav: the focused token jumps to the new verse the same render the anchor changes.
617+
act(() =>
618+
rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 }, focus: 'tok-new' }),
619+
);
620+
// Mid-fade: the display ref must still read the old token so the active-verse buttons on the
621+
// still-visible old content don't re-evaluate (and dim) before the fade-out completes.
622+
expect(result.current.isFaded).toBe(true);
623+
expect(result.current.displayFocusedTokenRef).toBe('tok-old');
624+
625+
// At the midpoint the window swaps behind the fade and the display ref catches up.
626+
act(() => jest.advanceTimersByTime(RECENTER_FADE_MS));
627+
expect(result.current.displayFocusedTokenRef).toBe('tok-new');
628+
});
629+
630+
it('updates displayFocusedTokenRef immediately for a within-verse focus move (no fade)', () => {
631+
const book = makeBook(40, 0);
632+
const { result, rerender } = renderSegmentWindow(
633+
book,
634+
{ book: 'GEN', chapterNum: 1, verseNum: 15 },
635+
'tok-a',
636+
);
637+
638+
// Same verse (anchor unchanged), focus moves token-to-token: no fade, display ref tracks at once.
639+
act(() =>
640+
rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 15 }, focus: 'tok-b' }),
641+
);
642+
expect(result.current.isFaded).toBe(false);
643+
expect(result.current.displayFocusedTokenRef).toBe('tok-b');
644+
});
588645
});

src/components/ContinuousView.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,17 @@ export default function ContinuousView({
250250

251251
const [isVisible, setIsVisible] = useState(false);
252252

253+
/**
254+
* True for the single render in which an instant jump (external nav or initial mount) flips
255+
* {@link committedActiveSegmentId}, so the link slots snap to their new widths instead of
256+
* animating. `isVisible` alone can't gate this: the scroll effect's cleanup restores visibility
257+
* before the new effect commits the segment, so by the time the slots want their new widths
258+
* `isVisible` is already `true` and the transition would play — sliding the boxes (and yanking
259+
* the recentered phrase) for ~200ms after the fade-in. Cleared in the deferred fade-in frame, one
260+
* paint after the snap, so genuine in-view toggles still animate.
261+
*/
262+
const [skipSlotTransitionForJump, setSkipSlotTransitionForJump] = useState(false);
263+
253264
/** True until the first scroll-into-view completes; suppresses smooth scroll on initial mount. */
254265
const isInitialLoadInProgressRef = useRef(true);
255266

@@ -477,6 +488,7 @@ export default function ContinuousView({
477488
if (shouldJumpInstantly) {
478489
// External jumps fade the strip out and the initial mount is static, so there is no animation
479490
// to disturb — commit the active segment now alongside the instant scroll.
491+
setSkipSlotTransitionForJump(true);
480492
commitPendingActiveSegment();
481493
phraseRefs.current[focusPhraseIndex]?.scrollIntoView({
482494
behavior: 'auto',
@@ -530,7 +542,11 @@ export default function ContinuousView({
530542
if (isInitialLoad) isInitialLoadInProgressRef.current = false;
531543

532544
// Defer the fade-in until after the browser applies the instant scroll position.
533-
const rafId = requestAnimationFrame(() => setIsVisible(true));
545+
const rafId = requestAnimationFrame(() => {
546+
setIsVisible(true);
547+
// The snapped-slot paint has happened; re-enable the transition for later in-view toggles.
548+
setSkipSlotTransitionForJump(false);
549+
});
534550
return () => {
535551
cancelAnimationFrame(rafId);
536552
setIsVisible(true);
@@ -662,7 +678,7 @@ export default function ContinuousView({
662678
activeSegmentId: committedActiveSegmentId,
663679
crossSegmentLinkTooltip:
664680
localizedStrings['%interlinearizer_linkButton_crossSegmentDisabledTooltip%'],
665-
skipLinkTransition: !isVisible,
681+
skipLinkTransition: !isVisible || skipSlotTransitionForJump,
666682
}),
667683
[
668684
phraseMode,
@@ -678,6 +694,7 @@ export default function ContinuousView({
678694
simplifyPhrases,
679695
committedActiveSegmentId,
680696
isVisible,
697+
skipSlotTransitionForJump,
681698
localizedStrings,
682699
],
683700
);
@@ -694,18 +711,22 @@ export default function ContinuousView({
694711

695712
/**
696713
* Resolved focus context — what's focused, what segment it's in, what phrase it belongs to. Built
697-
* once from `focusedTokenRef` and reused by all highlight + slot decisions so the rules match
698-
* SegmentView exactly.
714+
* from the fade-gated `displayFocusedTokenRef` (not the live `focusedTokenRef`) so every
715+
* highlight and link-button active/disabled decision moves only at the recenter midpoint, behind
716+
* the fade — never re-evaluating (and dimming the buttons) on the still-visible old strip the
717+
* instant an external nav reseeds the live focus. The scroll target (`focusedGroupIndex`) still
718+
* uses the live ref so the jump lands on the new verse behind the curtain. Mirrors SegmentView,
719+
* which is fed the segment window's own gated display ref.
699720
*/
700721
const focus = useMemo(
701722
() =>
702723
resolveFocusContext(
703-
focusedTokenRef,
724+
displayFocusedTokenRef,
704725
wordTokenByRef,
705726
committedPhraseLinkByRef,
706727
tokenSegmentMap,
707728
),
708-
[focusedTokenRef, wordTokenByRef, committedPhraseLinkByRef, tokenSegmentMap],
729+
[displayFocusedTokenRef, wordTokenByRef, committedPhraseLinkByRef, tokenSegmentMap],
709730
);
710731

711732
/** True when any committed phrase exists in the visible window. */

src/components/Interlinearizer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,14 @@ function InterlinearizerInner({
189189
windowSegments,
190190
isFaded,
191191
displayScrRef,
192+
displayFocusedTokenRef,
192193
topSentinelRef,
193194
bottomSentinelRef,
194195
recenterOnActive,
195196
} = useSegmentWindow({
196197
book,
197198
scrRef,
199+
focusedTokenRef,
198200
scrollContainerRef,
199201
internalNavRef,
200202
});
@@ -372,7 +374,7 @@ function InterlinearizerInner({
372374
key={seg.id}
373375
displayMode={continuousScroll ? 'baseline-text' : 'token-chip'}
374376
editPhraseSegmentId={editPhraseSegmentId}
375-
focusedTokenRef={continuousScroll ? undefined : focusedTokenRef}
377+
focusedTokenRef={continuousScroll ? undefined : displayFocusedTokenRef}
376378
hoveredPhraseId={hoveredPhraseId}
377379
isActive={
378380
seg.startRef.book === displayScrRef.book &&

src/hooks/useSegmentWindow.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export interface UseSegmentWindowArgs {
6868
book: Book;
6969
/** Current scripture reference; the active verse it names is the recenter anchor. */
7070
scrRef: SerializedVerseRef;
71+
/**
72+
* Token ref of the currently focused word token, or `undefined` when nothing is focused. Gated
73+
* alongside {@link UseSegmentWindowResult.displayScrRef} so the per-token focus highlight and the
74+
* link-button active state only move behind the recenter fade on external nav — never on the old,
75+
* still-visible content before the fade-out starts.
76+
*/
77+
focusedTokenRef: string | undefined;
7178
/** Ref to the scrollable list container; used to read/adjust scroll position and host sentinels. */
7279
scrollContainerRef: RefObject<HTMLElement | undefined>;
7380
/**
@@ -96,6 +103,13 @@ export interface UseSegmentWindowResult {
96103
* lockstep.
97104
*/
98105
displayScrRef: SerializedVerseRef;
106+
/**
107+
* Token ref the list should highlight as focused. Gated on the same clock as {@link displayScrRef}
108+
* so the per-token focus and link-button active state move only at the recenter midpoint, behind
109+
* the fade — never on the old content before the fade-out begins. Tracks `focusedTokenRef`
110+
* immediately for internal nav and the initial mount.
111+
*/
112+
displayFocusedTokenRef: string | undefined;
99113
/** Ref callback for the invisible sentinel placed above the first segment. */
100114
topSentinelRef: (el: HTMLElement | null) => void;
101115
/** Ref callback for the invisible sentinel placed below the last segment. */
@@ -173,6 +187,7 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange {
173187
export default function useSegmentWindow({
174188
book,
175189
scrRef,
190+
focusedTokenRef,
176191
scrollContainerRef,
177192
internalNavRef,
178193
}: UseSegmentWindowArgs): UseSegmentWindowResult {
@@ -192,6 +207,16 @@ export default function useSegmentWindow({
192207
*/
193208
const [displayScrRef, setDisplayScrRef] = useState<SerializedVerseRef>(scrRef);
194209

210+
/**
211+
* Focused token ref the per-token highlight and link-button active state track. Gated on the same
212+
* clock as {@link displayScrRef}: deferred to the recenter midpoint on external nav (so buttons
213+
* never re-evaluate active/disabled — and dim — on the old, still-visible content before the
214+
* fade-out), updated immediately for internal nav and the initial value.
215+
*/
216+
const [displayFocusedTokenRef, setDisplayFocusedTokenRef] = useState<string | undefined>(
217+
focusedTokenRef,
218+
);
219+
195220
/**
196221
* Scroll-height correction owed to the next paint. When segments are prepended the content above
197222
* the viewport grows; recording the pre-mutation `scrollHeight` lets the layout effect restore
@@ -234,6 +259,8 @@ export default function useSegmentWindow({
234259
totalRef.current = total;
235260
const scrRefRef = useRef(scrRef);
236261
scrRefRef.current = scrRef;
262+
const focusedTokenRefRef = useRef(focusedTokenRef);
263+
focusedTokenRefRef.current = focusedTokenRef;
237264

238265
/**
239266
* Handle of the in-flight recenter fade timeout, or `undefined` when no recenter is mid-flight.
@@ -355,6 +382,7 @@ export default function useSegmentWindow({
355382
setRange(buildCenteredRange(anchorIndexRef.current, totalRef.current));
356383
setRecenterEpoch((n) => n + 1);
357384
setDisplayScrRef(scrRefRef.current);
385+
setDisplayFocusedTokenRef(focusedTokenRefRef.current);
358386
setIsFaded(false);
359387
}, RECENTER_FADE_MS);
360388
}, []);
@@ -383,6 +411,7 @@ export default function useSegmentWindow({
383411
internalNavRef.current = undefined;
384412
if (isInternal) {
385413
setDisplayScrRef(currentScrRef);
414+
setDisplayFocusedTokenRef(focusedTokenRefRef.current);
386415
return;
387416
}
388417
triggerRecenter();
@@ -393,6 +422,19 @@ export default function useSegmentWindow({
393422
// eslint-disable-next-line react-hooks/exhaustive-deps
394423
}, [anchorIndex, segments, internalNavRef, triggerRecenter]);
395424

425+
// Track within-verse focus moves (arrow/click that stays in the active verse) immediately. These
426+
// change `focusedTokenRef` without changing `anchorIndex`, so the recenter effect above never
427+
// fires for them; sync the display ref here so the focus highlight follows. Skip while a recenter
428+
// fade is in flight — that swap owns the display ref and lands the new focus at the midpoint, so
429+
// updating here too would move the highlight (and re-dim buttons) on the old content before the
430+
// fade-out completes. `recenterTimeoutRef` is set synchronously by `triggerRecenter`, so it reads
431+
// true even in the same commit the external nav starts the fade — when `isFaded` state is still
432+
// stale.
433+
useEffect(() => {
434+
if (recenterTimeoutRef.current !== undefined) return;
435+
setDisplayFocusedTokenRef(focusedTokenRef);
436+
}, [focusedTokenRef]);
437+
396438
// The mounted sentinel elements, held in state so the observer effect re-runs once they attach.
397439
// Ref callbacks only record the node; the actual observe happens in the effect below, which runs
398440
// after React has attached every ref — including the scroll container (an ancestor). Wiring the
@@ -472,6 +514,7 @@ export default function useSegmentWindow({
472514
windowSegments,
473515
isFaded,
474516
displayScrRef,
517+
displayFocusedTokenRef,
475518
topSentinelRef,
476519
bottomSentinelRef,
477520
recenterOnActive,

0 commit comments

Comments
 (0)