From b0604b9c58d5cc6b64b04770bb84c5199b545fb8 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 5 Jun 2026 11:43:23 -0600 Subject: [PATCH 01/24] Change segment view to allow for infinite scroll within a book --- cspell.json | 5 + jest.config.ts | 6 +- jest.setup.intersection-observer.js | 42 ++ .../components/Interlinearizer.test.tsx | 172 ++++-- .../components/InterlinearizerLoader.test.tsx | 5 +- .../hooks/useInterlinearizerBookData.test.ts | 22 +- src/__tests__/hooks/useSegmentWindow.test.ts | 514 ++++++++++++++++++ src/components/ContinuousView.tsx | 14 +- src/components/Interlinearizer.tsx | 122 +++-- src/components/InterlinearizerLoader.tsx | 8 +- src/components/recenter-fade.ts | 18 + src/hooks/useInterlinearizerBookData.ts | 14 +- src/hooks/useSegmentWindow.ts | 367 +++++++++++++ 13 files changed, 1201 insertions(+), 108 deletions(-) create mode 100644 jest.setup.intersection-observer.js create mode 100644 src/__tests__/hooks/useSegmentWindow.test.ts create mode 100644 src/components/recenter-fade.ts create mode 100644 src/hooks/useSegmentWindow.ts diff --git a/cspell.json b/cspell.json index a8690e5d..9c929da9 100644 --- a/cspell.json +++ b/cspell.json @@ -38,12 +38,16 @@ "morphosyntactic", "nums", "okina", + "overscan", "papi", "paranext", "paratext", "pdpf", "plusplus", "punct", + "recentered", + "recentering", + "recenters", "relayout", "sandboxed", "scriptio", @@ -54,6 +58,7 @@ "Stylesheet", "typedefs", "unhover", + "unobserves", "unphrased", "unreviewed", "unsub", diff --git a/jest.config.ts b/jest.config.ts index a4c0cbba..992d3459 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -108,7 +108,11 @@ const config: Config = { modulePathIgnorePatterns: ['/dist'], /** Load @testing-library/jest-dom matchers and browser API stubs for React component tests. */ - setupFilesAfterEnv: ['/jest.setup.resize-observer.js', '/jest.setup.ts'], + setupFilesAfterEnv: [ + '/jest.setup.resize-observer.js', + '/jest.setup.intersection-observer.js', + '/jest.setup.ts', + ], /** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */ testEnvironment: 'jsdom', diff --git a/jest.setup.intersection-observer.js b/jest.setup.intersection-observer.js new file mode 100644 index 00000000..8cae5413 --- /dev/null +++ b/jest.setup.intersection-observer.js @@ -0,0 +1,42 @@ +/* eslint-disable no-underscore-dangle */ +// jsdom does not implement IntersectionObserver; stub it so hooks that use it don't throw. +// Plain JS to avoid TypeScript/ESLint restrictions on type assertions and class rules. +// +// The stub records every constructed observer and the elements it observes on `global.ioInstances` +// so tests can drive intersections deterministically. `triggerIntersection(el, isIntersecting)` +// finds the observer watching `el` and invokes its callback with a minimal entry. +global.ioInstances = []; + +global.IntersectionObserver = function IntersectionObserver(callback, options) { + const targets = new Set(); + const instance = { + callback, + options, + targets, + observe(el) { + targets.add(el); + }, + unobserve(el) { + targets.delete(el); + }, + disconnect() { + targets.clear(); + const i = global.ioInstances.indexOf(instance); + if (i !== -1) global.ioInstances.splice(i, 1); + }, + takeRecords() { + return []; + }, + }; + global.ioInstances.push(instance); + return instance; +}; + +// Fires an intersection for `el` on whichever observer is watching it. +global.triggerIntersection = function triggerIntersection(el, isIntersecting) { + global.ioInstances.forEach((instance) => { + if (instance.targets.has(el)) { + instance.callback([{ target: el, isIntersecting }], instance); + } + }); +}; diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 01ea79ce..ad45f8b8 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -6,8 +6,10 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import { act, render, screen } from '@testing-library/react'; import type { Book, ScriptureRef, Segment, Token } from 'interlinearizer'; import type { ReactNode } from 'react'; +import { useState } from 'react'; import Interlinearizer from '../../components/Interlinearizer'; import type { SegmentDisplayMode } from '../../components/SegmentView'; +import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import { defaultScrRef, GEN_1_1_BOOK } from '../test-helpers'; jest.mock('lucide-react', () => ({ @@ -208,6 +210,37 @@ jest.mock('../../components/modals/UnlinkPhraseConfirm', () => ({ /** Pre-built Book with no segments — used by the no-verse-data test. */ const GEN_EMPTY_BOOK: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments: [] }; +/** + * Builds a GEN book with `count` single-token verses in chapter 1. Used to exercise the segment + * window's recenter fade, which only triggers when the new active verse is outside the rendered + * window — impossible with the small fixtures above. + * + * @param count - Number of verses to generate. + * @returns A {@link Book} with `count` chapter-1 segments. + */ +function makeLargeBook(count: number): Book { + const segments: Segment[] = []; + for (let v = 1; v <= count; v += 1) { + segments.push({ + id: `GEN 1:${v}`, + startRef: { book: 'GEN', chapter: 1, verse: v }, + endRef: { book: 'GEN', chapter: 1, verse: v }, + baselineText: 'word', + tokens: [ + { + ref: `GEN 1:${v}:0`, + surfaceText: 'word', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }); + } + return { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; +} + /** Book with two segments in GEN 1 — used by chapter-display tests. */ const GEN_1_MULTI_BOOK: Book = { id: 'GEN', @@ -258,7 +291,6 @@ const GEN_1_MULTI_BOOK: Book = { */ function renderInterlinearizer({ book = GEN_1_1_BOOK, - chapterSegments = GEN_1_1_BOOK.segments, continuousScroll = false, scrRef = defaultScrRef, setScrRef = () => {}, @@ -266,7 +298,6 @@ function renderInterlinearizer({ simplifyPhrases = false, }: { book?: Book; - chapterSegments?: Book['segments']; continuousScroll?: boolean; scrRef?: SerializedVerseRef; setScrRef?: (r: SerializedVerseRef) => void; @@ -276,7 +307,6 @@ function renderInterlinearizer({ return render( { }); it('shows a no-verse message when the tokenized book has no segments at all', () => { - renderInterlinearizer({ chapterSegments: GEN_EMPTY_BOOK.segments }); + renderInterlinearizer({ book: GEN_EMPTY_BOOK }); expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); }); it('renders a SegmentView for every segment in the current chapter', () => { - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); expect(screen.getAllByTestId('segment-view')).toHaveLength(2); expect(capturedSegmentViewPropsList[0].segment.id).toBe('GEN 1:1'); @@ -321,7 +351,7 @@ describe('Interlinearizer', () => { }); it('passes isActive=true only to the segment matching the current verse', () => { - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); // defaultScrRef is GEN 1:1 expect(capturedSegmentViewPropsList[0].isActive).toBe(true); @@ -330,14 +360,14 @@ describe('Interlinearizer', () => { it('renders all segments when navigating to a title reference (verse 0)', () => { const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 }; - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, scrRef: titleRef }); expect(screen.getAllByTestId('segment-view')).toHaveLength(2); }); it('calls setScrRef with the segment ref when a segment fires onSelect', () => { const mockSetScrRef = jest.fn(); - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, setScrRef: mockSetScrRef }); capturedSegmentViewPropsList[1].onSelect?.({ book: 'GEN', chapter: 1, verse: 2 }); @@ -345,7 +375,7 @@ describe('Interlinearizer', () => { }); it('passes displayMode="baseline-text" to all SegmentViews when continuousScroll is true', () => { - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, continuousScroll: true }); capturedSegmentViewPropsList.forEach((p) => expect(p.displayMode).toBe('baseline-text')); }); @@ -364,7 +394,7 @@ describe('Interlinearizer', () => { it('renders ContinuousView above the chapter segment rows when both are present', () => { const { container } = renderInterlinearizer({ - chapterSegments: GEN_1_MULTI_BOOK.segments, + book: GEN_1_MULTI_BOOK, continuousScroll: true, }); @@ -379,7 +409,6 @@ describe('Interlinearizer', () => { const mockSetScrRef = jest.fn(); renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef, }); @@ -397,7 +426,6 @@ describe('Interlinearizer', () => { // Render in token-chip mode first so onSelect is available on SegmentView props. const { rerender } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: false, }); @@ -413,7 +441,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -434,7 +461,6 @@ describe('Interlinearizer', () => { const mockSetScrRef = jest.fn(); renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, setScrRef: mockSetScrRef, }); @@ -451,11 +477,36 @@ describe('Interlinearizer', () => { expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); }); + it('does not echo scrRef when the focused token belongs to a different book than scrRef', () => { + // During an external book change scrRef names the new book before its data loads, so the + // mounted book (and its focused token) still belong to the previous book. The echo-back effect + // must not fire that stale book's verse back as scrRef. Here the mounted book is GEN but scrRef + // names EXO, so a GEN focus move must not call setScrRef. + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: true, + scrRef: { book: 'EXO', chapterNum: 1, verseNum: 1 }, + setScrRef: mockSetScrRef, + }); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + mockSetScrRef.mockClear(); + const { onFocusedTokenRefChange } = capturedContinuousViewProps; + + act(() => { + // GEN 1:2:0 is in book GEN, which differs from the current scrRef's book (EXO). + onFocusedTokenRefChange('GEN 1:2:0'); + }); + + expect(mockSetScrRef).not.toHaveBeenCalled(); + }); + it('does not update scrRef when ContinuousView focus stays within the current verse', () => { const mockSetScrRef = jest.fn(); renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, setScrRef: mockSetScrRef, @@ -476,7 +527,6 @@ describe('Interlinearizer', () => { it('carries the strip focus into segment view when switching off continuousScroll', () => { const { rerender } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, }); @@ -493,7 +543,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -513,7 +562,6 @@ describe('Interlinearizer', () => { // Start in continuous mode without ContinuousView ever calling onFocusPhraseIndexChange. const { rerender } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, }); @@ -523,7 +571,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -544,7 +591,6 @@ describe('Interlinearizer', () => { // Start in segment mode and focus a specific token. const { rerender } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: false, }); @@ -560,7 +606,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -577,7 +622,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -597,13 +641,13 @@ describe('Interlinearizer', () => { }); it('renders the snap-to-active-verse button when segments are present', () => { - renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); expect(screen.getByRole('button', { name: /scroll to active verse/i })).toBeInTheDocument(); }); it('does not render the snap-to-active-verse button when there are no segments', () => { - renderInterlinearizer({ chapterSegments: GEN_EMPTY_BOOK.segments }); + renderInterlinearizer({ book: GEN_EMPTY_BOOK }); expect( screen.queryByRole('button', { name: /scroll to active verse/i }), @@ -611,7 +655,7 @@ describe('Interlinearizer', () => { }); it('snap button calls scrollIntoView on the active segment', () => { - renderInterlinearizer({ chapterSegments: GEN_1_1_BOOK.segments }); + renderInterlinearizer({ book: GEN_1_1_BOOK }); act(() => { screen.getByRole('button', { name: /scroll to active verse/i }).click(); @@ -627,7 +671,6 @@ describe('Interlinearizer', () => { // scrRef points to verse 99 which does not exist in GEN_1_MULTI_BOOK. const { rerender } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, scrRef: { book: 'GEN', chapterNum: 1, verseNum: 99 }, }); @@ -636,7 +679,6 @@ describe('Interlinearizer', () => { rerender( {}} @@ -656,7 +698,6 @@ describe('Interlinearizer', () => { render( {}} @@ -678,7 +719,6 @@ describe('Interlinearizer', () => { render( {}} @@ -698,7 +738,6 @@ describe('Interlinearizer', () => { render( {}} @@ -718,7 +757,6 @@ describe('Interlinearizer', () => { render( {}} @@ -732,4 +770,76 @@ describe('Interlinearizer', () => { expect(mockUpdatePhrase).toHaveBeenCalledWith('phrase-1', []); expect(setPhraseMode).toHaveBeenCalledWith({ kind: 'view' }); }); + + it('fades the segment list out while recentering on a far-away verse, then back in', () => { + jest.useFakeTimers(); + const book = makeLargeBook(60); + const props = { + book, + continuousScroll: false, + setScrRef: () => {}, + analysisLanguage: 'und', + phraseMode: { kind: 'view' } as const, + setPhraseMode: () => {}, + hideInactiveLinkButtons: false, + simplifyPhrases: false, + }; + const { container, rerender } = render( + , + ); + + const list = container.querySelector('.tw\\:transition-opacity'); + expect(list).toHaveStyle({ opacity: '1' }); + + // Navigate far past the rendered window so the hook fades out before rebuilding. + act(() => { + rerender( + , + ); + }); + expect(container.querySelector('.tw\\:transition-opacity')).toHaveStyle({ opacity: '0' }); + + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + expect(container.querySelector('.tw\\:transition-opacity')).toHaveStyle({ opacity: '1' }); + jest.useRealTimers(); + }); + + it('does not fade the segment list when navigation is originated by an internal segment click', () => { + jest.useFakeTimers(); + const book = makeLargeBook(60); + + // Stateful wrapper so a segment click's setScrRef flows back as the scrRef prop, exercising the + // internal-nav stamp that suppresses the recenter fade. + function Wrapper() { + const [ref, setRef] = useState({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + return ( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + /> + ); + } + const { container } = render(); + + // Click a segment far down the list (still mounted) — an internal nav, so no fade. + const select = capturedSegmentViewPropsList.find((p) => p.segment.id === 'GEN 1:7')?.onSelect; + if (typeof select !== 'function') throw new Error('Expected GEN 1:7 onSelect to be a function'); + act(() => select({ book: 'GEN', chapter: 1, verse: 7 }, 'GEN 1:7:0')); + + expect(container.querySelector('.tw\\:transition-opacity')).toHaveStyle({ opacity: '1' }); + jest.useRealTimers(); + }); }); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 4bb53f6e..fbcb7457 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -7,7 +7,7 @@ import { useData, useLocalizedStrings, useSetting } from '@papi/frontend/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { Book, PhraseAnalysisLink, Segment, TextAnalysis } from 'interlinearizer'; +import type { Book, PhraseAnalysisLink, TextAnalysis } from 'interlinearizer'; import type { Dispatch, SetStateAction } from 'react'; import InterlinearizerLoader from '../../components/InterlinearizerLoader'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; @@ -74,7 +74,6 @@ jest.mock('../../components/ContinuousView', () => ({ type CapturedInterlinearizerProps = { book: Book; - chapterSegments: Segment[]; continuousScroll: boolean; scrRef: SerializedVerseRef; setScrRef: (newScrRef: SerializedVerseRef) => void; @@ -256,7 +255,6 @@ function makeScrollGroupHook() { function mockBookData( overrides: Partial<{ book: Book | undefined; - chapterSegments: Book['segments']; isLoading: boolean; bookError: string | undefined; tokenizeError: { message: string; raw: unknown } | undefined; @@ -264,7 +262,6 @@ function mockBookData( ): void { jest.mocked(useInterlinearizerBookData).mockReturnValue({ book: GEN_1_1_BOOK, - chapterSegments: [], isLoading: false, bookError: undefined, tokenizeError: undefined, diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index 7448a269..06856bf6 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -187,7 +187,7 @@ describe('useInterlinearizerBookData', () => { expect(result.current.tokenizeError?.raw).toBe(nonErrorValue); }); - it('filters segments to current chapter', () => { + it('returns the whole tokenized book without filtering segments by chapter', () => { jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); @@ -195,24 +195,8 @@ describe('useInterlinearizerBookData', () => { useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), ); - expect(result.current.chapterSegments).toHaveLength(2); // Only GEN 1:1 and GEN 1:2 - expect(result.current.chapterSegments[0].id).toBe('GEN 1:1'); - expect(result.current.chapterSegments[1].id).toBe('GEN 1:2'); - }); - - it('filters segments for different chapters correctly', () => { - jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); - jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); - - const { result } = renderHook(() => - useInterlinearizerBookData({ - projectId: 'test-project', - scrRef: { book: 'GEN', chapterNum: 2, verseNum: 1 }, - }), - ); - - expect(result.current.chapterSegments).toHaveLength(1); // Only GEN 2:1 - expect(result.current.chapterSegments[0].id).toBe('GEN 2:1'); + expect(result.current.book).toBe(TEST_BOOK); + expect(result.current.book?.segments).toBe(TEST_BOOK.segments); }); it('falls back to "und" writing system when useProjectSetting returns PlatformError', () => { diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts new file mode 100644 index 00000000..1b02f040 --- /dev/null +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -0,0 +1,514 @@ +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, Segment } from 'interlinearizer'; +import { act, renderHook } from '@testing-library/react'; +import { useRef } from 'react'; +import useSegmentWindow, { verseKey } from '../../hooks/useSegmentWindow'; +import { RECENTER_FADE_MS } from '../../components/recenter-fade'; + +/** + * The intersection-observer Jest stub records instances on the global object and exposes a helper + * to fire intersections. Declare the shapes here so the test reads them without type assertions. + */ +declare global { + // eslint-disable-next-line no-var, vars-on-top + var triggerIntersection: (el: Element, isIntersecting: boolean) => void; + // eslint-disable-next-line no-var, vars-on-top + var ioInstances: { targets: Set }[]; +} + +/** + * Builds a single-token word segment for the given chapter/verse. Token surface text is irrelevant + * to windowing, so it is a fixed stub. + * + * @param chapter - Chapter number for the segment's refs. + * @param verse - Verse number for the segment's refs. + * @returns A minimal {@link Segment}. + */ +function makeSegment(chapter: number, verse: number): Segment { + return { + id: `GEN ${chapter}:${verse}`, + startRef: { book: 'GEN', chapter, verse }, + endRef: { book: 'GEN', chapter, verse }, + baselineText: 'word', + tokens: [ + { + ref: `GEN ${chapter}:${verse}:0`, + surfaceText: 'word', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }; +} + +/** + * Builds a book whose segments span two chapters: `chapter1Count` verses in chapter 1 followed by + * `chapter2Count` verses in chapter 2. + * + * @param chapter1Count - Number of verses in chapter 1. + * @param chapter2Count - Number of verses in chapter 2. + * @returns A {@link Book} with the combined flat segment list. + */ +function makeBook(chapter1Count: number, chapter2Count: number): Book { + const segments: Segment[] = []; + for (let v = 1; v <= chapter1Count; v += 1) segments.push(makeSegment(1, v)); + for (let v = 1; v <= chapter2Count; v += 1) segments.push(makeSegment(2, v)); + return { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; +} + +/** + * Renders {@link useSegmentWindow} with a real, attached scroll container so sentinel ref callbacks + * register with the stubbed observer and `scrollHeight`/`scrollTop` are writable for assertions. + * + * @param book - The book to window. + * @param scrRef - The scripture reference whose verse anchors the window. + * @returns The render-hook result plus the scroll container element. + */ +function renderSegmentWindow(book: Book, scrRef: SerializedVerseRef) { + const container = document.createElement('div'); + document.body.appendChild(container); + // Shared across renders so a test can stamp it (mimicking an internal nav) before a rerender. + const internalNavRef: { current: string | undefined } = { current: undefined }; + const hook = renderHook( + ({ b, ref }: { b: Book; ref: SerializedVerseRef }) => { + const scrollContainerRef = useRef(container); + return useSegmentWindow({ book: b, scrRef: ref, scrollContainerRef, internalNavRef }); + }, + { initialProps: { b: book, ref: scrRef } }, + ); + return { ...hook, container, internalNavRef }; +} + +/** + * Mounts the rendered window's sentinel elements into `container` so the observer has real targets. + * Returns the created top/bottom elements. + * + * @param container - The scroll container the sentinels live in. + * @param topRef - The hook's top sentinel ref callback. + * @param bottomRef - The hook's bottom sentinel ref callback. + * @returns The mounted sentinel elements. + */ +function mountSentinels( + container: HTMLElement, + topRef: (el: HTMLElement | null) => void, + bottomRef: (el: HTMLElement | null) => void, +) { + const top = document.createElement('div'); + const bottom = document.createElement('div'); + container.appendChild(top); + container.appendChild(bottom); + act(() => { + topRef(top); + bottomRef(bottom); + }); + return { top, bottom }; +} + +beforeEach(() => { + jest.useFakeTimers(); + global.ioInstances = []; +}); + +afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('useSegmentWindow', () => { + it('centers the initial window on the active verse, clamped to the book start', () => { + const book = makeBook(20, 0); + const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 1 }); + + // Anchor at index 0; the window cannot extend before the start, so it runs [0, 9). + expect(result.current.windowSegments[0].id).toBe('GEN 1:1'); + expect(result.current.windowSegments).toHaveLength(9); + }); + + it('spans chapter boundaries when the anchor is near the end of a chapter', () => { + const book = makeBook(10, 10); + const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 10 }); + + const ids = result.current.windowSegments.map((s) => s.id); + expect(ids).toContain('GEN 1:10'); + expect(ids).toContain('GEN 2:1'); + }); + + it('falls back to the first segment of the chapter when no exact verse matches', () => { + const book = makeBook(10, 10); + const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 2, verseNum: 999 }); + + // No GEN 2:999, so the anchor is the first chapter-2 segment (GEN 2:1, flat index 10). + expect(result.current.windowSegments.map((s) => s.id)).toContain('GEN 2:1'); + }); + + it('falls back to index 0 when the book has no matching book or chapter', () => { + const book = makeBook(5, 0); + const { result } = renderSegmentWindow(book, { book: 'EXO', chapterNum: 1, verseNum: 1 }); + + expect(result.current.windowSegments[0].id).toBe('GEN 1:1'); + }); + + it('appends later segments when the bottom sentinel intersects', () => { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 15, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + const before = result.current.windowSegments.length; + act(() => global.triggerIntersection(bottom, true)); + + expect(result.current.windowSegments.length).toBeGreaterThan(before); + }); + + it('ignores a non-intersecting sentinel entry', () => { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 15, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + const before = result.current.windowSegments.length; + act(() => global.triggerIntersection(bottom, false)); + + expect(result.current.windowSegments).toHaveLength(before); + }); + + it('prepends earlier segments and corrects scrollTop when the top sentinel intersects', () => { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 20, + }); + const { top } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + const firstBefore = result.current.windowSegments[0].id; + // Simulate the prepend growing the content: scrollHeight jumps by 100px across the mutation. + Object.defineProperty(container, 'scrollHeight', { value: 100, configurable: true }); + container.scrollTop = 50; + act(() => { + global.triggerIntersection(top, true); + Object.defineProperty(container, 'scrollHeight', { value: 200, configurable: true }); + }); + + expect(result.current.windowSegments[0].id).not.toBe(firstBefore); + expect(container.scrollTop).toBe(150); + }); + + it('does not extend past the book start when already at the top', () => { + const book = makeBook(20, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { top } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + const before = result.current.windowSegments.length; + act(() => global.triggerIntersection(top, true)); + + expect(result.current.windowSegments).toHaveLength(before); + }); + + it('does not extend past the book end when already at the bottom', () => { + const book = makeBook(6, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 6, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // The whole 6-segment book already fits in the window; the bottom is reached. + const before = result.current.windowSegments.length; + act(() => global.triggerIntersection(bottom, true)); + + expect(result.current.windowSegments).toHaveLength(before); + }); + + it('caps the mounted window at the maximum size while scrolling down', () => { + const book = makeBook(80, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // Fire many extends; the window must never exceed the hard cap. + for (let i = 0; i < 20; i += 1) { + act(() => global.triggerIntersection(bottom, true)); + } + expect(result.current.windowSegments.length).toBeLessThanOrEqual(30); + }); + + it('fades and recenters when external navigation moves the anchor outside the window', () => { + const book = makeBook(60, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + expect(result.current.windowSegments.map((s) => s.id)).not.toContain('GEN 1:50'); + + act(() => { + rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }); + }); + + // The window fades out immediately and only rebuilds after the fade timeout elapses. + expect(result.current.isFaded).toBe(true); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + expect(result.current.windowSegments.map((s) => s.id)).toContain('GEN 1:50'); + }); + + it('lags displayScrRef through the fade so the highlight moves only with the window swap', () => { + const book = makeBook(60, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + expect(result.current.displayScrRef.verseNum).toBe(1); + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + + // While the fade is running the highlight must still point at the old verse. + expect(result.current.isFaded).toBe(true); + expect(result.current.displayScrRef.verseNum).toBe(1); + + // Once the fade completes and the window swaps, the highlight moves to the new verse. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.displayScrRef.verseNum).toBe(50); + }); + + it('moves displayScrRef immediately for internal navigation (no fade)', () => { + const book = makeBook(60, 0); + const { result, rerender, internalNavRef } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + const target: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + internalNavRef.current = verseKey(target); + act(() => rerender({ b: book, ref: target })); + + expect(result.current.isFaded).toBe(false); + expect(result.current.displayScrRef.verseNum).toBe(50); + }); + + it('fades and recenters even when external navigation lands inside the current window', () => { + const book = makeBook(60, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + // GEN 1:6 already sits inside the initial window, yet an external nav must still fade so the + // segment list and continuous strip animate together. + act(() => { + rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 6 } }); + }); + expect(result.current.isFaded).toBe(true); + + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + expect(result.current.windowSegments.map((s) => s.id)).toContain('GEN 1:6'); + }); + + it('snaps the recentered verse to the top of the list behind the fade', () => { + const book = makeBook(60, 0); + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { result, container, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + // Mark the newly-active segment so the layout effect can find it via aria-current. + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + act(() => { + rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }); + }); + expect(scrollIntoView).not.toHaveBeenCalled(); + + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + expect(result.current.isFaded).toBe(false); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); + }); + + it('re-snaps the recentered verse after paint to correct for late layout settling', () => { + const book = makeBook(60, 0); + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { container, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + // The synchronous layout-effect snap has fired once; the post-paint re-snap is still pending. + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // Flushing the requestAnimationFrame re-snaps against the now-settled layout. + act(() => jest.advanceTimersByTime(16)); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + + it('does not re-snap on the initial mount, only after a recenter', () => { + const book = makeBook(60, 0); + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { container } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 1 }); + + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + // Flush any pending frames; the initial mount must not snap (no recenter happened). + act(() => jest.advanceTimersByTime(16)); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('re-creates the sentinel observer on recenter so the new geometry is re-evaluated', () => { + const book = makeBook(60, 0); + const { result, container, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + mountSentinels(container, result.current.topSentinelRef, result.current.bottomSentinelRef); + + const observerBefore = global.ioInstances[0]; + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + // The recenter tears down the stale observer and subscribes a fresh one. + expect(global.ioInstances).toHaveLength(1); + expect(global.ioInstances[0]).not.toBe(observerBefore); + }); + + it('does not fade when the navigation was originated internally', () => { + const book = makeBook(60, 0); + const { result, rerender, internalNavRef } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + // Stamp the ref as the parent does for a click/strip nav, then drive the matching scrRef change. + const newRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + internalNavRef.current = verseKey(newRef); + act(() => { + rerender({ b: book, ref: newRef }); + }); + + expect(result.current.isFaded).toBe(false); + // The ref is consumed so a later external nav to the same verse still fades. + expect(internalNavRef.current).toBeUndefined(); + }); + + it('fades on a later external nav to the same verse after an internal nav consumed the ref', () => { + const book = makeBook(60, 0); + const { result, rerender, internalNavRef } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + const target: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + internalNavRef.current = verseKey(target); + act(() => rerender({ b: book, ref: target })); + expect(result.current.isFaded).toBe(false); + + // Navigate away, then back to the same verse externally (ref no longer stamped): must fade. + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 5 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + act(() => rerender({ b: book, ref: target })); + expect(result.current.isFaded).toBe(true); + }); + + it('unobserves the previous sentinel when its ref is cleared', () => { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 15, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // Clear the bottom sentinel ref (as React does when the node unmounts), then fire an + // intersection on the now-detached element: it must not extend the window. + // eslint-disable-next-line no-null/no-null -- React clears ref callbacks with literal null + act(() => result.current.bottomSentinelRef(null)); + const before = result.current.windowSegments.length; + act(() => global.triggerIntersection(bottom, true)); + + expect(result.current.windowSegments).toHaveLength(before); + }); + + it('cleans up the observer on unmount', () => { + const book = makeBook(40, 0); + const { result, container, unmount } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 15, + }); + mountSentinels(container, result.current.topSentinelRef, result.current.bottomSentinelRef); + + expect(global.ioInstances.length).toBeGreaterThan(0); + unmount(); + expect(global.ioInstances).toHaveLength(0); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index c9c727ce..5a3c64f4 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -14,6 +14,7 @@ import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/tok import { useArcPaths } from '../hooks/useArcPaths'; import { usePhraseHoverState } from '../hooks/usePhraseHoverState'; import MemoizedArcOverlay from './ArcOverlay'; +import { RECENTER_FADE_EASING, RECENTER_FADE_MS } from './recenter-fade'; /** * Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero. @@ -29,16 +30,17 @@ function clampIndex(index: number, len: number): number { } /** - * CSS easing for the strip opacity fade-in/out animation. Uses a sine-like curve for a natural feel - * at both ends of the transition. + * CSS easing for the strip opacity fade-in/out animation. Aliased to the shared + * {@link RECENTER_FADE_EASING} so the strip and the segment list fade on the same curve. */ -const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; +const STRIP_FADE_EASING = RECENTER_FADE_EASING; /** - * Duration of the strip fade animation in milliseconds. Must match the `setTimeout` in the - * pending-jump effect. + * Duration of the strip fade animation in milliseconds. Aliased to the shared + * {@link RECENTER_FADE_MS} so the strip and the segment list fade as one; must match the + * `setTimeout` in the pending-jump effect. */ -const STRIP_FADE_MS = 500; +const STRIP_FADE_MS = RECENTER_FADE_MS; /** * Backstop, in milliseconds, for committing the deferred inactive-link relayout after an diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index db596c32..30a2f9b6 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -10,13 +10,13 @@ import type { PhraseMode } from '../types/phrase-mode'; import { isWordToken } from '../types/type-guards'; import MemoizedSegmentView from './SegmentView'; import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm'; +import useSegmentWindow, { verseKey } from '../hooks/useSegmentWindow'; +import { RECENTER_FADE_EASING, RECENTER_FADE_MS } from './recenter-fade'; /** Props for {@link Interlinearizer}. */ type InterlinearizerProps = Readonly<{ /** Tokenized book whose segments are rendered. */ book: Book; - /** Segments belonging to the current chapter, filtered by the caller. */ - chapterSegments: Segment[]; /** When true, the horizontal token strip is shown above the segment list. */ continuousScroll: boolean; /** Current scripture reference used to highlight the active verse. */ @@ -48,7 +48,6 @@ type InterlinearizerProps = Readonly<{ * * @param props - Component props * @param props.book - Tokenized book whose segments are rendered. - * @param props.chapterSegments - Segments belonging to the current chapter, filtered by the caller. * @param props.continuousScroll - When true, the horizontal token strip is shown above the segment * list. * @param props.scrRef - Current scripture reference used to highlight the active verse. @@ -64,7 +63,6 @@ type InterlinearizerProps = Readonly<{ */ function InterlinearizerInner({ book, - chapterSegments, continuousScroll, scrRef, setScrRef, @@ -73,20 +71,40 @@ function InterlinearizerInner({ hideInactiveLinkButtons, simplifyPhrases, }: Omit) { + /** + * Finds the book segment that owns the active verse named by `scrRef`, matching on book, chapter, + * and verse. Book must be matched too: during an external navigation the new `scrRef.book` is set + * before its book data finishes loading, so the still-mounted `book` belongs to the previous + * reference. A chapter+verse-only match would then resolve to the wrong book's verse (e.g. + * Genesis 15 while navigating to Matthew 15), seed `focusedTokenRef` from it, and the echo-back + * effect would fire that wrong-book verse back as `scrRef`, corrupting the global reference. + * Returning `undefined` until the matching book is mounted keeps focus unset rather than wrong. + * + * @returns The active verse's segment, or `undefined` when no segment matches. + */ + const findActiveSegment = useCallback( + () => + book.segments.find( + (seg) => + seg.startRef.book === scrRef.book && + seg.startRef.chapter === scrRef.chapterNum && + seg.startRef.verse === scrRef.verseNum, + ), + [book.segments, scrRef.book, scrRef.chapterNum, scrRef.verseNum], + ); + // Seed focusedTokenRef from the active verse on first render so the views always see a defined // value. An undefined focusedTokenRef would disable all link buttons (isSameSegmentAsFocus checks // focus.focusedSegmentId), so we never want it unset while there's a valid seed available. - const [focusedTokenRef, setFocusedTokenRef] = useState(() => { - const activeSeg = chapterSegments.find((seg) => seg.startRef.verse === scrRef.verseNum); - return activeSeg?.tokens.find((t) => t.type === 'word')?.ref; - }); + const [focusedTokenRef, setFocusedTokenRef] = useState( + () => findActiveSegment()?.tokens.find((t) => t.type === 'word')?.ref, + ); // Reseed when the book changes — the previous focusedTokenRef refers to a token from another // book and would never resolve in the new book's maps. useEffect(() => { - const activeSeg = chapterSegments.find((seg) => seg.startRef.verse === scrRef.verseNum); - setFocusedTokenRef(activeSeg?.tokens.find((t) => t.type === 'word')?.ref); - // chapterSegments and scrRef change frequently; only re-seed on book change. + setFocusedTokenRef(findActiveSegment()?.tokens.find((t) => t.type === 'word')?.ref); + // findActiveSegment changes with scrRef too; only re-seed on book change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [book]); @@ -131,6 +149,16 @@ function InterlinearizerInner({ const scrollContainerRef = useRef(undefined); + /** + * Verse key (see {@link verseKey}) of the most recent scripture-reference change this component + * originated internally — a segment/token click in the list, or strip arrow nav whose focus moved + * into a new verse. Read by {@link useSegmentWindow} to suppress the recenter fade for navigation + * that came from within the views (the target is already on screen); external navigation leaves + * it unset and so triggers the fade. Mirrors ContinuousView's `internalFocusedTokenRefRef` so + * both views agree on which `scrRef` changes are internal. + */ + const internalNavRef = useRef(undefined); + /** * Ref callback that stores the scroll container element so imperative scroll calls can target it. * @@ -151,6 +179,17 @@ function InterlinearizerInner({ active?.scrollIntoView({ behavior: 'auto', block: 'start' }); }, []); + // Scroll-anchored window into the full book's segment list. Spans chapters, grows/culls at the + // scrolled edge, and recenters (with a fade) on the active verse when navigation arrives from + // outside the list. + const { windowSegments, isFaded, displayScrRef, topSentinelRef, bottomSentinelRef } = + useSegmentWindow({ + book, + scrRef, + scrollContainerRef, + internalNavRef, + }); + /** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */ const [hoveredPhraseId, setHoveredPhraseId] = useState(); @@ -188,12 +227,12 @@ function InterlinearizerInner({ // when focus is already inside the new verse — that case means the verse change came from a // token click here, and we must not clobber the clicked token with the verse's first token. useEffect(() => { - const activeSeg = chapterSegments.find((seg) => seg.startRef.verse === scrRef.verseNum); + const activeSeg = findActiveSegment(); if (focusedTokenRef && tokenSegmentMap.get(focusedTokenRef) === activeSeg?.id) return; - /* v8 ignore next -- activeSeg is always defined when chapterSegments includes the active verse */ + /* v8 ignore next -- activeSeg is always defined when the book includes the active verse */ setFocusedTokenRef(activeSeg?.tokens.find((t) => t.type === 'word')?.ref); - // chapterSegments is intentionally excluded: it changes identity on every render and the - // verse-coordinate deps already capture the change we care about. + // findActiveSegment is intentionally excluded: the verse-coordinate deps already capture the + // change we care about, and it changes identity on every scrRef update. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrRef.book, scrRef.chapterNum, scrRef.verseNum]); @@ -208,18 +247,24 @@ function InterlinearizerInner({ const seg = segmentById.get(segId); /* v8 ignore next -- segmentById contains every segment id from tokenSegmentMap */ if (!seg) return; - if ( - seg.startRef.book === scrRef.book && - seg.startRef.chapter === scrRef.chapterNum && - seg.startRef.verse === scrRef.verseNum - ) { + // Never echo a verse from a different book back as scrRef. During an external book change the + // focused token can briefly belong to the previous book (Genesis) while scrRef already names the + // new one (Matthew); firing setScrRef here would overwrite the new book with the stale one. A + // cross-book move only ever originates externally, so there is nothing to echo in that case. + if (seg.startRef.book !== scrRef.book) return; + if (seg.startRef.chapter === scrRef.chapterNum && seg.startRef.verse === scrRef.verseNum) { return; } - setScrRef({ + const newScrRef = { book: seg.startRef.book, chapterNum: seg.startRef.chapter, verseNum: seg.startRef.verse, - }); + }; + // Strip arrow nav (or a strip phrase click) moved focus into a new verse. Mark it internal so + // the segment window doesn't fade — the continuous strip already smooth-scrolls to it, and the + // list just tracks along. + internalNavRef.current = verseKey(newScrRef); + setScrRef(newScrRef); // scrRef fields are intentionally excluded: they're guards against re-firing, not triggers. // Adding them would re-run this effect on every external verse change without doing useful work. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -234,7 +279,11 @@ function InterlinearizerInner({ */ const handleSegmentSelect = useCallback( (ref: ScriptureRef, tokenRef?: string) => { - setScrRef({ book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }); + const newScrRef = { book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }; + // Mark this as internal navigation so the segment window skips its recenter fade: the clicked + // verse is already on screen, so fading and rebuilding would be a jarring no-op. + internalNavRef.current = verseKey(newScrRef); + setScrRef(newScrRef); if (tokenRef) setFocusedTokenRef(tokenRef); }, [setScrRef], @@ -270,15 +319,15 @@ function InterlinearizerInner({
- {chapterSegments.length === 0 && ( + {windowSegments.length === 0 && (

No verse data for {scrRef.book} {scrRef.chapterNum}.

)} - {chapterSegments.length > 0 && ( + {windowSegments.length > 0 && ( <>
-
- {chapterSegments.map((seg) => ( +
+