diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 84b01dad..ed8f1dca 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -17,10 +17,13 @@ "%interlinearizer_viewOption_continuousScroll%": "Continuous Scroll", "%interlinearizer_viewOption_hideInactiveLinkButtons%": "Hide out-of-segment link buttons", "%interlinearizer_viewOption_simplifyPhrases%": "Show phrase controls on focus only", + "%interlinearizer_viewOption_chapterLabelInVerse%": "Show chapter in verse label", "%interlinearizer_projectSettings_hideInactiveLinkButtons%": "Hide Out-of-Segment Link Buttons", "%interlinearizer_projectSettings_hideInactiveLinkButtonsDescription%": "Hide link buttons between phrases in segments that are not currently active", "%interlinearizer_projectSettings_simplifyPhrases%": "Show Phrase Controls on Focus Only", "%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover", + "%interlinearizer_projectSettings_chapterLabelInVerse%": "Show Chapter in Verse Label", + "%interlinearizer_projectSettings_chapterLabelInVerseDescription%": "Mark chapter boundaries by labeling the first verse of each chapter as chapter:verse instead of showing an inline chapter header above it", "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.", "%interlinearizer_modal_create_title%": "Create Interlinear Project", diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json index 2e2f4a4a..769233ef 100644 --- a/contributions/projectSettings.json +++ b/contributions/projectSettings.json @@ -16,6 +16,11 @@ "label": "%interlinearizer_projectSettings_simplifyPhrases%", "description": "%interlinearizer_projectSettings_simplifyPhrasesDescription%", "default": false + }, + "interlinearizer.chapterLabelInVerse": { + "label": "%interlinearizer_projectSettings_chapterLabelInVerse%", + "description": "%interlinearizer_projectSettings_chapterLabelInVerseDescription%", + "default": false } } } diff --git a/cspell.json b/cspell.json index a8690e5d..04ae63db 100644 --- a/cspell.json +++ b/cspell.json @@ -16,7 +16,10 @@ "affordances", "appdata", "bara", + "baselining", "BBCCCVVV", + "clickability", + "cullable", "deconflict", "deconfliction", "deconflicts", @@ -36,15 +39,22 @@ "labelable", "lightningcss", "morphosyntactic", + "navigations", "nums", "okina", + "overscan", "papi", "paranext", "paratext", "pdpf", "plusplus", "punct", + "rebaseline", + "recentered", + "recentering", + "recenters", "relayout", + "resnap", "sandboxed", "scriptio", "scrollers", @@ -54,6 +64,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/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 0c33e80d..d6458220 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -375,17 +375,24 @@ const scrollIntoViewMock = jest.fn(); */ function buildLookups(book: Book): { tokenSegmentMap: ReadonlyMap; + tokenDocOrder: ReadonlyMap; wordTokenByRef: ReadonlyMap; } { const tokenSegmentMap = new Map(); + const tokenDocOrder = new Map(); const wordTokenByRef = new Map(); + let wordIndex = 0; book.segments.forEach((seg) => { seg.tokens.forEach((t) => { tokenSegmentMap.set(t.ref, seg.id); - if (isWordToken(t)) wordTokenByRef.set(t.ref, t); + if (isWordToken(t)) { + wordTokenByRef.set(t.ref, t); + tokenDocOrder.set(t.ref, wordIndex); + wordIndex += 1; + } }); }); - return { tokenSegmentMap, wordTokenByRef }; + return { tokenSegmentMap, tokenDocOrder, wordTokenByRef }; } /** @@ -408,11 +415,12 @@ function requiredProps( phraseMode: { kind: 'view' }; setPhraseMode: jest.Mock; tokenSegmentMap: ReadonlyMap; + tokenDocOrder: ReadonlyMap; wordTokenByRef: ReadonlyMap; hideInactiveLinkButtons: boolean; simplifyPhrases: boolean; } { - const { tokenSegmentMap, wordTokenByRef } = buildLookups(book); + const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); return { book, editPhraseSegmentId: undefined, @@ -421,6 +429,7 @@ function requiredProps( phraseMode: { kind: 'view' }, setPhraseMode: jest.fn(), tokenSegmentMap, + tokenDocOrder, wordTokenByRef, hideInactiveLinkButtons: false, simplifyPhrases: false, @@ -524,6 +533,71 @@ describe('ContinuousView initial render', () => { const focusedBox = screen.getByText('beginning').closest('[data-phrase-box="true"]'); expect(focusedBox).toHaveAttribute('data-focus-state', 'focused'); }); + + it('falls back to focusedTokenRef when the lagging displayed ref is from another book', () => { + // During a book change displayFocusedTokenRef lags by one fade, so it briefly names a token from + // the previous book that no longer exists in the new book. The focus must follow the live + // focusedTokenRef (the new book's active verse) rather than collapsing to the book's first phrase. + const book = makeBook(); + const { rerender } = render( + , + withAnalysisStore, + ); + + // Swap to a different book whose token refs share none of the previous book's. The displayed ref + // ('tok-2') is now absent; focusedTokenRef points at the new book's *second* phrase. + const otherBook: Book = { + id: 'MAT', + bookRef: 'MAT', + textVersion: '1', + segments: [ + { + id: 'MAT 1:1', + startRef: { book: 'MAT', chapter: 1, verse: 1 }, + endRef: { book: 'MAT', chapter: 1, verse: 1 }, + baselineText: 'Alpha', + tokens: [ + { + ref: 'mat-tok-0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'MAT 1:2', + startRef: { book: 'MAT', chapter: 1, verse: 2 }, + endRef: { book: 'MAT', chapter: 1, verse: 2 }, + baselineText: 'Beta', + tokens: [ + { + ref: 'mat-tok-1', + surfaceText: 'Beta', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }, + ], + }; + + scrollIntoViewMock.mockClear(); + rerender(); + + // The scroll target is resolved through focusPhraseIndex, which falls back to focusedTokenRef + // ('mat-tok-1', the second phrase) rather than collapsing to phrase 0. So the element scrolled + // into view is the one containing "Beta", never "Alpha". + const scrolledTexts = scrollIntoViewMock.mock.contexts.map((el) => + el instanceof HTMLElement ? el.textContent : undefined, + ); + expect(scrolledTexts.some((t) => t?.includes('Beta'))).toBe(true); + expect(scrolledTexts.some((t) => t?.includes('Alpha'))).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -719,6 +793,31 @@ describe('ContinuousView arrow navigation', () => { expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-1'); expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-2'); }); + + it('steps from the externally-imposed focus, not the stale pending index, after an external change interrupts an in-flight internal nav', async () => { + // Sequence: an external nav (tok-3) starts its fade while tok-1 is still displayed; the user + // clicks Next during the fade (internal nav in flight — this parent never echoes it); then a + // second external change lands back on the still-displayed tok-1. Because that value equals the + // displayed ref, the focus-change effect early-returns without clearing the in-flight marker, + // so only the render-phase external-override detection resyncs the pending index. Without it, + // the next step would advance from the stale pending index (group 2 → tok-1) instead of the + // externally-imposed position (group 1 → tok-0). + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); + const { rerender } = render(, withAnalysisStore); + + // External nav while idle: the fade starts; the displayed focus is still tok-1. + rerender(); + // Internal nav in flight: Next from the displayed group (tok-1) emits tok-2. + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-2'); + + // The parent imposes an external position (not the tok-2 echo) that matches the displayed ref. + rerender(); + + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-0'); + }); }); // --------------------------------------------------------------------------- @@ -755,13 +854,36 @@ describe('ContinuousView scroll behavior', () => { expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); }); + it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => { + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); + const { container, rerender } = render(, withAnalysisStore); + + act(() => { + jest.useFakeTimers(); + }); + // External nav into the other verse: the active segment commits instantly behind the fade, so + // the slots must snap to their new widths rather than animating (which would slide the boxes for + // ~200ms after the strip fades back in). + rerender(); + + const slotWrapper = container.querySelector('[data-link-slot] > span'); + if (!(slotWrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span'); + expect(slotWrapper.style.transitionDuration).toBe('0ms'); + + act(() => { + jest.advanceTimersByTime(600); + jest.useRealTimers(); + }); + }); + it('smooth-scrolls for internal nav once the parent echoes the ref back synchronously', async () => { // The smooth-scroll path requires the displayed focus to already agree with the prop and the // strip to be visible when the scroll effect runs. That only happens when a real (stateful) // parent reflects the internal ref change straight back, so simulate one here rather than // driving the ref via a jest.fn() that never updates the prop. const book = makeBook(); - const { tokenSegmentMap, wordTokenByRef } = buildLookups(book); + const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); function Parent() { const [ref, setRef] = useState('tok-0'); return ( @@ -773,6 +895,7 @@ describe('ContinuousView scroll behavior', () => { phraseMode={{ kind: 'view' }} setPhraseMode={jest.fn()} tokenSegmentMap={tokenSegmentMap} + tokenDocOrder={tokenDocOrder} wordTokenByRef={wordTokenByRef} hideInactiveLinkButtons={false} simplifyPhrases={false} @@ -806,7 +929,7 @@ describe('ContinuousView scroll behavior', () => { */ function renderHideInactiveCrossing(): () => boolean { const book = makeBook(); - const { tokenSegmentMap, wordTokenByRef } = buildLookups(book); + const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); function Parent() { const [ref, setRef] = useState('tok-1'); return ( @@ -818,6 +941,7 @@ describe('ContinuousView scroll behavior', () => { phraseMode={{ kind: 'view' }} setPhraseMode={jest.fn()} tokenSegmentMap={tokenSegmentMap} + tokenDocOrder={tokenDocOrder} wordTokenByRef={wordTokenByRef} hideInactiveLinkButtons simplifyPhrases={false} diff --git a/src/__tests__/components/InterlinearNavContext.test.tsx b/src/__tests__/components/InterlinearNavContext.test.tsx new file mode 100644 index 00000000..d52637ab --- /dev/null +++ b/src/__tests__/components/InterlinearNavContext.test.tsx @@ -0,0 +1,581 @@ +/** @file Unit tests for components/InterlinearNavContext.tsx. */ +/// +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { + INTERNAL_NAV_TTL_MS, + InterlinearNavProvider, + useInterlinearNav, +} from '../../components/InterlinearNavContext'; +import { RECENTER_FADE_MS } from '../../components/recenter-fade'; + +/** Tuple shape returned by the PAPI scroll-group hook. */ +type ScrollGroupTuple = [ + SerializedVerseRef, + (r: SerializedVerseRef) => void, + number | undefined, + (id: number | undefined) => void, +]; + +/** + * Builds a `useWebViewScrollGroupScrRef` stub returning the given tuple parts. Defaults cover the + * common case so a test only overrides what it asserts on. + * + * @param ref - The scripture reference the stub reports. + * @param setScrRef - The reference setter; defaults to a noop. + * @param scrollGroupId - The active scroll-group id; defaults to `undefined` (unlinked). + * @param setScrollGroupId - The scroll-group setter; defaults to a noop. + * @returns A hook returning the assembled tuple. + */ +function makeScrollGroupHook( + ref: SerializedVerseRef, + setScrRef: (r: SerializedVerseRef) => void = () => {}, + scrollGroupId: number | undefined = undefined, + setScrollGroupId: (id: number | undefined) => void = () => {}, +) { + return (): ScrollGroupTuple => [ref, setScrRef, scrollGroupId, setScrollGroupId]; +} + +/** + * Renders {@link useInterlinearNav} inside a provider wired to the given scroll-group hook. + * + * @param hook - The `useWebViewScrollGroupScrRef` stub the provider should call. + * @returns The render-hook result whose `current` is the nav surface. + */ +function renderNav(hook: () => ScrollGroupTuple) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useInterlinearNav(), { wrapper }); +} + +/** + * Renders the nav hook with a scroll-group stub whose reference can be restaged between rerenders, + * so a cross-book navigation can be simulated. A fresh object identity is required on each change + * so the provider's `liveScrRef` memo recomputes. + * + * @param initial - The reference reported on the first render. + * @returns The render-hook result plus a `setRef` to stage the next reference (call inside `act`, + * then `rerender`). + */ +function renderNavMutable(initial: SerializedVerseRef) { + let current = initial; + const hook = (): ScrollGroupTuple => [current, () => {}, undefined, () => {}]; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const result = renderHook(() => useInterlinearNav(), { wrapper }); + return { + ...result, + setRef: (next: SerializedVerseRef) => { + current = next; + }, + }; +} + +describe('InterlinearNavContext', () => { + it('exposes the raw reference and scroll-group plumbing verbatim', () => { + const setScrRef = jest.fn(); + const setScrollGroupId = jest.fn(); + const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 4 }; + const { result } = renderNav(makeScrollGroupHook(ref, setScrRef, 2, setScrollGroupId)); + + expect(result.current.rawScrRef).toEqual(ref); + expect(result.current.scrollGroupId).toBe(2); + + act(() => result.current.navigate({ book: 'MAT', chapterNum: 1, verseNum: 1 })); + expect(setScrRef).toHaveBeenCalledWith({ book: 'MAT', chapterNum: 1, verseNum: 1 }); + + act(() => result.current.setScrollGroupId(3)); + expect(setScrollGroupId).toHaveBeenCalledWith(3); + }); + + it('passes a verse-level reference through to liveScrRef unchanged', () => { + const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 4 }; + const { result } = renderNav(makeScrollGroupHook(ref)); + + expect(result.current.liveScrRef).toEqual(ref); + }); + + it('normalizes a chapter-level (verse 0) reference to verse 1 in liveScrRef', () => { + const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 0 }; + const { result } = renderNav(makeScrollGroupHook(ref)); + + expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 1 }); + // The raw reference still reports verse 0 so the editable nav controls reflect the selection. + expect(result.current.rawScrRef).toEqual(ref); + }); + + it('keeps the current verse when the host echoes a verse-0 reference for the chapter already shown', () => { + // After a verse navigation the host re-broadcasts the chapter as a separate verse-0 reference. + // Normalizing that to verse 1 would read as a fresh move off the verse the user is on, so a + // verse-0 echo for the same book+chapter must stay sticky on the current verse. + const { result, setRef, rerender } = renderNavMutable({ + book: 'GEN', + chapterNum: 3, + verseNum: 7, + }); + expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 }); + + act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 })); + rerender(); + + expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 }); + }); + + it('normalizes a verse-0 reference that names a different chapter (a real chapter jump)', () => { + // A verse-0 reference for a chapter other than the one shown is a genuine chapter navigation, not + // an echo, so it still normalizes to that chapter's first verse. + const { result, setRef, rerender } = renderNavMutable({ + book: 'GEN', + chapterNum: 3, + verseNum: 7, + }); + + act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 })); + rerender(); + + expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 }); + }); + + describe('duplicate host deliveries', () => { + it('keeps rawScrRef and liveScrRef identity when the host re-sends a value-equal reference', () => { + // The scripture picker fires each external navigation twice in quick succession, the second + // delivery being a fresh object with identical content. The provider must hand back the + // previously adopted objects so the duplicate is invisible to consumers (no context-value + // change, no re-render churn mid-recenter). + const { result, setRef, rerender } = renderNavMutable({ + book: 'GEN', + chapterNum: 3, + verseNum: 7, + }); + const rawBefore = result.current.rawScrRef; + const liveBefore = result.current.liveScrRef; + + act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 7 })); + rerender(); + + expect(result.current.rawScrRef).toBe(rawBefore); + expect(result.current.liveScrRef).toBe(liveBefore); + }); + + it('keeps liveScrRef identity when a verse-0 chapter jump is followed by its verse-1 form', () => { + // A chapter jump can arrive as a verse-0 reference (normalized to verse 1) followed by the + // explicit verse-1 reference. The raw references differ, but both normalize to the same + // verse, so the committed liveScrRef object must be reused for the second delivery. + const { result, setRef, rerender } = renderNavMutable({ + book: 'GEN', + chapterNum: 3, + verseNum: 7, + }); + + act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 })); + rerender(); + const liveAfterJump = result.current.liveScrRef; + expect(liveAfterJump).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 }); + + act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 1 })); + rerender(); + + expect(result.current.liveScrRef).toBe(liveAfterJump); + }); + + it('reuses the previous reference when a duplicate differs only in the verse segment string', () => { + // The host fills the optional `verse` field inconsistently across its duplicate deliveries. + // Nothing in the extension consumes it, so a delivery naming the same book/chapter/verse + // must dedupe regardless. + const initial: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 7, verse: '7' }; + const { result, setRef, rerender } = renderNavMutable(initial); + + act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 7, verse: '7a' })); + rerender(); + + expect(result.current.rawScrRef).toBe(initial); + }); + + it('reuses the previous reference when a duplicate differs only in versification', () => { + // Same rationale as the `verse` field: `versificationStr` arrives inconsistently on the + // duplicate deliveries and is never consumed, so it must not defeat the dedup. + const initial: SerializedVerseRef = { + book: 'GEN', + chapterNum: 3, + verseNum: 7, + versificationStr: 'English', + }; + const { result, setRef, rerender } = renderNavMutable(initial); + + act(() => + setRef({ book: 'GEN', chapterNum: 3, verseNum: 7, versificationStr: 'Septuagint' }), + ); + rerender(); + + expect(result.current.rawScrRef).toBe(initial); + }); + }); + + it('throws when used outside a provider', () => { + expect(() => renderHook(() => useInterlinearNav())).toThrow( + 'useInterlinearNav must be used within an InterlinearNavProvider', + ); + }); + + describe('navigation origin classification', () => { + it('marks an internal navigation as consumable exactly once', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + const target: SerializedVerseRef = { book: 'MAT', chapterNum: 5, verseNum: 3 }; + + act(() => result.current.navigate(target, 'internal')); + // First consume matches and clears; a second consume of the same verse is now external. + expect(result.current.consumeInternalNav(target)).toBe(true); + expect(result.current.consumeInternalNav(target)).toBe(false); + }); + + it('does not mark an external (default) navigation as internal', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + const target: SerializedVerseRef = { book: 'MAT', chapterNum: 5, verseNum: 3 }; + + act(() => result.current.navigate(target)); + expect(result.current.consumeInternalNav(target)).toBe(false); + }); + + it('returns false when consuming a verse that was never marked internal', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + expect(result.current.consumeInternalNav({ book: 'LUK', chapterNum: 2, verseNum: 1 })).toBe( + false, + ); + }); + + it('keeps distinct internal marks for rapid successive internal navigations', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + const a: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 5 }; + const b: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 9 }; + + // Two internal navigations before either is consumed: both stay pending (set, not single slot). + act(() => { + result.current.navigate(a, 'internal'); + result.current.navigate(b, 'internal'); + }); + expect(result.current.consumeInternalNav(a)).toBe(true); + expect(result.current.consumeInternalNav(b)).toBe(true); + }); + + it('expires a stranded internal mark after the TTL so a later external navigation fades', () => { + // When React batches two rapid internal clicks (verse A then B in one frame), the host + // echoes only the final value: B's marker is consumed but A's is stranded. Once the TTL has + // passed, a later external navigation to A must classify as external (consume returns + // false), not be misread as internal by the stale marker. + jest.useFakeTimers(); + try { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + const a: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 5 }; + const b: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 10 }; + + act(() => { + result.current.navigate(a, 'internal'); + result.current.navigate(b, 'internal'); + }); + // The host coalesces the batched navigations and echoes only the final value. + expect(result.current.consumeInternalNav(b)).toBe(true); + + jest.advanceTimersByTime(INTERNAL_NAV_TTL_MS + 1); + expect(result.current.consumeInternalNav(a)).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('matches a verse-0 internal mark against the host-normalized verse-1 reference', () => { + // An internal navigation stamped at chapter granularity (verse 0) must still be consumable + // when the host echoes it back normalized to the chapter's first verse (verse 1) — the keys + // are computed through the same normalization so they cannot diverge on the verse-0 boundary. + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + + act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal')); + expect(result.current.consumeInternalNav({ book: 'GEN', chapterNum: 3, verseNum: 1 })).toBe( + true, + ); + }); + }); + + describe('cross-book fade clock', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('starts idle and does not fade on the initial load', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('fades out on a book change, then in on reportSettled, then back to idle', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + expect(result.current.fadePhase).toBe('idle'); + + // Cross-book navigation: the clock fades out and holds. + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + + // The view reports it has laid out the new book: fade back in. + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // After the fade-in duration the clock returns to idle. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('does not fade for a same-book reference change', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'GEN', chapterNum: 5, verseNum: 10 })); + rerender(); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('ignores reportSettled when no cross-book fade is awaiting it', () => { + const { result } = renderNav( + makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + ); + + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('reveals immediately via cancelFade, aborting an in-flight fade-out', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + + act(() => result.current.cancelFade()); + expect(result.current.fadePhase).toBe('idle'); + + // The fade was adopted as displayed, so a subsequent settle is a no-op (nothing awaiting). + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('cancelFade clears a pending fade-in timer so it cannot fire after reveal', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // Abort mid-fade-in: the pending in→idle timer is cleared and we settle to idle now. + act(() => result.current.cancelFade()); + expect(result.current.fadePhase).toBe('idle'); + // Advancing past the original timer must not re-fire a stale transition. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('supersedes a pending fade-in timer when a second book change settles before idle', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + // First cross-book settle leaves an in→idle timer pending. + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // A second cross-book change + settle before the first timer fires must clear the prior timer + // and start a fresh fade-in rather than letting the stale timer flip to idle early. + act(() => setRef({ book: 'LUK', chapterNum: 2, verseNum: 1 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('clears the stale fade-in timer when a second book change begins before idle', () => { + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + // First cross-book settle starts an in→idle timer (Timer A). + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // Advance partway through the timer, then trigger a second book change. The render-time + // guard must clear Timer A so it cannot fire during the second fade-out. + act(() => jest.advanceTimersByTime(100)); + act(() => setRef({ book: 'LUK', chapterNum: 2, verseNum: 1 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + + // Advance past the point where Timer A would have fired — fadePhase must stay 'out'. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('out'); + + // The second settle starts a fresh timer and proceeds normally. + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('re-engages the curtain when an external navigation lands during the fade-in', () => { + // The host resolves one picker selection as two navigations: the book change first, the + // precise target a beat later — routinely landing while the reveal is still animating. + // Re-engaging the curtain folds both into one cycle instead of fading the just-revealed + // content a second time (the "double fade"). + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'ZEP', chapterNum: 1, verseNum: 1 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // The precise target arrives mid-reveal: the curtain drops back to 'out'. + act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 1 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + + // The stale in→idle timer was cleared: advancing past it must not flip the phase. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('out'); + + // The re-anchored view settles: the curtain lifts once and the cycle completes. + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.fadePhase).toBe('idle'); + }); + + it('does not re-engage the curtain for an internal navigation echoed back during the fade-in', () => { + // A click made while the reveal is animating targets content already on screen; dropping the + // curtain over it would hide the user's own selection. + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'ZEP', chapterNum: 1, verseNum: 1 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + act(() => result.current.navigate({ book: 'ZEP', chapterNum: 1, verseNum: 4 }, 'internal')); + act(() => setRef({ book: 'ZEP', chapterNum: 1, verseNum: 4 })); + rerender(); + + expect(result.current.fadePhase).toBe('in'); + }); + + it('re-engages the curtain when an expired stranded internal mark names the mid-reveal target', () => { + // A stranded internal marker (its echo never arrived) must stop exempting the verse once the + // TTL passes: a later external navigation to that verse landing mid-reveal still re-engages + // the curtain. Markers stamp at navigate time and RECENTER_FADE_MS (500ms) is far shorter + // than the TTL (3000ms), so the clock is advanced past the TTL *before* the reveal begins — + // advancing during the 'in' phase would fire the fade-in timer to 'idle' first. + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + // Strand a marker for the eventual target: an internal navigation whose echo never arrives. + act(() => result.current.navigate({ book: 'ZEP', chapterNum: 3, verseNum: 1 }, 'internal')); + // Let the marker expire while the clock is still idle. + act(() => jest.advanceTimersByTime(INTERNAL_NAV_TTL_MS + 1)); + + // Cross-book navigation, then settle: the curtain is mid-reveal ('in'). + act(() => setRef({ book: 'ZEP', chapterNum: 1, verseNum: 1 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + // An external navigation to the stranded verse lands mid-reveal. The expired marker must not + // exempt it: the curtain re-engages. + act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 1 })); + rerender(); + expect(result.current.fadePhase).toBe('out'); + }); + + it('does not re-engage the curtain for a verse-0 echo during the fade-in', () => { + // The host's chapter re-broadcast (verse 0 for the chapter already shown) is sticky — it + // names the verse currently displayed, so it must not read as a fresh navigation mid-reveal. + const { result, rerender, setRef } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 5 })); + rerender(); + act(() => result.current.reportSettled()); + expect(result.current.fadePhase).toBe('in'); + + act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 0 })); + rerender(); + + expect(result.current.fadePhase).toBe('in'); + }); + + it('clears the pending fade-in timer on unmount', () => { + const { result, rerender, setRef, unmount } = renderNavMutable({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + act(() => setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 })); + rerender(); + act(() => result.current.reportSettled()); + // Unmounting with the in→idle timer pending must not throw when the timer would have fired. + unmount(); + expect(() => jest.advanceTimersByTime(RECENTER_FADE_MS)).not.toThrow(); + }); + }); +}); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 01ea79ce..3e8f5d44 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -6,8 +6,11 @@ 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 { InterlinearNavProvider } from '../../components/InterlinearNavContext'; 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 +211,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', @@ -249,43 +283,100 @@ const GEN_1_MULTI_BOOK: Book = { ], }; +/** + * Two-chapter GEN book: chapter 1 has verses 1-2, chapter 2 has verses 1-2. Used to exercise the + * focus-reseed guard when the host echoes a click back at chapter granularity (verse-0 / first + * verse), which a verse-exact guard would misread as the chapter's first segment. + */ +const GEN_TWO_CHAPTER_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [1, 2].flatMap((chapter) => + [1, 2].map((verse) => ({ + 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' as const, + charStart: 0, + charEnd: 4, + }, + ], + })), + ), +}; + +/** + * Wraps an `` element in an {@link InterlinearNavProvider} so the component's + * `useInterlinearNav` call resolves. `Interlinearizer` now writes the reference through the + * context's `navigate` (which calls the scroll-group hook's setter), so navigation assertions hang + * off the `navigate` spy supplied here rather than a `setScrRef` prop. + * + * @param ui - The `` element to wrap. + * @param navigate - Spy wired as the scroll-group hook's setter; receives the reference each + * `navigate` call writes. Defaults to a noop. + * @returns The element wrapped in a nav provider. + */ +function withNav(ui: ReactNode, navigate: (r: SerializedVerseRef) => void = () => {}): ReactNode { + const scrollGroupHook = (): [ + SerializedVerseRef, + (r: SerializedVerseRef) => void, + number | undefined, + (id: number | undefined) => void, + ] => [defaultScrRef, navigate, undefined, () => {}]; + return ( + + {ui} + + ); +} + /** * Renders an Interlinearizer component with sensible defaults, allowing individual props to be - * overridden per test. + * overridden per test. Wrapped in an {@link InterlinearNavProvider} via {@link withNav}; `navigate` + * is the spy that captures references the component writes through the context. * * @param options - Partial props to merge over the defaults. * @returns The render result from @testing-library/react. */ function renderInterlinearizer({ book = GEN_1_1_BOOK, - chapterSegments = GEN_1_1_BOOK.segments, continuousScroll = false, scrRef = defaultScrRef, - setScrRef = () => {}, + navigate = () => {}, hideInactiveLinkButtons = false, simplifyPhrases = false, + chapterLabelInVerse = false, }: { book?: Book; - chapterSegments?: Book['segments']; continuousScroll?: boolean; scrRef?: SerializedVerseRef; - setScrRef?: (r: SerializedVerseRef) => void; + navigate?: (r: SerializedVerseRef) => void; hideInactiveLinkButtons?: boolean; simplifyPhrases?: boolean; + chapterLabelInVerse?: boolean; } = {}) { return render( - {}} - hideInactiveLinkButtons={hideInactiveLinkButtons} - simplifyPhrases={simplifyPhrases} - />, + withNav( + {}} + hideInactiveLinkButtons={hideInactiveLinkButtons} + simplifyPhrases={simplifyPhrases} + chapterLabelInVerse={chapterLabelInVerse} + />, + navigate, + ), ); } @@ -307,13 +398,13 @@ describe('Interlinearizer', () => { }); 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,31 +412,31 @@ 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); expect(capturedSegmentViewPropsList[1].isActive).toBeFalsy(); }); - 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 }); + it('renders all segments when the reference names a verse absent from the data', () => { + const missingVerseRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 99 }; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, scrRef: missingVerseRef }); 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 }); + const mockNavigate = jest.fn(); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, navigate: mockNavigate }); capturedSegmentViewPropsList[1].onSelect?.({ book: 'GEN', chapter: 1, verse: 2 }); - expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); }); 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 +455,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, }); @@ -376,11 +467,10 @@ describe('Interlinearizer', () => { }); it('calls setScrRef with the segment ref when a token is clicked', () => { - const mockSetScrRef = jest.fn(); + const mockNavigate = jest.fn(); renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, - setScrRef: mockSetScrRef, + navigate: mockNavigate, }); act(() => { @@ -390,53 +480,59 @@ describe('Interlinearizer', () => { ); }); - expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); }); it('passes the clicked token through to ContinuousView as focusedTokenRef', () => { - // 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, - }); - - const { onSelect } = capturedSegmentViewPropsList[1]; - if (typeof onSelect !== 'function') throw new Error('Expected onSelect to be a function'); - - act(() => { - onSelect({ book: 'GEN', chapter: 1, verse: 2 }, 'GEN 1:2:0'); - }); - - // Switch to continuous-scroll mode so ContinuousView is rendered and its props captured. - capturedSegmentViewPropsList = []; - rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, - ); - - if (!capturedContinuousViewProps) - throw new Error('Expected ContinuousView to have been rendered'); - expect(capturedContinuousViewProps.focusedTokenRef).toBe('GEN 1:2:0'); + jest.useFakeTimers(); + try { + // Render in token-chip mode first so onSelect is available on SegmentView props. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: false, + }); + + const { onSelect } = capturedSegmentViewPropsList[1]; + if (typeof onSelect !== 'function') throw new Error('Expected onSelect to be a function'); + + act(() => { + onSelect({ book: 'GEN', chapter: 1, verse: 2 }, 'GEN 1:2:0'); + }); + + // Switch to continuous-scroll mode so ContinuousView is rendered and its props captured. The + // strip mount is gated behind the recenter fade, so advance past it. + capturedSegmentViewPropsList = []; + rerender( + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), + ); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + expect(capturedContinuousViewProps.focusedTokenRef).toBe('GEN 1:2:0'); + } finally { + jest.useRealTimers(); + } }); it('updates scrRef when ContinuousView reports focus moving into a different verse', () => { - const mockSetScrRef = jest.fn(); + const mockNavigate = jest.fn(); renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, - setScrRef: mockSetScrRef, + navigate: mockNavigate, }); if (!capturedContinuousViewProps) @@ -448,103 +544,141 @@ describe('Interlinearizer', () => { onFocusedTokenRefChange('GEN 1:2:0'); }); - expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); }); - it('does not update scrRef when ContinuousView focus stays within the current verse', () => { - const mockSetScrRef = jest.fn(); + 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 mockNavigate = 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, + scrRef: { book: 'EXO', chapterNum: 1, verseNum: 1 }, + navigate: mockNavigate, }); if (!capturedContinuousViewProps) throw new Error('Expected ContinuousView to have been rendered'); - mockSetScrRef.mockClear(); + mockNavigate.mockClear(); const { onFocusedTokenRefChange } = capturedContinuousViewProps; act(() => { - onFocusedTokenRefChange('GEN 1:1:0'); + // 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(); + expect(mockNavigate).not.toHaveBeenCalled(); }); - it('carries the strip focus into segment view when switching off continuousScroll', () => { - const { rerender } = renderInterlinearizer({ + it('does not update scrRef when ContinuousView focus stays within the current verse', () => { + const mockNavigate = jest.fn(); + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, - chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + navigate: mockNavigate, }); if (!capturedContinuousViewProps) throw new Error('Expected ContinuousView to have been rendered'); + mockNavigate.mockClear(); const { onFocusedTokenRefChange } = capturedContinuousViewProps; act(() => { - onFocusedTokenRefChange('GEN 1:2:0'); + onFocusedTokenRefChange('GEN 1:1:0'); }); - // Switch to segment view — Interlinearizer should carry the strip focus over. - capturedSegmentViewPropsList = []; - rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, - ); - - const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:2:0'); - expect(focused).toBeDefined(); + expect(mockNavigate).not.toHaveBeenCalled(); }); - it('falls back to the active-verse first word when switching off continuousScroll with no strip position', () => { - // 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 }, - }); + it('carries the strip focus into segment view when switching off continuousScroll', () => { + jest.useFakeTimers(); + try { + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: true, + }); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + const { onFocusedTokenRefChange } = capturedContinuousViewProps; + + act(() => { + onFocusedTokenRefChange('GEN 1:2:0'); + }); + + // Switch to segment view — Interlinearizer should carry the strip focus over. The display mode + // is gated behind the recenter fade, so advance past it for the segments to render in + // token-chip mode with the focus applied. + capturedSegmentViewPropsList = []; + rerender( + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), + ); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); - // Switch to segment view without any strip position having been reported. - capturedSegmentViewPropsList = []; - rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, - ); + const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:2:0'); + expect(focused).toBeDefined(); + } finally { + jest.useRealTimers(); + } + }); - // The fallback focuses the first word of GEN 1:1 ('GEN 1:1:0'). - const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:1:0'); - expect(focused).toBeDefined(); + it('falls back to the active-verse first word when switching off continuousScroll with no strip position', () => { + jest.useFakeTimers(); + try { + // Start in continuous mode without ContinuousView ever calling onFocusPhraseIndexChange. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: true, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + }); + + // Switch to segment view without any strip position having been reported. + capturedSegmentViewPropsList = []; + rerender( + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), + ); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + // The fallback focuses the first word of GEN 1:1 ('GEN 1:1:0'). + const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:1:0'); + expect(focused).toBeDefined(); + } finally { + jest.useRealTimers(); + } }); it('preserves an existing focusedTokenRef when switching off continuousScroll with no strip position', () => { // 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, }); @@ -558,35 +692,37 @@ describe('Interlinearizer', () => { // Switch to continuous mode (without strip reporting any position). capturedSegmentViewPropsList = []; rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), ); // Switch back to segment mode — existing focusedTokenRef should be preserved. capturedSegmentViewPropsList = []; rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), ); // 'GEN 1:2:0' was already focused, so the fallback must not overwrite it. @@ -596,56 +732,173 @@ describe('Interlinearizer', () => { expect(stillFocused).toBeDefined(); }); + it('keeps the clicked token focused when the host echoes the click back as the clicked verse', () => { + // Active verse starts at GEN 1:1. Click a token in a later chapter/verse (GEN 2:2): focus is set + // to 'GEN 2:2:0'. The host echoes the navigation back as the actual clicked verse (GEN 2:2). The + // verse-exact reseed guard must see focus already in the active verse and leave the deliberately + // clicked token alone — never reseeding to the verse's (here, the only) first word from scratch. + const { rerender } = renderInterlinearizer({ + book: GEN_TWO_CHAPTER_BOOK, + continuousScroll: false, + }); + + const clicked = capturedSegmentViewPropsList.find((p) => p.segment.id === 'GEN 2:2'); + if (!clicked || typeof clicked.onSelect !== 'function') { + throw new Error('Expected an onSelect for the GEN 2:2 segment'); + } + act(() => { + clicked.onSelect?.({ book: 'GEN', chapter: 2, verse: 2 }, 'GEN 2:2:0'); + }); + + // Host delivers the echo of the actual clicked verse. + capturedSegmentViewPropsList = []; + rerender( + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), + ); + + // Focus must remain on the deliberately clicked token. + const stillFocused = capturedSegmentViewPropsList.find( + (p) => p.focusedTokenRef === 'GEN 2:2:0', + ); + expect(stillFocused).toBeDefined(); + }); + + it('reseeds focus to the first word of the active verse on an external within-chapter jump', () => { + // A genuine external jump within a long chapter (here GEN 2:1 → GEN 2:2) must move focus to the + // newly-named verse — a chapter-wide guard would wrongly strand focus on the old verse. Focus + // starts at the active verse's first word; after the jump it must point at the new verse's word. + // The segment view's focus highlight lags through the recenter fade, so advance past it. + jest.useFakeTimers(); + try { + const { rerender } = renderInterlinearizer({ + book: GEN_TWO_CHAPTER_BOOK, + scrRef: { book: 'GEN', chapterNum: 2, verseNum: 1 }, + continuousScroll: false, + }); + + capturedSegmentViewPropsList = []; + rerender( + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), + ); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + const reseeded = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 2:2:0'); + expect(reseeded).toBeDefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('renders an inline chapter header above the first verse of each chapter', () => { + renderInterlinearizer({ + book: GEN_TWO_CHAPTER_BOOK, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + continuousScroll: false, + }); + + // One header per chapter, rendered by the list (not inside SegmentView) at each boundary. + expect(screen.getByText('Chapter 1')).toBeInTheDocument(); + expect(screen.getByText('Chapter 2')).toBeInTheDocument(); + expect(screen.queryByText('Chapter 3')).not.toBeInTheDocument(); + }); + + it('omits inline chapter headers when chapterLabelInVerse is set', () => { + renderInterlinearizer({ + book: GEN_TWO_CHAPTER_BOOK, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + continuousScroll: false, + chapterLabelInVerse: true, + }); + + expect(screen.queryByText('Chapter 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Chapter 2')).not.toBeInTheDocument(); + }); + 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 }), ).not.toBeInTheDocument(); }); - it('snap button calls scrollIntoView on the active segment', () => { - renderInterlinearizer({ chapterSegments: GEN_1_1_BOOK.segments }); - - act(() => { - screen.getByRole('button', { name: /scroll to active verse/i }).click(); - }); - - expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'auto', - block: 'start', - }); + it('snap button fades, recenters, then scrolls the active segment to the top', () => { + jest.useFakeTimers(); + try { + renderInterlinearizer({ book: GEN_1_1_BOOK }); + + act(() => { + screen.getByRole('button', { name: /scroll to active verse/i }).click(); + }); + + // The button always fade-recenters (so a verse outside the window still comes into view), so the + // snap only lands after the fade timeout rebuilds the window behind the curtain. + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'start', + }); + } finally { + jest.useRealTimers(); + } }); it('leaves focusedTokenRef undefined when switching off continuousScroll with no strip position and no matching segment', () => { // 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 }, }); capturedSegmentViewPropsList = []; rerender( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'view' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), ); // No segment matches verse 99 so focusedTokenRef stays undefined for all views. @@ -654,40 +907,42 @@ describe('Interlinearizer', () => { it('renders EditPhraseControls toolbar when phraseMode is edit', () => { render( - {}} - analysisLanguage="und" - phraseMode={{ - kind: 'edit', - phraseId: 'phrase-1', - originalTokens: [{ tokenRef: 'GEN 1:1:0', surfaceText: 'In' }], - }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), ); expect(screen.getByTestId('done-edit-btn')).toBeInTheDocument(); }); it('renders UnlinkPhraseConfirm toolbar when phraseMode is confirm-unlink', () => { render( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'confirm-unlink', phraseId: 'phrase-1' }} - setPhraseMode={() => {}} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + />, + ), ); expect(screen.getByTestId('unlink-confirm')).toBeInTheDocument(); }); @@ -696,18 +951,19 @@ describe('Interlinearizer', () => { const setPhraseMode = jest.fn(); const originalTokens = [{ tokenRef: 'GEN 1:1:0', surfaceText: 'In' }]; render( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'edit', phraseId: 'phrase-1', originalTokens, revert: true }} - setPhraseMode={setPhraseMode} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + , + ), ); expect(mockUpdatePhrase).toHaveBeenCalledWith('phrase-1', originalTokens); expect(setPhraseMode).toHaveBeenCalledWith({ kind: 'view' }); @@ -716,20 +972,162 @@ describe('Interlinearizer', () => { it('calls updatePhrase and resets to view mode even when the phrase has 0 tokens (all removed)', () => { const setPhraseMode = jest.fn(); render( - {}} - analysisLanguage="und" - phraseMode={{ kind: 'edit', phraseId: 'phrase-1', originalTokens: [], revert: true }} - setPhraseMode={setPhraseMode} - hideInactiveLinkButtons={false} - simplifyPhrases={false} - />, + withNav( + , + ), ); 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(); + try { + const book = makeLargeBook(60); + const props = { + book, + continuousScroll: false, + analysisLanguage: 'und', + phraseMode: { kind: 'view' } as const, + setPhraseMode: () => {}, + hideInactiveLinkButtons: false, + simplifyPhrases: false, + chapterLabelInVerse: false, + }; + const { container, rerender } = render( + withNav( + , + ), + ); + + // The inner list-fade wrapper (distinguished by tw:gap-2) is the one that fades for external + // navigation; the outer interlinearizer wrapper only fades on a continuous-scroll toggle. + const list = container.querySelector('.tw\\:gap-2.tw\\:transition-opacity'); + expect(list).toHaveStyle({ opacity: '1' }); + + // Navigate far past the rendered window so the hook fades out before rebuilding. + act(() => { + rerender( + withNav( + , + ), + ); + }); + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '0', + }); + + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + } finally { + 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 navigate flows back as the scrRef prop, exercising the + // internal-nav classification that suppresses the recenter fade. `setRef` is wired as the + // context's navigate sink (via withNav), so `navigate(ref, 'internal')` updates the prop here. + let updateRef: (r: SerializedVerseRef) => void = () => {}; + /** + * Stateful wrapper that feeds its own `ref` state as the `scrRef` prop to + * {@link Interlinearizer}, letting `updateRef` simulate navigate calls from outside the + * component tree. + * + * @returns The wrapped Interlinearizer element. + */ + function Wrapper() { + const [ref, setRef] = useState({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + updateRef = setRef; + return ( + {}} + hideInactiveLinkButtons={false} + simplifyPhrases={false} + chapterLabelInVerse={false} + /> + ); + } + try { + const { container } = render(withNav(, (r) => updateRef(r))); + + // 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' }); + } finally { + jest.useRealTimers(); + } + }); + + it('fades the whole interlinearizer out and back in across a continuous-scroll toggle', () => { + jest.useFakeTimers(); + try { + const book = makeLargeBook(60); + const props = { + book, + analysisLanguage: 'und', + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + phraseMode: { kind: 'view' } as const, + setPhraseMode: () => {}, + hideInactiveLinkButtons: false, + simplifyPhrases: false, + chapterLabelInVerse: false, + }; + const { container, rerender } = render( + withNav(), + ); + + // The outer wrapper (tw:flex-1, distinct from the inner list's tw:gap-2 wrapper) starts opaque. + const outer = container.querySelector('.tw\\:flex-1.tw\\:transition-opacity'); + expect(outer).toHaveStyle({ opacity: '1' }); + + // Toggle continuous scroll on: the whole view fades out until the mode swap lands at midpoint. + act(() => { + rerender(withNav()); + }); + expect(container.querySelector('.tw\\:flex-1.tw\\:transition-opacity')).toHaveStyle({ + opacity: '0', + }); + + // At the recenter midpoint the rendered mode catches up and the view fades back in. + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + expect(container.querySelector('.tw\\:flex-1.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 4bb53f6e..5a352e6e 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -7,9 +7,10 @@ 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 { RECENTER_FADE_MS } from '../../components/recenter-fade'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; import { emptyAnalysis } from '../../types/empty-factories'; @@ -28,6 +29,8 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({ onHideInactiveLinkButtonsChange, simplifyPhrases, onSimplifyPhrasesChange, + chapterLabelInVerse, + onChapterLabelInVerseChange, }: { continuousScroll: boolean; onContinuousScrollChange: (v: boolean) => void; @@ -35,6 +38,8 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({ onHideInactiveLinkButtonsChange: (v: boolean) => void; simplifyPhrases: boolean; onSimplifyPhrasesChange: (v: boolean) => void; + chapterLabelInVerse: boolean; + onChapterLabelInVerseChange: (v: boolean) => void; }) => (
), })); @@ -74,7 +86,6 @@ jest.mock('../../components/ContinuousView', () => ({ type CapturedInterlinearizerProps = { book: Book; - chapterSegments: Segment[]; continuousScroll: boolean; scrRef: SerializedVerseRef; setScrRef: (newScrRef: SerializedVerseRef) => void; @@ -85,16 +96,27 @@ type CapturedInterlinearizerProps = { setPhraseMode: Dispatch>; hideInactiveLinkButtons: boolean; simplifyPhrases: boolean; + chapterLabelInVerse: boolean; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; - -jest.mock('../../components/Interlinearizer', () => ({ - __esModule: true, - default: (props: CapturedInterlinearizerProps) => { - capturedInterlinearizerProps = props; - return
; - }, -})); +let interlinearizerMountCount = 0; + +jest.mock('../../components/Interlinearizer', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { useEffect } = require('react'); + return { + __esModule: true, + default: (props: CapturedInterlinearizerProps) => { + capturedInterlinearizerProps = props; + // Count mounts so tests can distinguish a remount (book change) from an in-place update. + // eslint-disable-next-line react-hooks/rules-of-hooks -- stub render fn acts as a component + useEffect(() => { + interlinearizerMountCount += 1; + }, []); + return
; + }, + }; +}); /** Minimal project summary used across modal interaction tests. */ type MockProject = { @@ -238,14 +260,19 @@ jest.mock('../../components/modals/ProjectModals', () => ({ }, })); -/** Returns a `useWebViewScrollGroupScrRef` hook stub fixed to GEN 1:1. */ -function makeScrollGroupHook() { +/** + * Returns a `useWebViewScrollGroupScrRef` hook stub fixed to a scripture reference. + * + * @param ref - The reference the stub reports; defaults to GEN 1:1. + * @returns A hook returning `[ref, noop setScrRef, undefined scrollGroupId, noop setter]`. + */ +function makeScrollGroupHook(ref: SerializedVerseRef = defaultScrRef) { return (): [ SerializedVerseRef, (r: SerializedVerseRef) => void, number | undefined, (id: number | undefined) => void, - ] => [defaultScrRef, () => {}, undefined, () => {}]; + ] => [ref, () => {}, undefined, () => {}]; } /** @@ -256,7 +283,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 +290,6 @@ function mockBookData( ): void { jest.mocked(useInterlinearizerBookData).mockReturnValue({ book: GEN_1_1_BOOK, - chapterSegments: [], isLoading: false, bookError: undefined, tokenizeError: undefined, @@ -320,6 +345,7 @@ function mockSettings( describe('InterlinearizerLoader', () => { beforeEach(() => { capturedInterlinearizerProps = undefined; + interlinearizerMountCount = 0; mockBookData(); mockOptimisticSetting(); mockSendCommand.mockResolvedValue(undefined); @@ -359,6 +385,46 @@ describe('InterlinearizerLoader', () => { expect(screen.getByTestId('interlinearizer')).toBeInTheDocument(); }); + it('normalizes a chapter-level (verse 0) reference to verse 1 before passing it to Interlinearizer', () => { + render( + , + ); + + expect(capturedInterlinearizerProps?.scrRef).toEqual({ + book: 'GEN', + chapterNum: 3, + verseNum: 1, + }); + }); + + it('passes a verse-level reference through to Interlinearizer unchanged', () => { + render( + , + ); + + expect(capturedInterlinearizerProps?.scrRef).toEqual({ + book: 'GEN', + chapterNum: 3, + verseNum: 4, + }); + }); + it('shows Loading when book data has not arrived', () => { mockBookData({ book: undefined, isLoading: true }); render( @@ -517,6 +583,32 @@ describe('InterlinearizerLoader', () => { expect(onChangeByKey.get('interlinearizer.simplifyPhrases')).toHaveBeenCalledWith(true); }); + it('passes chapterLabelInVerse=false to Interlinearizer by default', () => { + render( + , + ); + + expect(capturedInterlinearizerProps?.chapterLabelInVerse).toBe(false); + }); + + it('wires ViewOptionsDropdown chapter-label-in-verse to onChange from useOptimisticBooleanSetting', async () => { + const onChangeByKey = mockOptimisticSetting(); + render( + , + ); + + await userEvent.click(screen.getByTestId('chapter-label-in-verse-toggle')); + expect(onChangeByKey.get('interlinearizer.chapterLabelInVerse')).toHaveBeenCalledWith(true); + }); + it('passes continuousScroll=true to Interlinearizer when the setting is true', () => { mockOptimisticSetting(true); render( @@ -1025,4 +1117,160 @@ describe('InterlinearizerLoader', () => { expect(capturedInterlinearizerProps?.phraseMode).toEqual({ kind: 'view' }); }); }); + + describe('cross-book fade curtain', () => { + /** + * Reads the live opacity of the book-fade wrapper the loader renders from the context's fade + * phase. + * + * @returns The wrapper's inline `opacity` style value. + */ + function fadeOpacity(): string { + return screen.getByTestId('book-fade-wrapper').style.opacity; + } + + /** + * Builds a scroll-group hook whose reference can be restaged between rerenders. A fresh object + * identity is required each change so the provider's `liveScrRef` memo recomputes. + * + * @param initial - The reference reported on the first render. + * @returns A `[hook, setRef]` pair. + */ + function makeMutableScrollGroupHook( + initial: SerializedVerseRef, + ): [ + () => [SerializedVerseRef, () => void, undefined, () => void], + (n: SerializedVerseRef) => void, + ] { + let current = initial; + const hook = (): [SerializedVerseRef, () => void, undefined, () => void] => [ + current, + () => {}, + undefined, + () => {}, + ]; + return [ + hook, + (next) => { + current = next; + }, + ]; + } + + /** + * Renders the loader with a mutable scroll-group hook, returning a `rerenderNow` that rebuilds + * a fresh element so React re-invokes the component (the stub mutates a closure variable, not + * state, so an identical element would let React bail out). + * + * @param initial - The scroll-group reference reported on the first render. + * @returns `setRef` to stage the next reference and `rerenderNow` to re-render with it. + */ + function renderLoader(initial: SerializedVerseRef) { + const [scrollGroupHook, setRef] = makeMutableScrollGroupHook(initial); + const webViewState = makeWebViewState(); + const buildUi = () => ( + + ); + const { rerender } = render(buildUi()); + return { setRef, rerenderNow: () => rerender(buildUi()) }; + } + + it('fades the content out the moment scrRef names a new book', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 1 }); + // Initial GEN load shows no fade. + expect(fadeOpacity()).toBe('1'); + + // External jump to MAT: the context detects the book change and the curtain fades out. + setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 }); + mockBookData({ book: undefined, isLoading: true }); + rerenderNow(); + expect(fadeOpacity()).toBe('0'); + }); + + it('drops the curtain instantly (no transition) during the fade-out', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 1 }); + const wrapper = () => screen.getByTestId('book-fade-wrapper'); + // At idle the shared recenter timing is armed for the next rise. + expect(wrapper().style.transitionDuration).toBe(`${RECENTER_FADE_MS}ms`); + + // Cross-book jump: the old book is swapped for Loading… in the same commit, so a gradual + // descent has nothing to fade — it would only let a fast-loading new book ghost in at + // partial opacity (the "false-start fade"). The descent must be instant. + setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 }); + mockBookData({ book: undefined, isLoading: true }); + rerenderNow(); + expect(fadeOpacity()).toBe('0'); + expect(wrapper().style.transitionDuration).toBe('0ms'); + }); + + it('shows the Loading curtain (not the old book) during a cross-book swap', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 5 }); + expect(screen.getByTestId('interlinearizer')).toBeInTheDocument(); + + // Cross-book jump to MAT while the loaded book is still GEN (the window before the USJ arrives / + // Interlinearizer remounts). Rather than leave the previous book's views mounted — where they + // would show through the fade as the swap happens — the loader shows the Loading curtain, so + // nothing of either book is visible until the new one mounts and fades in. + setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 }); + rerenderNow(); + expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + // Once MAT's book data arrives, Interlinearizer mounts on it and receives the live MAT ref. + mockBookData({ book: { ...GEN_1_1_BOOK, id: 'MAT', bookRef: 'MAT' } }); + rerenderNow(); + expect(capturedInterlinearizerProps?.scrRef).toEqual({ + book: 'MAT', + chapterNum: 5, + verseNum: 3, + }); + }); + + it('remounts Interlinearizer on a book change but not on a same-book verse change', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 1 }); + expect(interlinearizerMountCount).toBe(1); + + // A same-book verse change must keep the same Interlinearizer instance (no remount): its + // scroll/focus state and in-component recenter fade carry the within-book navigation. + setRef({ book: 'GEN', chapterNum: 1, verseNum: 40 }); + rerenderNow(); + expect(interlinearizerMountCount).toBe(1); + + // A book change must tear down the old instance and mount a fresh one keyed by the new book, so + // it never updates in place against carried-over (wrong-book) scroll/focus state — the shuffle + // that surfaced before the curtain settled. + setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 }); + mockBookData({ book: { ...GEN_1_1_BOOK, id: 'MAT', bookRef: 'MAT' } }); + rerenderNow(); + expect(interlinearizerMountCount).toBe(2); + }); + + it('reveals the error instead of staying faded when the new book fails to load', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 1 }); + expect(fadeOpacity()).toBe('1'); + + // Cross-book nav whose target book errors: cancelFade must reveal the content rather than + // leave the error hidden behind a curtain that will never receive a settle. + setRef({ book: 'MAT', chapterNum: 5, verseNum: 3 }); + mockBookData({ book: undefined, bookError: 'No USJ book available' }); + rerenderNow(); + expect(fadeOpacity()).toBe('1'); + expect(screen.getByText('No USJ book available')).toBeInTheDocument(); + }); + + it('does not fade for a same-book external navigation', () => { + const { setRef, rerenderNow } = renderLoader({ book: 'GEN', chapterNum: 1, verseNum: 1 }); + expect(fadeOpacity()).toBe('1'); + + // A verse change within the same book keeps Interlinearizer mounted; the loader curtain stays + // up (its own in-component fade handles within-book recenters). + setRef({ book: 'GEN', chapterNum: 1, verseNum: 40 }); + rerenderNow(); + expect(fadeOpacity()).toBe('1'); + }); + }); }); diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index 76e4eee2..4d6c6193 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -256,6 +256,19 @@ describe('PhraseBox', () => { expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveFocus(); }); + it('forwards box-click focus with preventScroll so the list never realigns under the click', async () => { + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + renderBox(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + await userEvent.click(phraseBox ?? document.body); + + // The clicked box is already on screen; the browser's default scroll-focused-input-into-view + // would realign the segment list (the first input can sit on another wrapped row), so the + // forwarded focus must opt out of scrolling. + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + it('clicking a nested non-chip element inside the box also focuses the first gloss input', async () => { const onFocusPhrase = jest.fn(); renderBox( diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 4af6ea69..cf560a30 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -192,6 +192,7 @@ function requiredProps(): { wordTokenByRef: ReadonlyMap; hideInactiveLinkButtons: boolean; simplifyPhrases: boolean; + chapterLabelInVerse: boolean; } { return { displayMode: 'token-chip', @@ -209,6 +210,7 @@ function requiredProps(): { wordTokenByRef: new Map(), hideInactiveLinkButtons: false, simplifyPhrases: false, + chapterLabelInVerse: false, }; } @@ -262,6 +264,27 @@ describe('SegmentView', () => { expect(screen.getByText('1')).toBeInTheDocument(); }); + it('shows a bare verse number by default', () => { + render(, withAnalysisStore); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.queryByText('1:1')).not.toBeInTheDocument(); + }); + + it('folds the chapter into the verse label when chapterLabelInVerse is set', () => { + render(, withAnalysisStore); + + expect(screen.getByText('1:1')).toBeInTheDocument(); + }); + + it('never renders the inline chapter header (the list owns it)', () => { + const { rerender } = render(, withAnalysisStore); + expect(screen.queryByText('Chapter 1')).not.toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('Chapter 1')).not.toBeInTheDocument(); + }); + it('sets aria-current="true" when isActive is true', () => { const { container } = render(, withAnalysisStore); diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index 3ed1218f..98a868b7 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -2,7 +2,7 @@ /// /// -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AssignmentStatus, Token, TokenSnapshot } from 'interlinearizer'; import { AnalysisStoreProvider } from '../../components/AnalysisStore'; @@ -187,6 +187,55 @@ describe('TokenChip', () => { expect(handleFocus).not.toHaveBeenCalled(); }); + it('focuses the gloss input without native scrolling on a surface-text mouse-down', () => { + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + render( + + + , + ); + + // Clicking the word hits the label, whose native activation would forward focus to the input + // with the browser's default scroll-into-view — realigning the segment list under the click. + // The mouse-down handler must preempt it: default prevented, focus forwarded with + // preventScroll. + const defaultAllowed = fireEvent.mouseDown(screen.getByText('hello')); + + expect(defaultAllowed).toBe(false); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveFocus(); + }); + + it('leaves a mouse-down on the gloss input itself to the input handler', () => { + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + render( + + + , + ); + + // The input's own handler focuses once with preventScroll; the label handler (which the event + // bubbles to) must stand down rather than focus a second time. + fireEvent.mouseDown(screen.getByRole('textbox', { name: 'Gloss for hello' })); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('does not intercept a surface-text mouse-down when disabled', () => { + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + render( + + + , + ); + + const defaultAllowed = fireEvent.mouseDown(screen.getByText('hello')); + + expect(defaultAllowed).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + }); + it('renders remove button when onRemove is provided', () => { render( diff --git a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx index 2f83f8ee..acdec59e 100644 --- a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx +++ b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx @@ -25,6 +25,8 @@ const DEFAULT_PROPS = { onHideInactiveLinkButtonsChange: jest.fn(), simplifyPhrases: false, onSimplifyPhrasesChange: jest.fn(), + chapterLabelInVerse: false, + onChapterLabelInVerseChange: jest.fn(), }; describe('ViewOptionsDropdown', () => { @@ -195,4 +197,31 @@ describe('ViewOptionsDropdown', () => { expect(onSimplifyPhrasesChange).toHaveBeenCalledWith(true); }); }); + + describe('chapter label in verse toggle', () => { + it('reflects the checked value', async () => { + render(); + await userEvent.click(screen.getByTestId('view-options-button')); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[3]).toBeChecked(); + }); + + it('calls onChapterLabelInVerseChange when toggled', async () => { + const onChapterLabelInVerseChange = jest.fn(); + render( + , + ); + await userEvent.click(screen.getByTestId('view-options-button')); + + const checkboxes = screen.getAllByRole('checkbox'); + await userEvent.click(checkboxes[3]); + + expect(onChapterLabelInVerseChange).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index 7448a269..0fa619ae 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', () => { @@ -269,6 +253,61 @@ describe('useInterlinearizerBookData', () => { ); }); + it('preserves book identity when PAPI delivers a duplicate result for the same book', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const usjPayload = { USJ: 'mock-usj' }; + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [usjPayload, jest.fn(), false], + }); + + const { result, rerender } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + const firstBook = result.current.book; + expect(firstBook).toBe(TEST_BOOK); + + const callsBefore = jest.mocked(extractBookFromUsj).mock.calls.length; + + const duplicatePayload = { USJ: 'mock-usj' }; + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [duplicatePayload, jest.fn(), false], + }); + + rerender(); + + expect(result.current.book).toBe(firstBook); + expect(jest.mocked(extractBookFromUsj).mock.calls.length).toBe(callsBefore); + }); + + it('re-tokenizes when PAPI delivers genuinely new content', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [{ USJ: 'first-usj' }, jest.fn(), false], + }); + + const { result, rerender } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBe(TEST_BOOK); + + const updatedBook: Book = { ...TEST_BOOK, textVersion: 'v2' }; + jest.mocked(tokenizeBook).mockReturnValue(updatedBook); + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [{ USJ: 'updated-usj' }, jest.fn(), false], + }); + + rerender(); + + expect(result.current.book).toBe(updatedBook); + expect(jest.mocked(extractBookFromUsj)).toHaveBeenCalledTimes(2); + }); + it('logs tokenization error with the resolved writing system', () => { const platformError: PlatformError = { message: 'Setting unavailable', diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts new file mode 100644 index 00000000..f02cd8e6 --- /dev/null +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -0,0 +1,1388 @@ +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, Segment } from 'interlinearizer'; +import { act, fireEvent, renderHook } from '@testing-library/react'; +import { useRef } from 'react'; +import useSegmentWindow from '../../hooks/useSegmentWindow'; +import { verseKey } from '../../components/InterlinearNavContext'; +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, + focusedTokenRef?: string, + onSettled?: () => void, +) { + const container = document.createElement('div'); + document.body.appendChild(container); + // Mirrors the context's internal-nav classification: a test calls `markInternal(ref)` to mimic an + // internally-originated navigation (a segment/strip click) before a rerender; the hook's + // `consumeInternalNav` matches+clears it, exactly as the real context does. + const pendingInternal = new Set(); + const markInternal = (ref: SerializedVerseRef) => pendingInternal.add(verseKey(ref)); + const consumeInternalNav = (ref: SerializedVerseRef) => { + const key = verseKey(ref); + if (!pendingInternal.has(key)) return false; + pendingInternal.delete(key); + return true; + }; + // Records each gated continuous-scroll value the hook reports at a recenter midpoint, so a test can + // assert the strip-visibility flip lands with (not after) the window rebuild. + const displayContinuousScrollReports: boolean[] = []; + const onDisplayContinuousScrollChange = (v: boolean) => displayContinuousScrollReports.push(v); + const hook = renderHook< + ReturnType, + { b: Book; ref: SerializedVerseRef; focus?: string | undefined; cont?: boolean } + >( + ({ b, ref, focus, cont }) => { + const scrollContainerRef = useRef(container); + return useSegmentWindow({ + book: b, + scrRef: ref, + focusedTokenRef: focus, + continuousScroll: cont ?? false, + scrollContainerRef, + consumeInternalNav, + onDisplayContinuousScrollChange, + onSettled, + }); + }, + { initialProps: { b: book, ref: scrRef, focus: focusedTokenRef, cont: false } }, + ); + return { + ...hook, + container, + markInternal, + hasPendingInternal: (ref: SerializedVerseRef) => pendingInternal.has(verseKey(ref)), + displayContinuousScrollReports, + }; +} + +/** + * 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 }; +} + +/** + * Installs a stub `ResizeObserver` that records the most recently created callback (and the + * elements it observes) and returns a `fire` helper (invokes it inside `act`) plus a `restore` to + * put the original observer back. Shared by every test that drives the scroll-compensation / + * re-snap observer by hand, so the stub class is declared once at module scope rather than + * re-declared in each test. + * + * @returns `fire` to invoke the recorded observer callback, `observedTargets` to read the elements + * the most recent observer watches, and `restore` to reinstate the original. + */ +function installResizeObserver(): { + fire: () => void; + observedTargets: () => Element[]; + restore: () => void; +} { + const original = global.ResizeObserver; + let callback: ResizeObserverCallback | undefined; + let observed: Element[] = []; + const stub: ResizeObserver = { observe() {}, unobserve() {}, disconnect() {} }; + class StubResizeObserver implements ResizeObserver { + /** @param cb - Stored so a test can fire it on demand. */ + constructor(cb: ResizeObserverCallback) { + callback = cb; + observed = []; + } + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + observe(el: Element) { + observed.push(el); + } + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + unobserve() {} + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + disconnect() { + observed = []; + } + } + global.ResizeObserver = StubResizeObserver; + return { + fire: () => + act(() => { + callback?.([], stub); + }), + observedTargets: () => [...observed], + restore: () => { + global.ResizeObserver = original; + }, + }; +} + +/** + * Stubs `getBoundingClientRect` on an element to report fixed top and bottom edges, so the window + * hook's geometry reads (cull walks, extend anchors, sentinel offsets) are deterministic in jsdom. + * + * @param el - The element to stub. + * @param top - The `top` value the rect should report. + * @param bottom - The `bottom` value the rect should report; defaults to `top` (zero height). + */ +function stubRect(el: Element, top: number, bottom: number = top): void { + el.getBoundingClientRect = () => ({ + top, + bottom, + left: 0, + right: 0, + width: 0, + height: bottom - top, + x: 0, + y: top, + toJSON: () => ({}), + }); +} + +/** + * Mounts one stub segment root per id into `container`, carrying the `data-segment-id` attribute + * the window hook uses to enumerate mounted segments for cull measurement and extend anchoring. + * + * @param container - The scroll container to mount into. + * @param ids - Segment ids in window order. + * @returns The mounted elements, index-aligned with `ids`. + */ +function mountSegmentEls(container: HTMLElement, ids: readonly string[]): HTMLElement[] { + return ids.map((id) => { + const el = document.createElement('div'); + el.setAttribute('data-segment-id', id); + container.appendChild(el); + return el; + }); +} + +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 holds the anchor segment still 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; + // Mount the window's segment roots so the extend can anchor on the old first segment. The + // prepend pushes that anchor down by 300px (100 → 400); the correction must add exactly that + // delta to scrollTop so the visible content holds still. + const els = mountSegmentEls( + container, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], 100); + container.scrollTop = 50; + act(() => { + global.triggerIntersection(top, true); + stubRect(els[0], 400); + }); + + expect(result.current.windowSegments[0].id).not.toBe(firstBefore); + expect(container.scrollTop).toBe(350); + }); + + it('holds the anchor segment still when a bottom extend culls content above the viewport', () => { + const book = makeBook(60, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // Container viewport spans [0, 600); the first two segments sit far above the retention line + // (bottom < -800), so the extend culls them. Removing their height shifts the old last segment + // (the anchor) up by 50px across the mutation; the correction must subtract that delta. + stubRect(container, 0, 600); + const els = mountSegmentEls( + container, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], -1200, -1000); + stubRect(els[1], -1000, -850); + const anchor = els[els.length - 1]; + stubRect(anchor, 500); + container.scrollTop = 1700; + act(() => { + global.triggerIntersection(bottom, true); + stubRect(anchor, 450); + }); + + expect(result.current.windowSegments[0].id).toBe('GEN 1:3'); + expect(container.scrollTop).toBe(1650); + }); + + it('does not cull segments that are still within the retention margin', () => { + const book = makeBook(60, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // The first segment ends 700px above the viewport — beyond the sentinel margin but inside the + // retention line (800px) — so the extend must keep it mounted. + stubRect(container, 0, 600); + const els = mountSegmentEls( + container, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], -900, -700); + act(() => global.triggerIntersection(bottom, true)); + + expect(result.current.windowSegments[0].id).toBe('GEN 1:1'); + }); + + it('culls far-below segments when a top extend prepends earlier ones', () => { + 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, + ); + + // Container viewport spans [0, 600); the last two segments start beyond the retention line + // below it (top > 1400), so the top extend culls them from the bottom edge. + stubRect(container, 0, 600); + const ids = result.current.windowSegments.map((s) => s.id); + const els = mountSegmentEls(container, ids); + stubRect(els[els.length - 2], 1500, 1600); + stubRect(els[els.length - 1], 1600, 1700); + act(() => global.triggerIntersection(top, true)); + + const after = result.current.windowSegments.map((s) => s.id); + expect(after).not.toContain(ids[ids.length - 1]); + expect(after).not.toContain(ids[ids.length - 2]); + expect(after).toContain(ids[ids.length - 3]); + }); + + it('skips the scroll correction when the anchor segment was unmounted across the mutation', () => { + 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 els = mountSegmentEls( + container, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], 100); + container.scrollTop = 50; + // Remove the anchor element before the layout effect measures it (as React would when the + // segment unmounts in the same commit): the correction must stand down rather than measure a + // detached rect. + act(() => { + global.triggerIntersection(top, true); + els[0].remove(); + }); + + expect(container.scrollTop).toBe(50); + }); + + 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 hard cap when nothing is cullable', () => { + const book = makeBook(200, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + + // With no segment roots mounted (jsdom reports no geometry) nothing is ever cullable, so growth + // stops exactly at the hard cap: later extends are skipped outright. + for (let i = 0; i < 30; i += 1) { + act(() => global.triggerIntersection(bottom, true)); + } + expect(result.current.windowSegments).toHaveLength(120); + }); + + 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('fades and recenters when the segments identity changes at the same anchor index', () => { + // A book swap (or a re-tokenized book) hands the hook a new `segments` array whose anchor can + // resolve to the same index as before; the identity check must still detect the change and + // recenter rather than leaving the window on stale segment objects. + const book = makeBook(10, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + expect(result.current.isFaded).toBe(false); + + act(() => rerender({ b: makeBook(10, 0), ref: { book: 'GEN', chapterNum: 1, verseNum: 1 } })); + + expect(result.current.isFaded).toBe(true); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + 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('reports the gated continuous-scroll value only at the recenter midpoint', () => { + const book = makeBook(60, 0); + const { rerender, displayContinuousScrollReports } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 1 }, + undefined, + ); + + // Toggle continuous scroll on while triggering a recenter (external nav). The report must NOT fire + // during the fade-out — only when the window rebuilds at the midpoint, so the parent's strip + // mounts in the same commit. + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 }, cont: true })); + expect(displayContinuousScrollReports).toEqual([]); + + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(displayContinuousScrollReports).toEqual([true]); + }); + + it('moves displayScrRef immediately for internal navigation (no fade)', () => { + const book = makeBook(60, 0); + const { result, rerender, markInternal } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + const target: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + markInternal(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('grows the snap spacer when scrollIntoView cannot reach the top', () => { + 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); + + const spacer = document.createElement('div'); + spacer.setAttribute('data-snap-spacer', ''); + container.appendChild(spacer); + + // Simulate scrollIntoView failing to reach the top: after the call the target still reports a + // positive offset below the container top (the content below is too short for the browser to + // scroll the target all the way up). + const containerRect = { top: 0, left: 0, right: 0, bottom: 400, width: 0, height: 400 }; + jest + .spyOn(container, 'getBoundingClientRect') + .mockReturnValue({ ...containerRect, x: 0, y: 0, toJSON: () => containerRect }); + const activeRect = { top: 30, left: 0, right: 0, bottom: 60, width: 0, height: 30 }; + jest + .spyOn(active, 'getBoundingClientRect') + .mockReturnValue({ ...activeRect, x: 0, y: 0, toJSON: () => activeRect }); + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + expect(scrollIntoView).toHaveBeenCalledTimes(2); + expect(spacer.style.height).toBe('30px'); + }); + + it('does not grow the snap spacer when scrollIntoView reaches the top', () => { + 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); + + const spacer = document.createElement('div'); + spacer.setAttribute('data-snap-spacer', ''); + spacer.style.height = '50px'; + container.appendChild(spacer); + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + // scrollIntoView succeeded (remainingOffset is 0 in jsdom) — spacer stays at 0 from the reset. + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(spacer.style.height).toBe('0px'); + }); + + 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 loop is still pending. + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // Flushing the first animation frame re-snaps against the now-painted layout. + act(() => jest.advanceTimersByTime(16)); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + + it('does not re-snap on idle frames after the single post-paint snap', () => { + 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)); + // Layout-effect snap: 1. The post-paint rAF re-snap has not run yet. + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // The post-paint frame re-snaps once against the painted layout: 2 total. + act(() => jest.advanceTimersByTime(16)); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + + // No further resizes fire (jsdom doesn't lay out), so the event-driven snap stays quiet — unlike + // the old per-frame loop it does not keep snapping on idle frames. The quiet timer then reports + // settled and the deadline elapses with no extra snaps. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS * 2)); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + + it('re-snaps against each later settling wave relayed through the compensation observer', () => { + const { fire, restore } = installResizeObserver(); + try { + const book = makeBook(60, 0); + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const onSettled = jest.fn(); + const { container, result, rerender } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 1 }, + undefined, + onSettled, + ); + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + // Mount the segment wrapper so the compensation/relay observer subscribes. + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + act(() => result.current.contentRef(wrapper)); + + act(() => jest.advanceTimersByTime(16)); + onSettled.mockClear(); + + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + // The post-paint rAF re-snaps once against the painted layout. + act(() => jest.advanceTimersByTime(16)); + const afterFirstSnap = scrollIntoView.mock.calls.length; + + // Each later settling wave (arc padding applied, strip mounted) fires the resize observer; while + // the recenter is in flight that re-snaps the verse rather than compensating. + fire(); + fire(); + expect(scrollIntoView.mock.calls.length).toBe(afterFirstSnap + 2); + + // Once the waves stop the quiet window elapses and the recenter reports settled exactly once. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(onSettled).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + + 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('snaps the active verse to the top on a fresh mount whose anchor sits mid-book', () => { + // A cross-book remount mounts this hook fresh with the new book centered on a mid-book anchor. + // Without a mount snap the verse renders mid-window, below the fold, at scrollTop 0. + const book = makeBook(60, 0); + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { container } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 30 }); + + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + // The mount-snap loop runs on this fresh mid-book mount (skipped when the anchor is at the book + // start), pulling the active verse to the top behind the loader curtain. + act(() => jest.advanceTimersByTime(16)); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); + }); + + it('fires onSettled on the next frame for a first mount that needs no snap', () => { + const book = makeBook(60, 0); + const onSettled = jest.fn(); + // Anchor at the book start: no mount snap, so settle fires once the next frame paints. + renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 1 }, undefined, onSettled); + + expect(onSettled).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(16)); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + it('fires onSettled once the mid-book mount snap settles', () => { + const book = makeBook(60, 0); + Element.prototype.scrollIntoView = jest.fn(); + const onSettled = jest.fn(); + const { container } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 30 }, + undefined, + onSettled, + ); + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + // The mount snap re-snaps once after paint, then — no further resize waves fire in jsdom — the + // quiet window elapses and it reports settled. Advance past the rAF, the quiet window, and the + // deadline backstop. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS + 16)); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + it('fires onSettled after a recenter snap settles', () => { + const book = makeBook(60, 0); + Element.prototype.scrollIntoView = jest.fn(); + const onSettled = jest.fn(); + const { rerender, container } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 1 }, + undefined, + onSettled, + ); + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + + // Drain the initial-mount settle (anchor at book start → next-frame settle). + act(() => jest.advanceTimersByTime(16)); + onSettled.mockClear(); + + // An external recenter rebuilds + snaps; settle fires again once the layout goes quiet. + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS + 16)); + expect(onSettled).toHaveBeenCalled(); + }); + + it('fires onSettled once via the deadline when settling waves never stop', () => { + const { fire, restore } = installResizeObserver(); + try { + const book = makeBook(60, 0); + Element.prototype.scrollIntoView = jest.fn(); + const onSettled = jest.fn(); + const { container, result } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 30 }, + undefined, + onSettled, + ); + const active = document.createElement('div'); + active.setAttribute('aria-current', 'true'); + container.appendChild(active); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + act(() => result.current.contentRef(wrapper)); + + // A layout that resizes faster than the quiet window keeps relaying re-snaps, so the quiet + // timer never fires. The deadline backstop reports settled exactly once so the curtain is never + // stranded. Fire a resize on every quiet interval right up to the deadline. + const ticks = Math.ceil(RECENTER_FADE_MS / 50) + 2; + for (let i = 0; i < ticks; i += 1) { + fire(); + act(() => jest.advanceTimersByTime(50)); + } + expect(onSettled).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + + it('tolerates a mount with no onSettled callback', () => { + const book = makeBook(60, 0); + // No callback: the next-frame settle path must run without throwing on the optional call. + renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 1 }); + expect(() => act(() => jest.advanceTimersByTime(16))).not.toThrow(); + }); + + 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('re-creates the sentinel observer after each extend so a still-intersecting sentinel keeps filling', () => { + const book = makeBook(200, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const { bottom } = mountSentinels( + container, + result.current.topSentinelRef, + result.current.bottomSentinelRef, + ); + const observerBefore = global.ioInstances[0]; + + // An IntersectionObserver only fires on transitions, so a sentinel that never leaves the arming + // margin (compact baseline-text segments) would otherwise extend once and stall scrolling. Each + // extend must re-subscribe a fresh observer, whose initial delivery re-evaluates the sentinel + // and keeps the window filling. + act(() => global.triggerIntersection(bottom, true)); + expect(result.current.windowSegments).toHaveLength(15); + expect(global.ioInstances).toHaveLength(1); + expect(global.ioInstances[0]).not.toBe(observerBefore); + + act(() => global.triggerIntersection(bottom, true)); + expect(result.current.windowSegments).toHaveLength(21); + }); + + it('observes both the segment wrapper and the container once the wrapper is registered', () => { + const { observedTargets, restore } = installResizeObserver(); + try { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + // No observer until the wrapper attaches: the wrapper is what actually grows when segment + // heights settle (the container's own box is fixed by the panel layout), so without it there + // is nothing meaningful to watch. + expect(observedTargets()).toEqual([]); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + act(() => result.current.contentRef(wrapper)); + + expect(observedTargets()).toContain(wrapper); + expect(observedTargets()).toContain(container); + } finally { + restore(); + } + }); + + it('disconnects the resize observer when the wrapper ref is cleared', () => { + const { observedTargets, restore } = installResizeObserver(); + try { + const book = makeBook(40, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + act(() => result.current.contentRef(wrapper)); + expect(observedTargets()).toContain(wrapper); + + // eslint-disable-next-line no-null/no-null -- React clears ref callbacks with literal null + act(() => result.current.contentRef(null)); + + expect(observedTargets()).toEqual([]); + } finally { + restore(); + } + }); + + it('does not fade when the navigation was originated internally', () => { + const book = makeBook(60, 0); + const { result, rerender, markInternal, hasPendingInternal } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + // Mark the nav internal as the context does for a click/strip nav, then drive the matching + // scrRef change. + const newRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + markInternal(newRef); + act(() => { + rerender({ b: book, ref: newRef }); + }); + + expect(result.current.isFaded).toBe(false); + // The marker is consumed so a later external nav to the same verse still fades. + expect(hasPendingInternal(newRef)).toBe(false); + }); + + it('fades on a later external nav to the same verse after an internal nav consumed the marker', () => { + const book = makeBook(60, 0); + const { result, rerender, markInternal } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 5, + }); + + const target: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 50 }; + markInternal(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); + }); + + it('initializes displayFocusedTokenRef from the initial focused token', () => { + const book = makeBook(40, 0); + const { result } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 15 }, + 'tok-initial', + ); + expect(result.current.displayFocusedTokenRef).toBe('tok-initial'); + }); + + it('defers displayFocusedTokenRef to the recenter midpoint on external nav', () => { + const book = makeBook(60, 0); + const { result, rerender } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 5 }, + 'tok-old', + ); + + // External nav: the focused token jumps to the new verse the same render the anchor changes. + act(() => + rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 }, focus: 'tok-new' }), + ); + // Mid-fade: the display ref must still read the old token so the active-verse buttons on the + // still-visible old content don't re-evaluate (and dim) before the fade-out completes. + expect(result.current.isFaded).toBe(true); + expect(result.current.displayFocusedTokenRef).toBe('tok-old'); + + // At the midpoint the window swaps behind the fade and the display ref catches up. + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.displayFocusedTokenRef).toBe('tok-new'); + }); + + it('updates displayFocusedTokenRef immediately for a within-verse focus move (no fade)', () => { + const book = makeBook(40, 0); + const { result, rerender } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 15 }, + 'tok-a', + ); + + // Same verse (anchor unchanged), focus moves token-to-token: no fade, display ref tracks at once. + act(() => + rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 15 }, focus: 'tok-b' }), + ); + expect(result.current.isFaded).toBe(false); + expect(result.current.displayFocusedTokenRef).toBe('tok-b'); + }); + + describe('above-viewport scroll compensation', () => { + // The module-scope `installResizeObserver` swaps in a stub observer; restore the original after + // each test in this block so the stub never leaks into another test's render. + let restoreResizeObserver: (() => void) | undefined; + afterEach(() => { + restoreResizeObserver?.(); + restoreResizeObserver = undefined; + }); + + /** + * Installs the shared stub `ResizeObserver` and registers its restore for this block's + * `afterEach`, so callers only need the returned `fire` helper. + * + * @returns `fire`, which invokes the recorded observer callback inside `act`. + */ + function installBlockResizeObserver(): { fire: () => void } { + const { fire, restore } = installResizeObserver(); + restoreResizeObserver = restore; + return { fire }; + } + + /** + * Renders a window anchored at the book start, drains the initial-mount settle (which clears + * the recenter-in-flight gate), mounts the segment wrapper plus the window's segment roots + * (anchor candidates), and stubs the container and first-segment rects so the anchor seeds + * deterministically (first segment visible at offset 10). + * + * @returns The render result plus the observer `fire` helper and the mounted segment elements. + */ + function renderSettledWindow() { + const { fire } = installBlockResizeObserver(); + const book = makeBook(60, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + // Anchor at book start needs no mount snap, so the next frame clears recenterInFlight. + act(() => jest.advanceTimersByTime(16)); + stubRect(container, 0, 600); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + const els = mountSegmentEls( + wrapper, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], 10, 50); + // Subscribing the observer seeds the compensation anchor from the current rects. + act(() => result.current.contentRef(wrapper)); + return { result, container, els, fire }; + } + + it('holds the visible content still when content above the viewport grows', () => { + const { container, els, fire } = renderSettledWindow(); + container.scrollTop = 100; + + // Growth above the viewport pushes the anchor segment down 30px; the correction scrolls down + // by the same amount so the visible content never moves. + stubRect(els[0], 40, 80); + fire(); + + expect(container.scrollTop).toBe(130); + }); + + it('does not compensate while the list is scrolled to the very top', () => { + const { container, els, fire } = renderSettledWindow(); + container.scrollTop = 0; + + stubRect(els[0], 40, 80); + fire(); + + expect(container.scrollTop).toBe(0); + }); + + it('does not move scrollTop when the anchor offset is unchanged', () => { + const { container, fire } = renderSettledWindow(); + container.scrollTop = 100; + + // Same rects as the seed: nothing above the viewport changed, so scrollTop is left alone. + fire(); + + expect(container.scrollTop).toBe(100); + }); + + it('re-baselines on scroll so user scrolling is never re-applied as a correction', () => { + const { container, els, fire } = renderSettledWindow(); + container.scrollTop = 100; + + // The user scrolls down 60px: every segment moves up by that amount on screen and the seeded + // anchor scrolls out of the viewport. The scroll listener re-baselines onto the next visible + // segment, so the following resize wave reads a zero delta — without it, the stale anchor + // offset would re-apply the 60px as a phantom "correction". + container.scrollTop = 160; + stubRect(els[0], -50, -10); + stubRect(els[1], -10, 30); + fireEvent.scroll(container); + fire(); + + expect(container.scrollTop).toBe(160); + }); + + it('stands down when the anchor segment was unmounted, then resumes from the re-picked anchor', () => { + const { container, els, fire } = renderSettledWindow(); + container.scrollTop = 100; + + // The anchor segment unmounts (e.g. culled): the fire must not correct against a detached + // rect — it re-picks the next visible segment instead. + els[0].remove(); + stubRect(els[1], 5, 45); + fire(); + expect(container.scrollTop).toBe(100); + + // The re-picked anchor is live: the next growth above the viewport compensates as usual. + stubRect(els[1], 25, 65); + fire(); + expect(container.scrollTop).toBe(120); + }); + + it('ignores container movement because anchor offsets are container-relative', () => { + const { container, els, fire } = renderSettledWindow(); + container.scrollTop = 100; + + // The strip mounts above the list: the container's top edge moves down 40px and every + // segment moves with it. The anchor's offset below the container top is unchanged, so no + // phantom correction fires. + stubRect(container, 40, 640); + stubRect(els[0], 50, 90); + fire(); + + expect(container.scrollTop).toBe(100); + }); + + it('re-baselines after an extend so the next resize does not re-apply the extend shift', () => { + const { fire } = installBlockResizeObserver(); + // Anchor mid-book so the window starts past the book start, leaving earlier segments to + // prepend. The mid-book mount snap settles below, clearing recenterInFlight — otherwise the + // compensation observer stands down for the whole test. + const book = makeBook(60, 0); + const { result, container } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 30, + }); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS + 16)); + expect(result.current.windowSegments[0].id).not.toBe('GEN 1:1'); + stubRect(container, 0, 600); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + const els = mountSegmentEls( + wrapper, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], 20, 60); + act(() => result.current.contentRef(wrapper)); + const top = document.createElement('div'); + container.appendChild(top); + act(() => result.current.topSentinelRef(top)); + container.scrollTop = 100; + + // A prepend pushes the old first segment (both the extend anchor and the compensation + // anchor) down 100px; the layout effect adds that delta to scrollTop and re-baselines. + act(() => { + global.triggerIntersection(top, true); + stubRect(els[0], 120, 160); + }); + expect(container.scrollTop).toBe(200); + + // The resize wave the prepend triggers must read the re-baselined anchor offset, not the + // stale pre-extend one — re-applying the 100px delta would land scrollTop at 300, the random + // jump. + fire(); + expect(container.scrollTop).toBe(200); + }); + + it('re-snaps instead of compensating while a recenter is in flight', () => { + const { fire } = installBlockResizeObserver(); + const book = makeBook(60, 0); + Element.prototype.scrollIntoView = jest.fn(); + const { result, container, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + act(() => jest.advanceTimersByTime(16)); + stubRect(container, 0, 600); + const wrapper = document.createElement('div'); + container.appendChild(wrapper); + const els = mountSegmentEls( + wrapper, + result.current.windowSegments.map((s) => s.id), + ); + stubRect(els[0], 10, 50); + act(() => result.current.contentRef(wrapper)); + container.scrollTop = 100; + + // Start an external recenter; while it is in flight the observer relays each resize to the + // re-snap handler (which pins the verse via scrollIntoView) rather than compensating, so it + // never moves scrollTop directly — the two corrections can't fight. + act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } })); + stubRect(els[0], 50, 90); + fire(); + + expect(container.scrollTop).toBe(100); + }); + }); +}); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 8afa3070..78a9259d 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -264,7 +264,7 @@ describe('main', () => { await activate(context); - expect(context.registrations.unsubscribers.size).toBe(16); + expect(context.registrations.unsubscribers.size).toBe(17); }); it('logs activation start and finish', async () => { diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts index e11a6daa..74e8360a 100644 --- a/src/__tests__/store/analysisSlice.test.ts +++ b/src/__tests__/store/analysisSlice.test.ts @@ -10,9 +10,9 @@ import type { } from 'interlinearizer'; import { createAnalysisStore } from '../../store'; import { makePhraseLink } from '../test-helpers'; +import { emptyAnalysis } from '../../types/empty-factories'; import { createPhrase, - defaultAnalysis, defaultState, deletePhrase, mergePhrases, @@ -49,7 +49,7 @@ function makeApprovedLink(ta: TokenAnalysis): TokenAnalysisLink { */ function makeAnalysis(ta: TokenAnalysis): TextAnalysis { return { - ...defaultAnalysis, + ...emptyAnalysis(), tokenAnalyses: [ta], tokenAnalysisLinks: [makeApprovedLink(ta)], }; @@ -67,7 +67,7 @@ describe('setAnalysis', () => { it('does not mutate analysisLanguage', () => { const store = createAnalysisStore({ analysis: { ...defaultState, analysisLanguage: 'fr' } }); - store.dispatch(setAnalysis(defaultAnalysis)); + store.dispatch(setAnalysis(emptyAnalysis())); expect(store.getState().analysis.analysisLanguage).toBe('fr'); }); }); @@ -82,7 +82,7 @@ describe('writeGloss', () => { }; const store = createAnalysisStore({ analysis: { - analysis: { ...defaultAnalysis, tokenAnalysisLinks: [orphanLink] }, + analysis: { ...emptyAnalysis(), tokenAnalysisLinks: [orphanLink] }, analysisLanguage: 'und', }, }); @@ -102,7 +102,7 @@ describe('writeGloss', () => { }; const store = createAnalysisStore({ analysis: { - analysis: { ...defaultAnalysis, tokenAnalysisLinks: [orphanLink] }, + analysis: { ...emptyAnalysis(), tokenAnalysisLinks: [orphanLink] }, analysisLanguage: 'und', }, }); @@ -159,7 +159,7 @@ describe('selectApprovedGloss', () => { */ function makeAnalysisWithPhrase(link: PhraseAnalysisLink): TextAnalysis { return { - ...defaultAnalysis, + ...emptyAnalysis(), phraseAnalyses: [{ id: link.analysisId, surfaceText: 'phrase' }], phraseAnalysisLinks: [link], }; @@ -288,7 +288,7 @@ describe('deletePhrase', () => { const store = createAnalysisStore({ analysis: { analysis: { - ...defaultAnalysis, + ...emptyAnalysis(), phraseAnalyses: [ { id: 'phrase-1', surfaceText: 'A' }, { id: 'phrase-2', surfaceText: 'B' }, @@ -316,7 +316,7 @@ describe('mergePhrases', () => { const store = createAnalysisStore({ analysis: { analysis: { - ...defaultAnalysis, + ...emptyAnalysis(), phraseAnalyses: [ { id: 'phrase-1', surfaceText: 'A' }, { id: 'phrase-2', surfaceText: 'B' }, @@ -377,7 +377,7 @@ describe('mergePhrases', () => { const store = createAnalysisStore({ analysis: { analysis: { - ...defaultAnalysis, + ...emptyAnalysis(), phraseAnalyses: [{ id: 'phrase-1', surfaceText: 'A' }], phraseAnalysisLinks: [phrase], }, @@ -436,7 +436,7 @@ describe('selectPhraseLinks', () => { status: 'suggested', }; const analysis: TextAnalysis = { - ...defaultAnalysis, + ...emptyAnalysis(), phraseAnalyses: [ { id: 'phrase-1', surfaceText: 'A' }, { id: 'phrase-2', surfaceText: 'B' }, diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index 7fd746df..3a9de42a 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -5,7 +5,6 @@ import type { ReactNode } from 'react'; import { Provider as ReduxProvider, useDispatch, useSelector, useStore } from 'react-redux'; import { createPhrase, - defaultAnalysis, deletePhrase, mergePhrases, selectAnalysis, @@ -18,6 +17,7 @@ import { writePhraseGloss, } from '../store/analysisSlice'; import { createAnalysisStore, type AnalysisDispatch, type AnalysisRootState } from '../store'; +import { emptyAnalysis } from '../types/empty-factories'; // #region Internal context @@ -86,7 +86,7 @@ export function AnalysisStoreProvider({ const storeRef = useRef | undefined>(undefined); if (!storeRef.current) { storeRef.current = createAnalysisStore({ - analysis: { analysis: initialAnalysis ?? defaultAnalysis, analysisLanguage }, + analysis: { analysis: initialAnalysis ?? emptyAnalysis(), analysisLanguage }, }); } const store = storeRef.current; diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index c9c727ce..3102e704 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -2,18 +2,23 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import type { Book, Token } from 'interlinearizer'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; -import { splitPhraseAtBoundary } from '../utils/phrase-arc'; -import { usePhraseDispatch, usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; +import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; import type { PhraseMode } from '../types/phrase-mode'; -import { isWordToken } from '../types/type-guards'; import { PhraseStripProvider } from './PhraseStripContext'; -import type { PhraseStripContextValue } from './PhraseStripContext'; import { PhraseStrip, LINK_SLOT_TRANSITION_MS, type StripItem } from './PhraseStripParts'; import type { LinkSlot, TokenGroup } from '../types/token-layout'; import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/token-layout'; import { useArcPaths } from '../hooks/useArcPaths'; import { usePhraseHoverState } from '../hooks/usePhraseHoverState'; +import { + useArcSplitHandler, + useCandidatePhraseIds, + useEditPhraseTokens, + usePhraseStripContextValue, +} from '../hooks/usePhraseStripSetup'; +import useLatestRef from '../hooks/useLatestRef'; import MemoizedArcOverlay from './ArcOverlay'; +import { RECENTER_FADE_MS, RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; /** * Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero. @@ -28,18 +33,6 @@ function clampIndex(index: number, len: number): number { return Math.max(0, Math.min(index, len - 1)); } -/** - * 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. - */ -const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; - -/** - * Duration of the strip fade animation in milliseconds. Must match the `setTimeout` in the - * pending-jump effect. - */ -const STRIP_FADE_MS = 500; - /** * Backstop, in milliseconds, for committing the deferred inactive-link relayout after an * internal-nav smooth scroll. The relayout normally fires on the scroll container's `scrollend` @@ -110,6 +103,8 @@ type ContinuousViewProps = Readonly<{ setPhraseMode: Dispatch>; /** Token ref → segment id lookup; used to resolve the focused token's segment for slot rules. */ tokenSegmentMap: ReadonlyMap; + /** Word token ref → flat book-level index; used to sort phrase tokens in document order. */ + tokenDocOrder: ReadonlyMap; /** Word token ref → token lookup; used to resolve the focused token from `focusedTokenRef`. */ wordTokenByRef: ReadonlyMap; /** @@ -144,6 +139,8 @@ type ContinuousViewProps = Readonly<{ * @param props.phraseMode - Current phrase-interaction mode; controls token click behavior * @param props.setPhraseMode - Setter for `phraseMode`; passed to phrase boxes for mode transitions * @param props.tokenSegmentMap - Token ref → segment id lookup for focus resolution + * @param props.tokenDocOrder - Word token ref → flat book-level index for document-order phrase + * merges * @param props.wordTokenByRef - Word token ref → token lookup for focus resolution * @param props.hideInactiveLinkButtons - When true, link buttons between phrases are hidden outside * the focused token's segment. @@ -159,6 +156,7 @@ export default function ContinuousView({ phraseMode, setPhraseMode, tokenSegmentMap, + tokenDocOrder, wordTokenByRef, hideInactiveLinkButtons, simplifyPhrases, @@ -175,25 +173,7 @@ export default function ContinuousView({ const committedPhraseLinkByRef = usePhraseLinkMap(); const committedPhraseLinkById = usePhraseLinkByIdMap(); - /** - * Token list of the phrase currently being edited, or `undefined` outside edit mode. Hoisted to a - * single lookup here rather than recomputed per group; passed into each `PhraseGroup`. - */ - const editPhraseTokens = useMemo( - () => - phraseMode.kind === 'edit' - ? /* v8 ignore next -- phrase always exists in the store when edit mode is entered */ - committedPhraseLinkById.get(phraseMode.phraseId)?.tokens - : undefined, - [phraseMode, committedPhraseLinkById], - ); - - /** Maps each word token ref to its flat document index for document-order phrase merges. */ - const tokenDocOrder = useMemo(() => { - const map = new Map(); - allTokens.filter(isWordToken).forEach((t, i) => map.set(t.ref, i)); - return map; - }, [allTokens]); + const editPhraseTokens = useEditPhraseTokens(phraseMode); /** Phrase groups built from the flat token list, respecting the committed phrase-link map. */ const phraseGroups = useMemo( @@ -210,12 +190,6 @@ export default function ContinuousView({ return map; }, [phraseGroups]); - /** Flat token index -> owning segment lookup; used for per-slot segment resolution. */ - const tokenSegment = useMemo( - () => book.segments.flatMap((seg) => seg.tokens.map(() => seg)), - [book.segments], - ); - /** * Token ref that the strip is currently displaying as focused. Lags `focusedTokenRef` during the * fade-out for external jumps so the window/scroll/highlight don't shift until the strip has @@ -229,16 +203,36 @@ export default function ContinuousView({ /** * Group index of the displayed focused token, or `0` when nothing is focused. Single source of * truth for scroll position, windowing, arrow disabled state, and per-group focus highlighting. + * + * During a book change `displayFocusedTokenRef` lags the new book by one fade (it only catches up + * when the fade timeout fires), so for a few frames it names a token from the previous book that + * no longer exists in this book's `groupIndexByTokenRef`. Falling straight back to `0` then parks + * the strip on the new book's very first phrase instead of the verse the user navigated to. Fall + * back to the live `focusedTokenRef` first — the parent reseeds it to the new book's active verse + * on the book change — so the transient lands on the intended verse rather than book start. */ const focusPhraseIndex = useMemo(() => { - if (displayFocusedTokenRef === undefined) return 0; - const gi = groupIndexByTokenRef.get(displayFocusedTokenRef); - /* v8 ignore next -- gi is always defined when displayFocusedTokenRef is set */ - return gi === undefined ? 0 : clampIndex(gi, phraseGroups.length); - }, [displayFocusedTokenRef, groupIndexByTokenRef, phraseGroups.length]); + const resolved = + (displayFocusedTokenRef !== undefined + ? groupIndexByTokenRef.get(displayFocusedTokenRef) + : undefined) ?? + (focusedTokenRef !== undefined ? groupIndexByTokenRef.get(focusedTokenRef) : undefined); + return resolved === undefined ? 0 : clampIndex(resolved, phraseGroups.length); + }, [displayFocusedTokenRef, focusedTokenRef, groupIndexByTokenRef, phraseGroups.length]); const [isVisible, setIsVisible] = useState(false); + /** + * True for the single render in which an instant jump (external nav or initial mount) flips + * {@link committedActiveSegmentId}, so the link slots snap to their new widths instead of + * animating. `isVisible` alone can't gate this: the scroll effect's cleanup restores visibility + * before the new effect commits the segment, so by the time the slots want their new widths + * `isVisible` is already `true` and the transition would play — sliding the boxes (and yanking + * the recentered phrase) for ~200ms after the fade-in. Cleared in the deferred fade-in frame, one + * paint after the snap, so genuine in-view toggles still animate. + */ + const [skipSlotTransitionForJump, setSkipSlotTransitionForJump] = useState(false); + /** True until the first scroll-into-view completes; suppresses smooth scroll on initial mount. */ const isInitialLoadInProgressRef = useRef(true); @@ -259,6 +253,28 @@ export default function ContinuousView({ * group instead of two. */ const pendingPhraseIndexRef = useRef(0); + + /** + * `focusedTokenRef` prop value from the previous render. Lets the sync block below distinguish a + * prop that merely hasn't echoed an in-flight internal nav yet (unchanged since last render) from + * one the parent changed to an external position (changed to something other than the in-flight + * ref). + */ + const prevFocusedTokenRefPropRef = useRef(focusedTokenRef); + // If the prop changed to anything other than the in-flight internal ref, the parent imposed an + // external position instead of echoing the nav. Clear the in-flight marker so the pending index + // resyncs below; otherwise the next step() would advance from the stale pending index rather + // than the externally-imposed position. The focus-change effect can't cover this case: it + // early-returns without clearing the marker when the external value already matches the + // displayed ref. + if ( + internalFocusedTokenRefRef.current !== undefined && + focusedTokenRef !== prevFocusedTokenRefPropRef.current && + focusedTokenRef !== internalFocusedTokenRefRef.current + ) { + internalFocusedTokenRefRef.current = undefined; + } + prevFocusedTokenRefPropRef.current = focusedTokenRef; // Keep in sync with the rendered value so external jumps reset the pending index. When an // internal nav is still in flight (the parent hasn't echoed back yet), do not overwrite: a rapid // second click needs to read the already-advanced pending index rather than the stale rendered @@ -270,6 +286,24 @@ export default function ContinuousView({ /** DOM ref array indexed by group index; used to scroll the focused phrase box into view. */ const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); + /** + * Scrolls the phrase group at `groupIndex` to horizontal center of the strip. Every centering + * call site shares the `block: 'nearest', inline: 'center'` options and differs only in + * `behavior`, so they route through here. Stable identity (reads `phraseRefs` and takes the index + * explicitly) so the effects that center a snapshot index keep their intentionally-narrow dep + * arrays. + * + * @param groupIndex - Index into `phraseRefs` of the group to center. + * @param behavior - `'auto'` for an instant jump, `'smooth'` for an animated glide. + */ + const centerGroup = useCallback((groupIndex: number, behavior: ScrollBehavior) => { + phraseRefs.current[groupIndex]?.scrollIntoView({ + behavior, + block: 'nearest', + inline: 'center', + }); + }, []); + /** Ref to the token-strip row; the content row and mouse-leave target. */ // eslint-disable-next-line no-null/no-null const stripRowRef = useRef(null); @@ -303,17 +337,33 @@ export default function ContinuousView({ focusedTokenRef !== undefined ? tokenSegmentMap.get(focusedTokenRef) : undefined; /** Ref mirror of the target so the post-scroll timeout reads the latest value without a dep. */ - const targetActiveSegmentIdRef = useRef(targetActiveSegmentId); - targetActiveSegmentIdRef.current = targetActiveSegmentId; + const targetActiveSegmentIdRef = useLatestRef(targetActiveSegmentId); /** Snaps the committed active segment to the current target; runs after an internal-nav scroll. */ const commitPendingActiveSegment = useCallback(() => { setCommittedActiveSegmentId(targetActiveSegmentIdRef.current); - }, []); + }, [targetActiveSegmentIdRef]); /** Ref mirror of `onFocusedTokenRefChange` so callbacks never need it as a dep. */ - const onFocusedTokenRefChangeRef = useRef(onFocusedTokenRefChange); - onFocusedTokenRefChangeRef.current = onFocusedTokenRefChange; + const onFocusedTokenRefChangeRef = useLatestRef(onFocusedTokenRefChange); + + /** + * Emits a focus change that originated _inside_ the strip (arrow nav, phrase click, edit-mode + * jump). Records the ref as internally-originated, then notifies the parent. When the parent + * echoes the same ref back through `focusedTokenRef`, the focus-change effect recognizes the + * match and applies it immediately with a smooth scroll instead of the fade-then-snap used for + * external jumps. Folds the stamp and the notify into one call so the "this is an internal emit" + * intent lives in a single place rather than being restated at each call site. + * + * @param ref - The word-token ref to focus. + */ + const emitInternalFocus = useCallback( + (ref: string) => { + internalFocusedTokenRefRef.current = ref; + onFocusedTokenRefChangeRef.current(ref); + }, + [onFocusedTokenRefChangeRef], + ); // Notify the parent of the initially-focused token on mount so the segment list scrolls the // active verse into view on first render. Only fires when no token was already focused. @@ -377,12 +427,9 @@ export default function ContinuousView({ if (clamped === pendingPhraseIndexRef.current) return; pendingPhraseIndexRef.current = clamped; const nextRef = phraseGroups[clamped]?.tokens[0]?.ref; - if (nextRef !== undefined) { - internalFocusedTokenRefRef.current = nextRef; - onFocusedTokenRefChangeRef.current(nextRef); - } + if (nextRef !== undefined) emitInternalFocus(nextRef); }, - [phraseGroups], + [phraseGroups, emitInternalFocus], ); /** Moves focus one phrase backward. */ @@ -404,34 +451,13 @@ export default function ContinuousView({ const currentGroupIndex = focusedTokenRef === undefined ? undefined : groupIndexByTokenRef.get(focusedTokenRef); if (targetGroupIndex !== undefined && targetGroupIndex === currentGroupIndex) return; - internalFocusedTokenRefRef.current = ref; - onFocusedTokenRefChangeRef.current(ref); + emitInternalFocus(ref); }, - [focusedTokenRef, groupIndexByTokenRef], + [focusedTokenRef, groupIndexByTokenRef, emitInternalFocus], ); - const { createPhrase, updatePhrase, deletePhrase } = usePhraseDispatch(); - - /** - * Splits a phrase arc at a token boundary and dispatches the resulting create/update/delete - * operations. No-ops if `phraseId` is not in `committedPhraseLinkById`. - * - * @param phraseId - Id of the phrase arc to split. - * @param splitAfterTokenRef - Token ref at whose trailing boundary the split is made. - */ - const handleArcSplit = useCallback( - (phraseId: string, splitAfterTokenRef: string) => { - const phraseLink = committedPhraseLinkById.get(phraseId); - if (!phraseLink) return; - splitPhraseAtBoundary( - phraseLink, - splitAfterTokenRef, - { createPhrase, updatePhrase, deletePhrase }, - tokenDocOrder, - ); - }, - [committedPhraseLinkById, tokenDocOrder, createPhrase, updatePhrase, deletePhrase], - ); + /** Splits a phrase arc at a token boundary and dispatches the resulting phrase-store writes. */ + const handleArcSplit = useArcSplitHandler(tokenDocOrder); // React to changes in the prop `focusedTokenRef`. For internal nav (arrow/click in this view), // apply the change immediately and smooth-scroll. For external jumps (segment-mode click, @@ -450,7 +476,7 @@ export default function ContinuousView({ setIsVisible(false); const timeout = setTimeout(() => { setDisplayFocusedTokenRef(focusedTokenRef); - }, STRIP_FADE_MS); + }, RECENTER_FADE_MS); return () => clearTimeout(timeout); }, [focusedTokenRef, displayFocusedTokenRef]); @@ -466,12 +492,9 @@ export default function ContinuousView({ if (shouldJumpInstantly) { // External jumps fade the strip out and the initial mount is static, so there is no animation // to disturb — commit the active segment now alongside the instant scroll. + setSkipSlotTransitionForJump(true); commitPendingActiveSegment(); - phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ - behavior: 'auto', - block: 'nearest', - inline: 'center', - }); + centerGroup(focusPhraseIndex, 'auto'); } if (isInternal && !isInitialLoad) { @@ -480,11 +503,7 @@ export default function ContinuousView({ // Scrolling synchronously here animates toward a position that then shifts, producing a visible // overshoot-and-return ("yank") when crossing a verse boundary. const navRafId = requestAnimationFrame(() => { - phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }); + centerGroup(focusPhraseIndex, 'smooth'); }); // Commit the active-segment change (which toggles inactive link-icon visibility, re-laying out // the strip) only once the smooth scroll has actually settled. Updating it mid-scroll would @@ -519,12 +538,16 @@ export default function ContinuousView({ if (isInitialLoad) isInitialLoadInProgressRef.current = false; // Defer the fade-in until after the browser applies the instant scroll position. - const rafId = requestAnimationFrame(() => setIsVisible(true)); + const rafId = requestAnimationFrame(() => { + setIsVisible(true); + // The snapped-slot paint has happened; re-enable the transition for later in-view toggles. + setSkipSlotTransitionForJump(false); + }); return () => { cancelAnimationFrame(rafId); setIsVisible(true); }; - }, [focusPhraseIndex, commitPendingActiveSegment]); + }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup]); // Keep the focused group pinned dead-center after the deferred active-segment flip. When // `committedActiveSegmentId` flips (after an internal-nav scroll settles), inactive link icons @@ -542,13 +565,7 @@ export default function ContinuousView({ return undefined; } /** Re-centers the focused group; called synchronously now and each `rAF` until the deadline. */ - const recenter = () => { - phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ - behavior: 'auto', - block: 'nearest', - inline: 'center', - }); - }; + const recenter = () => centerGroup(focusPhraseIndex, 'auto'); recenter(); const deadline = performance.now() + LINK_SLOT_TRANSITION_MS; let rafId = requestAnimationFrame(function recenterFrame() { @@ -567,13 +584,9 @@ export default function ContinuousView({ // when hidden (`opacity: 0`; clickability is guarded at the button level), so toggling it does // not shift the layout. useEffect(() => { - phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ - behavior: 'auto', - block: 'nearest', - inline: 'center', - }); + centerGroup(focusPhraseIndex, 'auto'); // focusPhraseIndex is intentionally excluded: it has its own scroll effect above. This effect - // only re-centers in response to layout-affecting option toggles. + // only re-centers in response to layout-affecting option toggles. centerGroup is stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, [simplifyPhrases]); @@ -587,10 +600,9 @@ export default function ContinuousView({ const nextRef = group?.tokens[0]?.ref; /* v8 ignore next -- phrase always has tokens; focusedTokenRef differs at mode entry */ if (nextRef === undefined || nextRef === focusedTokenRef) return; - internalFocusedTokenRefRef.current = nextRef; - onFocusedTokenRefChangeRef.current(nextRef); + emitInternalFocus(nextRef); // phraseGroups and focusedTokenRef are read once per mode change; intentionally not deps so the - // effect only fires on actual mode transitions. + // effect only fires on actual mode transitions. emitInternalFocus has a stable identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [phraseMode]); @@ -621,55 +633,37 @@ export default function ContinuousView({ clearAll: clearHoverState, } = usePhraseHoverState(); - const candidatePhraseIds = useMemo>(() => { - if (candidateTokenRefs.size === 0) return new Set(); - const ids = new Set(); - committedPhraseLinkByRef.forEach((link) => { - if (link.tokens.some((t) => candidateTokenRefs.has(t.tokenRef))) ids.add(link.analysisId); - }); - return ids; - }, [candidateTokenRefs, committedPhraseLinkByRef]); + /** Clears both the hovered phrase id and all hover-preview state on mouse leave. */ + const clearAllHoverState = useCallback(() => { + setHoveredPhraseId(undefined); + clearHoverState(); + }, [clearHoverState]); + + const candidatePhraseIds = useCandidatePhraseIds(candidateTokenRefs, committedPhraseLinkByRef); /** - * Strip-wide context value shared by every phrase group and link slot. Memoized so the leaf - * `MemoizedPhraseBox` / `MemoizedTokenLinkIcon` consumers don't re-render on unrelated changes. - * `setHoveredPhraseId` doubles as both the phrase-hover and candidate-phrase hover callback. + * Strip-wide context value shared by every phrase group and link slot. `setHoveredPhraseId` + * doubles as both the phrase-hover and candidate-phrase hover callback. The active segment lags + * the focus (`committedActiveSegmentId`); the link-slot transition is suppressed while the strip + * is faded out or snapping into place after an instant jump. */ - const stripContext = useMemo( - () => ({ - phraseMode, - setPhraseMode, - editPhraseTokens, - editPhraseSegmentId, - tokenSegmentMap, - tokenDocOrder, - onHoverPhrase: setHoveredPhraseId, - onHoverCandidateTokens: setCandidateTokenRefs, - onHoverSplitFreeTokens: handleHoverSplitFreeTokens, - hideInactiveLinkButtons, - simplifyPhrases, - activeSegmentId: committedActiveSegmentId, - crossSegmentLinkTooltip: - localizedStrings['%interlinearizer_linkButton_crossSegmentDisabledTooltip%'], - skipLinkTransition: !isVisible, - }), - [ - phraseMode, - setPhraseMode, - editPhraseTokens, - editPhraseSegmentId, - tokenSegmentMap, - tokenDocOrder, - setHoveredPhraseId, - setCandidateTokenRefs, - handleHoverSplitFreeTokens, - hideInactiveLinkButtons, - simplifyPhrases, - committedActiveSegmentId, - isVisible, - localizedStrings, - ], - ); + const stripContext = usePhraseStripContextValue({ + phraseMode, + setPhraseMode, + editPhraseTokens, + editPhraseSegmentId, + tokenSegmentMap, + tokenDocOrder, + onHoverPhrase: setHoveredPhraseId, + onHoverCandidateTokens: setCandidateTokenRefs, + onHoverSplitFreeTokens: handleHoverSplitFreeTokens, + hideInactiveLinkButtons, + simplifyPhrases, + activeSegmentId: committedActiveSegmentId, + crossSegmentLinkTooltip: + localizedStrings['%interlinearizer_linkButton_crossSegmentDisabledTooltip%'], + skipLinkTransition: !isVisible || skipSlotTransitionForJump, + }); /** * Group index of the focused token, derived from `focusedTokenRef`. Used per-slot to compute @@ -683,18 +677,22 @@ export default function ContinuousView({ /** * Resolved focus context — what's focused, what segment it's in, what phrase it belongs to. Built - * once from `focusedTokenRef` and reused by all highlight + slot decisions so the rules match - * SegmentView exactly. + * from the fade-gated `displayFocusedTokenRef` (not the live `focusedTokenRef`) so every + * highlight and link-button active/disabled decision moves only at the recenter midpoint, behind + * the fade — never re-evaluating (and dimming the buttons) on the still-visible old strip the + * instant an external nav reseeds the live focus. The scroll target (`focusedGroupIndex`) still + * uses the live ref so the jump lands on the new verse behind the curtain. Mirrors SegmentView, + * which is fed the segment window's own gated display ref. */ const focus = useMemo( () => resolveFocusContext( - focusedTokenRef, + displayFocusedTokenRef, wordTokenByRef, committedPhraseLinkByRef, tokenSegmentMap, ), - [focusedTokenRef, wordTokenByRef, committedPhraseLinkByRef, tokenSegmentMap], + [displayFocusedTokenRef, wordTokenByRef, committedPhraseLinkByRef, tokenSegmentMap], ); /** True when any committed phrase exists in the visible window. */ @@ -786,11 +784,11 @@ export default function ContinuousView({ const key = `slot-${prevGroup?.tokens[prevGroup.tokens.length - 1]?.ref ?? 'start'}-${nextGroup?.tokens[0]?.ref ?? 'end'}`; const prevSegmentId = item.prevGroupIndex !== undefined && phraseGroups[item.prevGroupIndex] !== undefined - ? tokenSegment[phraseGroups[item.prevGroupIndex].firstIndex]?.id + ? tokenSegmentMap.get(phraseGroups[item.prevGroupIndex].tokens[0].ref) : undefined; const nextSegmentId = item.nextGroupIndex !== undefined && phraseGroups[item.nextGroupIndex] !== undefined - ? tokenSegment[phraseGroups[item.nextGroupIndex].firstIndex]?.id + ? tokenSegmentMap.get(phraseGroups[item.nextGroupIndex].tokens[0].ref) : undefined; return { kind: 'slot', @@ -815,14 +813,7 @@ export default function ContinuousView({ }, }; }), - [ - renderItems, - phraseGroups, - tokenSegment, - focusedSideIsPrevByItem, - displayFocusedTokenRef, - phraseRefs, - ], + [renderItems, phraseGroups, tokenSegmentMap, focusedSideIsPrevByItem, displayFocusedTokenRef], ); return ( @@ -867,10 +858,7 @@ export default function ContinuousView({ data-testid="strip-fade-wrapper" ref={arcContainerRef} className={`tw:arc-container tw:transition-opacity ${stripOpacityClass}`} - style={{ - transitionDuration: `${STRIP_FADE_MS}ms`, - transitionTimingFunction: STRIP_FADE_EASING, - }} + style={RECENTER_FADE_TRANSITION_STYLE} > { - setHoveredPhraseId(undefined); - clearHoverState(); - }} + onMouseLeave={clearAllHoverState} > void; + /** + * Consumes a pending internal-navigation marker for `ref`: returns `true` (and clears the marker) + * when the most recent {@link navigate} to this verse was `internal`, else `false`. The segment + * window calls this when an anchor change arrives to decide whether to fade. Consuming clears the + * marker so a later _external_ navigation to the same verse still fades. Markers older than + * {@link INTERNAL_NAV_TTL_MS} are ignored (and discarded): a marker stranded by React batching + * rapid clicks — where the host echoes only the last of several internal navigations — must not + * misclassify a later external navigation to the un-echoed verse. + * + * @param ref - The reference whose pending classification to consume. + * @returns `true` if the navigation to `ref` was internal (skip the fade), else `false`. + */ + consumeInternalNav: (ref: SerializedVerseRef) => boolean; + /** The currently active scroll-group ID (`undefined` = unlinked). */ + scrollGroupId: number | undefined; + /** Changes the active scroll group. */ + setScrollGroupId: (scrollGroupId: number | undefined) => void; + /** + * Current phase of the cross-book fade clock. Drives the loader-level curtain opacity so a book + * change fades out, holds through the load and first-mount settle, then fades back in. See + * {@link FadePhase}. + */ + fadePhase: FadePhase; + /** + * Reports that the view has finished settling on the current book (active verse snapped into + * place, layout stabilized). Lifts the cross-book curtain: transitions the clock from `out` to + * `in`. No-op unless a cross-book fade is awaiting settle, so an unrelated settle (e.g. a + * same-book recenter, or a remount that wasn't a book change) can't lift a curtain that isn't + * down — or start one that never began. + */ + reportSettled: () => void; + /** + * Aborts an in-flight cross-book fade and reveals the content without waiting for a settle (the + * reveal still animates through the wrapper's opacity transition). Called by the loader when the + * new book fails to load, so the error is shown rather than left hidden behind a curtain that + * will never receive a settle. + */ + cancelFade: () => void; +} + +/** + * React context carrying the {@link InterlinearNav} surface. Undefined outside a provider so + * {@link useInterlinearNav} can throw a clear error rather than handing back a silently-empty + * object. + */ +const InterlinearNavContext = createContext(undefined); + +/** + * Provides the {@link InterlinearNav} surface to the subtree. Calls the host scroll-group hook + * internally so the PAPI ref remains the ultimate owner of the shared reference — the context + * writes through it rather than shadowing it, keeping other scroll-group consumers in sync. + * + * @param props - Component props. + * @param props.useWebViewScrollGroupScrRef - The PAPI hook exposing the shared scroll-group + * reference and its setter; injected by the host (not imported) so it can be stubbed in tests. + * @param props.children - The subtree that consumes navigation through {@link useInterlinearNav}. + * @returns The provider wrapping `children`. + */ +export function InterlinearNavProvider({ + useWebViewScrollGroupScrRef, + children, +}: Readonly<{ + useWebViewScrollGroupScrRef: UseWebViewScrollGroupScrRefHook; + children: ReactNode; +}>) { + const [hostScrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + + /** + * The last host delivery adopted as `rawScrRef`, kept so the duplicate-delivery guard below can + * hand back the same object when the host re-sends an identical reference. + */ + const stableRawScrRefRef = useRef(hostScrRef); + + // The host delivers each scripture-picker navigation twice in quick succession: two back-to-back + // signals carrying the same reference as distinct objects. Passing the second through verbatim + // would change `rawScrRef`'s identity — and with it the context value — for a navigation that + // already happened, re-rendering every nav consumer mid-recenter for nothing (the same + // double-fire whose duplicate USJ payload `useInterlinearizerBookData` already stabilizes). Reuse + // the previously adopted object when the delivery is value-equal, so a duplicate is invisible + // downstream. + const rawScrRef = areScrRefsEqual(hostScrRef, stableRawScrRefRef.current) + ? stableRawScrRefRef.current + : hostScrRef; + stableRawScrRefRef.current = rawScrRef; + + /** + * The last committed {@link liveScrRef}, mirrored so the verse-0 stickiness below can compare the + * incoming `rawScrRef` against the verse currently shown. + */ + const liveScrRefRef = useRef(normalizeScrRef(rawScrRef)); + + // After a verse navigation the host re-broadcasts the *chapter* to the scroll group as a separate + // `verseNum: 0` reference (an echo of the current location, not a real move). Normalizing that to + // verse 1 unconditionally would read as a fresh navigation to the chapter's first verse, fading and + // recentering the views off the verse the user is actually on. So a verse-0 reference that names the + // book+chapter already shown is treated as sticky: keep the current `liveScrRef` (its real verse) + // rather than snapping to verse 1. A verse-0 reference for a *different* chapter is a genuine + // chapter jump and normalizes to verse 1 as before. When two *different* deliveries normalize to + // the same verse (a verse-0 chapter jump followed by its verse-1 form), the previously committed + // object is reused so the second delivery never reads as a fresh navigation downstream. + const liveScrRef = useMemo(() => { + const prev = liveScrRefRef.current; + if ( + rawScrRef.verseNum === 0 && + rawScrRef.book === prev.book && + rawScrRef.chapterNum === prev.chapterNum + ) { + return prev; + } + const normalized = normalizeScrRef(rawScrRef); + return areScrRefsEqual(normalized, prev) ? prev : normalized; + }, [rawScrRef]); + /** + * The {@link liveScrRef} committed on the previous render, captured before the mirror update below + * overwrites it, so the mid-reveal navigation guard further down can compare the incoming + * reference against the verse last shown. + */ + const prevLiveScrRef = liveScrRefRef.current; + liveScrRefRef.current = liveScrRef; + + /** + * Verse keys of internal navigations still awaiting their host round-trip, each mapped to its + * `Date.now()` stamp. `navigate(ref, 'internal')` records `verseKey(ref)`; `consumeInternalNav` + * removes it on match. Keyed (not a single value) so that rapid successive clicks both stay + * pending and neither host delivery is misread as external. The stamp gives each marker a TTL + * ({@link INTERNAL_NAV_TTL_MS} — see its doc for why stranded markers must expire), honored by + * BOTH readers: `consumeInternalNav` (which also evicts expired markers) and the render-phase + * mid-reveal guard (a pure read — no eviction during render). + */ + const pendingInternalNavRef = useRef>(new Map()); + + const navigate = useCallback( + (newScrRef: SerializedVerseRef, origin: NavOrigin = 'external') => { + if (origin === 'internal') pendingInternalNavRef.current.set(verseKey(newScrRef), Date.now()); + setScrRef(newScrRef); + }, + [setScrRef], + ); + + const consumeInternalNav = useCallback((ref: SerializedVerseRef) => { + const pending = pendingInternalNavRef.current; + // Evict expired markers before matching, so a marker stranded by a batched rapid-click (its + // echo never arrived) cannot be consumed by a later external navigation to the same verse. + // Eviction also bounds the map's size; freshness is the shared `isInternalNavMarkerFresh` + // definition so this reader and the mid-reveal guard cannot drift. + pending.forEach((stampedAt, pendingKey) => { + if (!isInternalNavMarkerFresh(stampedAt)) pending.delete(pendingKey); + }); + const key = verseKey(ref); + if (!pending.has(key)) return false; + pending.delete(key); + return true; + }, []); + + const [fadePhase, setFadePhase] = useState('idle'); + + /** + * Book code the curtain currently shows fully faded-in. A book change is detected by comparing + * `liveScrRef.book` against this; `reportSettled` advances it to the new book once the view has + * laid out. Seeded to the book at mount so the initial load shows no fade. + */ + const displayedBookRef = useRef(liveScrRef.book); + + /** + * `true` between detecting a book change and the view reporting settled. Gates `reportSettled` so + * only a settle that actually closes an in-flight cross-book fade lifts the curtain. + */ + const awaitingSettleRef = useRef(false); + + /** Handle of the in-flight fade-in→idle timer, or `undefined` when none is pending. */ + const fadeInTimeoutRef = useRef | undefined>(undefined); + + // Detect a cross-book navigation *during render* and start the fade-out synchronously, so the + // curtain drops in the same commit the book ref changes — never a paint later (an effect-driven + // fade-out would let the mounted views render one frame against the new book's ref before the + // curtain covers them). Setting state during render is the guarded React pattern: `awaitingSettle` + // flips true so this fires once per book change, and `setFadePhase` is batched into this commit. + if (liveScrRef.book !== displayedBookRef.current && !awaitingSettleRef.current) { + if (fadeInTimeoutRef.current !== undefined) { + clearTimeout(fadeInTimeoutRef.current); + fadeInTimeoutRef.current = undefined; + } + awaitingSettleRef.current = true; + setFadePhase('out'); + } else if ( + fadePhase === 'in' && + verseKey(liveScrRef) !== verseKey(prevLiveScrRef) && + !isInternalNavMarkerFresh(pendingInternalNavRef.current.get(verseKey(liveScrRef))) + ) { + // A follow-up external navigation landing mid-fade-in: the host resolves one picker selection + // as two navigations (book change, then precise target), so the second routinely arrives while + // the reveal is still animating and would fade the fresh content a second time. Instead, + // re-engage the curtain (the CSS transition carries opacity smoothly down from wherever the + // rise reached) and lift once when the views settle on the new verse. Internal echoes (a click + // made during the reveal) are exempt — their target is already on screen — but the exemption + // honors the marker TTL, so a stranded marker cannot suppress the re-engage. Pure read, no + // eviction: this runs during render; `consumeInternalNav` handles eviction. + /* v8 ignore next -- defensive: reportSettled always arms the fade-in timer alongside 'in' */ + if (fadeInTimeoutRef.current !== undefined) clearTimeout(fadeInTimeoutRef.current); + fadeInTimeoutRef.current = undefined; + awaitingSettleRef.current = true; + setFadePhase('out'); + } + + const reportSettled = useCallback(() => { + if (!awaitingSettleRef.current) return; + awaitingSettleRef.current = false; + displayedBookRef.current = liveScrRef.book; + setFadePhase('in'); + /* v8 ignore next -- defensive: render-time book-change guard always clears any stale timer first */ + if (fadeInTimeoutRef.current !== undefined) clearTimeout(fadeInTimeoutRef.current); + fadeInTimeoutRef.current = setTimeout(() => { + fadeInTimeoutRef.current = undefined; + setFadePhase('idle'); + }, RECENTER_FADE_MS); + }, [liveScrRef.book]); + + const cancelFade = useCallback(() => { + if (fadeInTimeoutRef.current !== undefined) { + clearTimeout(fadeInTimeoutRef.current); + fadeInTimeoutRef.current = undefined; + } + awaitingSettleRef.current = false; + displayedBookRef.current = liveScrRef.book; + setFadePhase('idle'); + }, [liveScrRef.book]); + + // Clear any pending fade-in timer on unmount so a deferred state update doesn't run on a torn-down + // tree. + useEffect( + () => () => { + if (fadeInTimeoutRef.current !== undefined) clearTimeout(fadeInTimeoutRef.current); + }, + [], + ); + + const value = useMemo( + () => ({ + rawScrRef, + liveScrRef, + navigate, + consumeInternalNav, + scrollGroupId, + setScrollGroupId, + fadePhase, + reportSettled, + cancelFade, + }), + [ + rawScrRef, + liveScrRef, + navigate, + consumeInternalNav, + scrollGroupId, + setScrollGroupId, + fadePhase, + reportSettled, + cancelFade, + ], + ); + + return {children}; +} + +/** + * Reads the {@link InterlinearNav} surface from the nearest {@link InterlinearNavProvider}. + * + * @returns The navigation surface. + * @throws {Error} When called outside an {@link InterlinearNavProvider}. + */ +export function useInterlinearNav(): InterlinearNav { + const nav = useContext(InterlinearNavContext); + if (!nav) { + throw new Error('useInterlinearNav must be used within an InterlinearNavProvider'); + } + return nav; +} diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index db596c32..db026049 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -1,28 +1,45 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book, ScriptureRef, Segment, TextAnalysis } from 'interlinearizer'; -import { LocateFixed } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { AnalysisStoreProvider, usePhraseDispatch } from './AnalysisStore'; import ContinuousView from './ContinuousView'; import EditPhraseControls from './controls/EditPhraseControls'; +import useBookIndexes from '../hooks/useBookIndexes'; +import useLatestRef from '../hooks/useLatestRef'; import type { PhraseMode } from '../types/phrase-mode'; import { isWordToken } from '../types/type-guards'; -import MemoizedSegmentView from './SegmentView'; +import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref'; +import SegmentListView from './SegmentListView'; import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm'; +import { useInterlinearNav } from './InterlinearNavContext'; +import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; + +/** + * Returns the ref of the first word token in `segment`, or `undefined` when the segment has none. + * The seed value for `focusedTokenRef` whenever focus must fall back to the active verse's leading + * word (initial mount, book change, and external verse reseed). + * + * @param segment - The segment to read, or `undefined` when no active segment is resolved. + * @returns The first word token's ref, or `undefined`. + */ +function firstWordTokenRefOf(segment: Segment | undefined): string | undefined { + return segment?.tokens.find(isWordToken)?.ref; +} /** 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. */ + /** + * Current scripture reference used to highlight the active verse. Must carry a normalized verse + * number (>= 1): production refs flow through `normalizeScrRef` in `InterlinearNavContext`, which + * maps a verse-0 (chapter heading) selection to verse 1 before it reaches this component. A raw + * verse-0 ref would match no segment, leaving focus unseeded and nothing highlighted. + */ scrRef: SerializedVerseRef; - /** Called when the user navigates to a different verse. */ - setScrRef: (newScrRef: SerializedVerseRef) => void; /** * BCP 47 tag for reading and writing gloss values. Defaults to `analysisLanguages[0]` of the * active project (supplied by the caller). @@ -40,6 +57,12 @@ type InterlinearizerProps = Readonly<{ hideInactiveLinkButtons: boolean; /** When true, phrase-level controls are hidden on every phrase except the focused one. */ simplifyPhrases: boolean; + /** + * When true, every verse is labeled `chapter:verse` and no inline chapter header is shown; when + * false, an inline chapter header precedes the first verse of each chapter and verse labels stay + * bare verse numbers. + */ + chapterLabelInVerse: boolean; }>; /** @@ -48,11 +71,9 @@ 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. - * @param props.setScrRef - Called when the user navigates to a different verse. * @param props.phraseMode - Current phrase-interaction mode passed down for rendering. * @param props.setPhraseMode - Setter for `phraseMode`; passed to child components so they can * transition modes. @@ -60,100 +81,80 @@ type InterlinearizerProps = Readonly<{ * segments other than the active verse. * @param props.simplifyPhrases - When true, phrase-level controls are hidden on every phrase except * the focused one. + * @param props.chapterLabelInVerse - When true, every verse is labeled `chapter:verse` instead of + * showing an inline chapter header. * @returns The interlinearizer layout without the provider wrapper. */ function InterlinearizerInner({ book, - chapterSegments, continuousScroll, scrRef, - setScrRef, phraseMode, setPhraseMode, hideInactiveLinkButtons, simplifyPhrases, + chapterLabelInVerse, }: Omit) { + // Navigation surface from the context: `navigate` writes the reference (classifying internal vs + // external at the call site), `consumeInternalNav` lets the segment window suppress the fade for + // internal moves, and `reportSettled` lifts the cross-book curtain once the new book is laid out. + const { navigate, consumeInternalNav, reportSettled } = useInterlinearNav(); + + /** + * 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) => isSameVerse(seg.startRef, scrRef)), + [book.segments, scrRef], + ); + // 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(() => + firstWordTokenRefOf(findActiveSegment()), + ); // 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(firstWordTokenRefOf(findActiveSegment())); + // findActiveSegment changes with scrRef too; only re-seed on book change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [book]); - /** Maps every segment id to the segment; used to resolve a focused token's verse. */ - const segmentById = useMemo(() => { - const map = new Map(); - book.segments.forEach((seg) => map.set(seg.id, seg)); - return map; - }, [book.segments]); - - /** All word tokens in book order — index into this array is the phrase index. */ - const wordTokens = useMemo( - () => book.segments.flatMap((seg) => seg.tokens).filter(isWordToken), - [book.segments], - ); - - /** - * Maps every word token ref to its flat book-level index; used to sort phrase tokens in document - * order. - */ - const tokenDocOrder = useMemo(() => { - const map = new Map(); - wordTokens.forEach((t, i) => map.set(t.ref, i)); - return map; - }, [wordTokens]); - - /** Maps every token ref to the id of the segment that contains it. */ - const tokenSegmentMap = useMemo(() => { - const map = new Map(); - book.segments.forEach((seg) => { - seg.tokens.forEach((t) => map.set(t.ref, seg.id)); - }); - return map; - }, [book.segments]); - - /** Maps every word token ref to the token; used by views to resolve focus context. */ - const wordTokenByRef = useMemo(() => { - const map = new Map(); - wordTokens.forEach((t) => map.set(t.ref, t)); - return map; - }, [wordTokens]); - - const scrollContainerRef = useRef(undefined); - - /** - * Ref callback that stores the scroll container element so imperative scroll calls can target it. - * - * @param el - The mounted div, or `null` on unmount. - */ - const setScrollContainer = useCallback((el: HTMLDivElement | null) => { - scrollContainerRef.current = el ?? undefined; - }, []); - - /** - * Scrolls the element marked `aria-current="true"` inside the scroll container into view at the - * top of the list. - */ - const snapToActive = useCallback(() => { - const container = scrollContainerRef.current; - const active = container?.querySelector('[aria-current="true"]'); - /* v8 ignore next -- active is always found when a verse is rendered; guard for empty lists */ - active?.scrollIntoView({ behavior: 'auto', block: 'start' }); - }, []); + // Book-wide lookup indexes the views share, built in one pass over the segment list. + const { segmentById, tokenDocOrder, tokenSegmentMap, wordTokenByRef } = useBookIndexes(book); /** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */ const [hoveredPhraseId, setHoveredPhraseId] = useState(); + // Continuous-scroll mode actually rendered, passed back down to SegmentListView as the display + // mode its segments render. A toggle defers this to the recenter midpoint so the horizontal strip + // mounts/unmounts in lockstep with the segments' display swap — never on the old content the + // instant the toggle flips. The fade clock lives in `useSegmentWindow` (inside SegmentListView), + // which flips this setter inside its midpoint state batch — so the strip below mounts/unmounts in + // the *same* React commit as the list's window rebuild, and the post-recenter re-snap measures + // the active verse against the strip-included layout. + const [displayContinuousScroll, setDisplayContinuousScroll] = useState(continuousScroll); + + // Fade the whole interlinearizer (strip + list) out and back in across a continuous-scroll toggle, + // so the strip and the list animate as one unit rather than the list fading under a strip that + // pops in/out. The toggle flips `continuousScroll` immediately but the rendered mode + // (`displayContinuousScroll`) only catches up at the recenter midpoint; the window between the two + // is exactly the fade-out half, so keying opacity off their mismatch gives a clean out-then-in + // cycle on the shared clock without any extra timer here. External verse navigation never changes + // these, so it leaves the wrapper fully opaque (the list still runs its own recenter fade). + const isModeToggleFading = continuousScroll !== displayContinuousScroll; + /** The segment id that contains the phrase currently being edited, if any. */ const editPhraseSegmentId = useMemo(() => { if (phraseMode.kind !== 'edit') return undefined; @@ -174,70 +175,84 @@ function InterlinearizerInner({ updatePhrase(phraseMode.phraseId, phraseMode.originalTokens); setPhraseMode({ kind: 'view' }); // phraseMode is intentionally omitted: adding it would re-fire on every edit - // keystroke; isRevert changing to true guarantees phraseMode holds the revert values. + // keystroke; isRevert changing to true guarantees phraseMode holds the revert values — a + // guarantee that holds only while isRevert stays derived directly from phraseMode above, so + // don't move or memoize that derivation independently of this effect. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRevert, updatePhrase, setPhraseMode]); - // Snap the segment list to the active verse when switching modes. - useEffect(() => { - snapToActive(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [continuousScroll]); - // Reseed focusedTokenRef when scrRef changes externally (e.g. Paratext verse selector). Skip // 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. + // token click here (or a strip nav echoed back through `focusToken`), and we must not clobber the + // deliberately-focused token with the verse's first token. A verse-exact match is intentional: an + // external jump *within* a chapter (common in long chapters like Psalm 119) must still move focus + // to the newly-named verse, so matching the whole chapter would wrongly strand focus. Internal + // navigation never reaches the reseed branch here because the click/strip handler has already set + // focus into the target verse; the fade is separately suppressed by the segment window's + // `consumeInternalNav` (kept key-symmetric with the host echo in `InterlinearNavContext`). 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 */ - 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. + /* v8 ignore next -- activeSeg is always defined when the book includes the active verse */ + setFocusedTokenRef(firstWordTokenRefOf(activeSeg)); + // findActiveSegment is intentionally excluded: the verse-coordinate deps already capture the + // change we care about, and it changes identity on every scrRef update. focusedTokenRef and + // tokenSegmentMap are excluded too — they are read only as guards; as deps they would re-run + // this effect on every focus move and clobber the deliberately-focused token with the verse's + // first word. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrRef.book, scrRef.chapterNum, scrRef.verseNum]); - // Update scrRef when focusedTokenRef moves into a different verse (e.g. arrow nav in the - // continuous strip). Skip when scrRef already matches — that case means scrRef and focus - // were set together by a click and no further work is needed. - useEffect(() => { - if (!focusedTokenRef) return; - const segId = tokenSegmentMap.get(focusedTokenRef); - /* v8 ignore next -- focusedTokenRef is always set from tokens in tokenSegmentMap */ - if (!segId) return; - 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 - ) { - return; - } - setScrRef({ - book: seg.startRef.book, - chapterNum: seg.startRef.chapter, - verseNum: seg.startRef.verse, - }); - // 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 - }, [focusedTokenRef, tokenSegmentMap, segmentById, setScrRef]); + const scrRefRef = useLatestRef(scrRef); /** - * Updates the active scripture reference and, when a specific token was clicked, focuses that - * token. + * Focuses `tokenRef` and, when it lives in a different verse than the active one, navigates + * there. The single explicit focus-move operation behind strip arrow nav and phrase clicks: it + * both sets the focused token and pushes the verse change as an _internal_ navigation (so the + * segment window tracks along without a recenter fade). Replaces the former focus→scrRef "echo" + * effect, which watched `focusedTokenRef` and re-derived the navigation after the fact; doing it + * inline removes that indirection. + * + * Never navigates when the focused token's book differs from the active `scrRef`'s book: during + * an external book change `scrRef` can briefly name the new book while the mounted book (and this + * token) still belong to the previous one, and echoing that stale verse would overwrite the new + * reference. (The loader's `viewScrRef` freeze normally keeps the two in sync, so this guards a + * transient.) + * + * @param tokenRef - The word-token ref to focus. + */ + const focusToken = useCallback( + (tokenRef: string) => { + setFocusedTokenRef(tokenRef); + const segId = tokenSegmentMap.get(tokenRef); + /* v8 ignore next 2 -- tokenRef always resolves to a segment in the mounted book */ + const seg = segId === undefined ? undefined : segmentById.get(segId); + if (!seg) return; + const { current } = scrRefRef; + if (seg.startRef.book !== current.book) return; + if (isSameVerse(seg.startRef, current)) return; + navigate(toSerializedVerseRef(seg.startRef), 'internal'); + }, + [segmentById, tokenSegmentMap, navigate, scrRefRef], + ); + + /** + * Updates the active scripture reference (when the verse actually changed) and, when a specific + * token was clicked, focuses that token. Skips the write to PAPI when the clicked verse matches + * the current one, avoiding a gratuitous echo round-trip. * * @param ref - The verse coordinate that was selected. * @param tokenRef - The token that was clicked; omitted when the whole segment was selected. */ const handleSegmentSelect = useCallback( (ref: ScriptureRef, tokenRef?: string) => { - setScrRef({ book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }); + const { current } = scrRefRef; + if (!isSameVerse(ref, current)) { + navigate(toSerializedVerseRef(ref), 'internal'); + } if (tokenRef) setFocusedTokenRef(tokenRef); }, - [setScrRef], + [navigate, scrRefRef], ); return ( @@ -251,71 +266,50 @@ function InterlinearizerInner({ )}
)} - {continuousScroll && ( -
- -
- )} -
- {chapterSegments.length === 0 && ( -

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

+ {displayContinuousScroll && ( +
+ +
)} - {chapterSegments.length > 0 && ( - <> -
- -
- -
- {chapterSegments.map((seg) => ( - - ))} -
- - )} +
); @@ -327,11 +321,10 @@ function InterlinearizerInner({ * descendant components can read and write analysis data without prop drilling. * * @param props - Component props - * @param props.book - Book data used by the continuous view - * @param props.chapterSegments - Segments to render as individual verse views + * @param props.book - Book data used by the continuous view and segment window * @param props.continuousScroll - Whether the continuous scroll view is shown - * @param props.scrRef - Current scripture reference - * @param props.setScrRef - Callback to update the scripture reference + * @param props.scrRef - Current scripture reference; must be normalized (verse >= 1, see + * {@link InterlinearizerProps}). * @param props.initialAnalysis - Seed analysis data for the store; not reactive after mount * @param props.analysisLanguage - BCP 47 tag for gloss read/write * @param props.onSaveAnalysis - Called after each gloss write with the updated `TextAnalysis` diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 3b5cd0ef..470064a9 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -14,11 +14,23 @@ import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; import type { PhraseMode } from '../types/phrase-mode'; import ProjectModals, { type ModalState } from './modals/ProjectModals'; import ScriptureNavControls from './controls/ScriptureNavControls'; +import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext'; +import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; /** - * Root component for the Interlinearizer WebView. Loads book data and settings, manages modal state - * for project creation/selection/metadata, then renders error and loading states or delegates to - * {@link Interlinearizer} when data is ready. + * WebView menu holding only the platform defaults. Used both as the `useData` default while the + * provider's menu is loading and as the fallback when it returns an error. + */ +const DEFAULT_WEB_VIEW_MENU = { + topMenu: undefined, + includeDefaults: true, + contextMenu: undefined, +}; + +/** + * Root component for the Interlinearizer WebView. Mounts the {@link InterlinearNavProvider} so the + * loader and the whole {@link Interlinearizer} subtree read and write navigation through one source + * of truth, then delegates the actual loading/rendering to {@link InterlinearizerLoaderInner}. * * @param props - Component props * @param props.projectId - PAPI project ID passed from the host @@ -26,8 +38,7 @@ import ScriptureNavControls from './controls/ScriptureNavControls'; * reference and its setter * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted * by the PAPI host - * @returns The toolbar and either an error/loading state or the fully rendered - * {@link Interlinearizer} + * @returns The nav provider wrapping {@link InterlinearizerLoaderInner} */ export default function InterlinearizerLoader({ projectId, @@ -38,7 +49,42 @@ export default function InterlinearizerLoader({ useWebViewScrollGroupScrRef: UseWebViewScrollGroupScrRefHook; useWebViewState: UseWebViewStateHook; }>) { - const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + return ( + + + + ); +} + +/** + * Loads book data and settings, manages modal state for project creation/selection/metadata, then + * renders error and loading states or delegates to {@link Interlinearizer} when data is ready. Reads + * the scripture reference and scroll-group linkage from {@link useInterlinearNav} rather than + * calling the host hook directly. + * + * @param props - Component props + * @param props.projectId - PAPI project ID passed from the host + * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted + * by the PAPI host + * @returns The toolbar and either an error/loading state or the fully rendered + * {@link Interlinearizer} + */ +function InterlinearizerLoaderInner({ + projectId, + useWebViewState, +}: Readonly<{ + projectId: string; + useWebViewState: UseWebViewStateHook; +}>) { + const { + rawScrRef, + liveScrRef: scrRef, + navigate, + scrollGroupId, + setScrollGroupId, + fadePhase, + cancelFade, + } = useInterlinearNav(); const [interfaceMode] = useSetting('platform.interfaceMode', 'simple'); const [interfaceLanguages] = useSetting('platform.interfaceLanguage', ['und']); @@ -126,7 +172,7 @@ export default function InterlinearizerLoader({ } }; - loadAnalysis().catch(() => {}); + loadAnalysis(); return () => { canceled = true; @@ -168,16 +214,39 @@ export default function InterlinearizerLoader({ value: simplifyPhrases, } = useOptimisticBooleanSetting(projectId, 'interlinearizer.simplifyPhrases', false); - const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( - { projectId, scrRef }, - ); + const { + isLoading: isChapterLabelInVerseLoading, + onChange: handleChapterLabelInVerseChange, + value: chapterLabelInVerse, + } = useOptimisticBooleanSetting(projectId, 'interlinearizer.chapterLabelInVerse', false); + + const { book, isLoading, bookError, tokenizeError } = useInterlinearizerBookData({ + projectId, + scrRef, + }); const hasError = !!bookError || !!tokenizeError; const isSettingLoading = - isContinuousScrollLoading || isHideInactiveLinkButtonsLoading || isSimplifyPhrasesLoading; - const showLoading = isLoading || isAnalysisLoading || isSettingLoading; + isContinuousScrollLoading || + isHideInactiveLinkButtonsLoading || + isSimplifyPhrasesLoading || + isChapterLabelInVerseLoading; + // True during a cross-book swap: the live `scrRef` already names the new book but the loaded `book` + // is still the previous one (its USJ hasn't arrived yet). The old `Interlinearizer` is still + // mounted here; showing it (even frozen on its last in-book reference) lets the previous book's + // components stay visible while the new book loads, so the swap is seen before the fade hides it. + // Treating this window as loading swaps the old view for the Loading… curtain immediately, so + // nothing of either book shows until the new one has mounted and fades in. + const isCrossBookSwap = !!book && scrRef.book !== book.bookRef; + const showLoading = isLoading || isAnalysisLoading || isSettingLoading || isCrossBookSwap; const isLoaded = !hasError && !showLoading && !!book; + // Abort any in-flight cross-book fade when the new book fails to load, so the error is revealed + // rather than left hidden behind a curtain that will never receive a settle. + useEffect(() => { + if (hasError) cancelFade(); + }, [hasError, cancelFade]); + const [modal, setModal] = useState('none'); const [phraseMode, setPhraseMode] = useState({ kind: 'view' }); @@ -216,7 +285,7 @@ export default function InterlinearizerLoader({ */ const [webViewMenuPossiblyError] = useData(papi.menuData.dataProviderName).WebViewMenu( 'interlinearizer.mainWebView', - { topMenu: undefined, includeDefaults: true, contextMenu: undefined }, + DEFAULT_WEB_VIEW_MENU, ); /** @@ -230,7 +299,7 @@ export default function InterlinearizerLoader({ const menu = webViewMenuPossiblyError && !isPlatformError(webViewMenuPossiblyError) ? webViewMenuPossiblyError - : { topMenu: undefined, includeDefaults: true, contextMenu: undefined }; + : DEFAULT_WEB_VIEW_MENU; if (!menu.topMenu || activeProject) return menu.topMenu; const { items } = menu.topMenu; /* v8 ignore next */ if (!Array.isArray(items)) return menu.topMenu; @@ -250,8 +319,8 @@ export default function InterlinearizerLoader({ startAreaChildren={ interfaceMode === 'power' ? ( @@ -266,6 +335,8 @@ export default function InterlinearizerLoader({ onHideInactiveLinkButtonsChange={handleHideInactiveLinkButtonsChange} simplifyPhrases={simplifyPhrases} onSimplifyPhrasesChange={handleSimplifyPhrasesChange} + chapterLabelInVerse={chapterLabelInVerse} + onChapterLabelInVerseChange={handleChapterLabelInVerseChange} /> ) : undefined } @@ -276,43 +347,59 @@ export default function InterlinearizerLoader({ }} /> - {hasError || showLoading || !book ? ( -
- {bookError && ( -
-

Error loading book

-
{bookError}
-
- )} - - {tokenizeError && ( -
-

Error processing book

-
{tokenizeError.message}
-
- )} - - {!hasError && showLoading && ( -

Loading…

- )} -
- ) : ( - - )} +
+ {hasError || showLoading || !book ? ( +
+ {bookError && ( +
+

Error loading book

+
{bookError}
+
+ )} + + {tokenizeError && ( +
+

Error processing book

+
{tokenizeError.message}
+
+ )} + + {!hasError && showLoading && ( +

Loading…

+ )} +
+ ) : ( + + )} +
) => { if (e.target instanceof Element && e.target.closest('input, button, a, label')) return; - e.currentTarget.querySelector('input')?.focus(); + e.currentTarget.querySelector('input')?.focus({ preventScroll: true }); }, []); /** diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx new file mode 100644 index 00000000..c715cd32 --- /dev/null +++ b/src/components/SegmentListView.tsx @@ -0,0 +1,263 @@ +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, ScriptureRef, Token } from 'interlinearizer'; +import { LocateFixed } from 'lucide-react'; +import { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import type { PhraseMode } from '../types/phrase-mode'; +import MemoizedSegmentView from './SegmentView'; +import useSegmentWindow from '../hooks/useSegmentWindow'; +import { isSameVerse } from '../utils/verse-ref'; +import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; + +/** Props for {@link SegmentListView}. */ +type SegmentListViewProps = Readonly<{ + /** Tokenized book whose segments are windowed and rendered. */ + book: Book; + /** Current scripture reference; its verse is the recenter anchor and active-verse highlight. */ + scrRef: SerializedVerseRef; + /** Token ref of the currently focused word token, or `undefined` when nothing is focused. */ + focusedTokenRef: string | undefined; + /** When true, the horizontal token strip is shown above this list (changes display mode). */ + continuousScroll: boolean; + /** + * Continuous-scroll mode the segments actually render. Owned by the parent and updated through + * {@link SegmentListViewProps.onDisplayContinuousScrollChange} at the recenter midpoint, so the + * parent's strip and this list's display mode swap in the same React commit, behind the fade. + */ + displayContinuousScroll: boolean; + /** + * Reports the gated continuous-scroll value — the mode that should actually be rendered, which a + * toggle defers to the recenter midpoint (behind the fade). Forwarded straight into + * {@link useSegmentWindow}, which calls it inside the midpoint state batch so the parent's strip + * mounts/unmounts in the same commit as this list's window rebuild. + */ + onDisplayContinuousScrollChange: (displayContinuousScroll: boolean) => void; + /** + * Consumes the internal-navigation classification for a reference so the window can suppress its + * recenter fade for navigation that originated within the views. + */ + consumeInternalNav: (ref: SerializedVerseRef) => boolean; + /** Reports that the window has settled on the current book; lifts the cross-book curtain. */ + reportSettled: () => void; + /** Current phrase-interaction mode; passed through to each {@link SegmentView}. */ + phraseMode: PhraseMode; + /** Setter for `phraseMode`; passed down so child components can transition modes. */ + setPhraseMode: Dispatch>; + /** When true, link buttons between phrases are hidden in segments other than the active verse. */ + hideInactiveLinkButtons: boolean; + /** When true, phrase-level controls are hidden on every phrase except the focused one. */ + simplifyPhrases: boolean; + /** + * When true, every verse is labeled `chapter:verse` and no inline chapter header is shown; when + * false, verse labels stay bare verse numbers and an inline chapter header precedes the first + * verse of each chapter. + */ + chapterLabelInVerse: boolean; + /** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */ + hoveredPhraseId: string | undefined; + /** Sets the hovered phraseId when the pointer enters or leaves a phrase box. */ + setHoveredPhraseId: (phraseId: string | undefined) => void; + /** Segment id that contains the phrase currently being edited, or `undefined`. */ + editPhraseSegmentId: string | undefined; + /** Called when a segment or one of its word tokens is selected. */ + onSelect: (ref: ScriptureRef, tokenRef?: string) => void; + /** Maps every token ref to the id of the segment that contains it. */ + tokenSegmentMap: ReadonlyMap; + /** Maps every word token ref to its flat book-level index; used to sort phrase tokens. */ + tokenDocOrder: ReadonlyMap; + /** Maps every word token ref to the token; used by segments to resolve focus context. */ + wordTokenByRef: ReadonlyMap; +}>; + +/** + * Renders the scroll-anchored, infinitely-scrolling list of segments for the active book. Owns the + * scroll container, the {@link useSegmentWindow} window into the book's segments, the LocateFixed + * "scroll to active verse" button, the recenter fade wrapper, and the top/bottom infinite-scroll + * sentinels. Extracted from {@link Interlinearizer} so the list — which carries the bulk of the + * scroll/fade/window machinery — lives in one focused component. + * + * @param props - Component props + * @param props.book - Tokenized book whose segments are windowed and rendered. + * @param props.scrRef - Current scripture reference; its verse is the recenter anchor. + * @param props.focusedTokenRef - Token ref of the currently focused word token, or `undefined`. + * @param props.continuousScroll - When true, the horizontal token strip is shown above this list. + * @param props.displayContinuousScroll - Continuous-scroll mode the segments actually render; owned + * by the parent and updated at the recenter midpoint. + * @param props.onDisplayContinuousScrollChange - Reports the gated continuous-scroll value + * (deferred to the recenter midpoint) so the parent mounts/unmounts the strip in lockstep with + * this list. + * @param props.consumeInternalNav - Consumes the internal-nav classification to suppress the fade. + * @param props.reportSettled - Reports the window has settled; lifts the cross-book curtain. + * @param props.phraseMode - Current phrase-interaction mode passed down for rendering. + * @param props.setPhraseMode - Setter for `phraseMode`. + * @param props.hideInactiveLinkButtons - When true, link buttons are hidden outside the active + * verse. + * @param props.simplifyPhrases - When true, phrase controls are hidden except on the focused + * phrase. + * @param props.chapterLabelInVerse - When true, every verse is labeled `chapter:verse` instead of + * showing an inline chapter header. + * @param props.hoveredPhraseId - PhraseId currently hovered anywhere in the interlinearizer. + * @param props.setHoveredPhraseId - Sets the hovered phraseId. + * @param props.editPhraseSegmentId - Segment id containing the phrase being edited, or `undefined`. + * @param props.onSelect - Called when a segment or one of its word tokens is selected. + * @param props.tokenSegmentMap - Token ref → segment id lookup. + * @param props.tokenDocOrder - Word token ref → flat book-level index. + * @param props.wordTokenByRef - Word token ref → token lookup for the whole book. + * @returns The scrollable segment list with its fade wrapper, sentinels, and locate button. + */ +export default function SegmentListView({ + book, + scrRef, + focusedTokenRef, + continuousScroll, + displayContinuousScroll, + onDisplayContinuousScrollChange, + consumeInternalNav, + reportSettled, + phraseMode, + setPhraseMode, + hideInactiveLinkButtons, + simplifyPhrases, + chapterLabelInVerse, + hoveredPhraseId, + setHoveredPhraseId, + editPhraseSegmentId, + onSelect, + tokenSegmentMap, + tokenDocOrder, + wordTokenByRef, +}: SegmentListViewProps) { + /** + * Ids of the segments that begin a new chapter — the first segment of the book and every segment + * whose chapter differs from the immediately preceding segment in book order. Computed over the + * whole `book.segments` list (not just the mounted window) so a chapter boundary is detected even + * when the chapter's first segment scrolls in mid-window, and so the marker never depends on + * which slice happens to be mounted. + */ + const chapterStartIds = useMemo(() => { + const ids = new Set(); + let prevChapter: number | undefined; + book.segments.forEach((seg) => { + if (seg.startRef.chapter !== prevChapter) ids.add(seg.id); + prevChapter = seg.startRef.chapter; + }); + return ids; + }, [book.segments]); + + const scrollContainerRef = useRef(undefined); + + /** + * Ref callback that stores the scroll container element so imperative scroll calls can target it. + * + * @param el - The mounted div, or `null` on unmount. + */ + const setScrollContainer = useCallback((el: HTMLDivElement | null) => { + scrollContainerRef.current = el ?? undefined; + }, []); + + // 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, + displayFocusedTokenRef, + topSentinelRef, + bottomSentinelRef, + contentRef, + recenterOnActive, + } = useSegmentWindow({ + book, + scrRef, + focusedTokenRef, + continuousScroll, + scrollContainerRef, + consumeInternalNav, + onDisplayContinuousScrollChange, + onSettled: reportSettled, + }); + + // Recenter the segment list on the active verse when switching between continuous and segment + // modes. Skips the initial mount: the window is already built centered on the anchor there, so a + // recenter would needlessly fade. Only an actual mode toggle should fade-and-recenter. + // `recenterOnActive` has a stable identity, so listing it as a dep doesn't re-fire this. + const didMountModeSwitchRef = useRef(false); + useEffect(() => { + if (!didMountModeSwitchRef.current) { + didMountModeSwitchRef.current = true; + return; + } + recenterOnActive(); + }, [continuousScroll, recenterOnActive]); + + return ( +
+ {windowSegments.length === 0 && ( +

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

+ )} + + {windowSegments.length > 0 && ( + <> +
+ +
+ +
+