diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index ed68e273..e623da75 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -4,18 +4,30 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer'; -import { useState, type ReactNode } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import type { PhraseDispatch } from '../../components/AnalysisStore'; import { AltHeldProvider } from '../../components/AltHeldContext'; import ContinuousView from '../../components/ContinuousView'; +import { + createFocusStore, + FocusStoreProvider, + type FocusActions, + type FocusOrigin, +} from '../../components/FocusStore'; import { SegmentationProvider, type SegmentationContextValue, } from '../../components/SegmentationStore'; +import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import { isWordToken } from '../../types/type-guards'; -import type { ViewOptions } from '../../types/view-options'; -import { FIXTURE_STAMPS, makePunctToken, makeSegment, makeWordToken } from '../test-helpers'; +import { + FIXTURE_STAMPS, + makePhraseLink, + makePunctToken, + makeSegment, + makeWordToken, +} from '../test-helpers'; import { allFalseViewOptions, mockKeyAsValueLocalizedStrings, @@ -247,7 +259,7 @@ function makeLargeBook(count: number): Book { const scrollIntoViewMock = jest.fn(); -/** Builds the lookup maps that ContinuousView's parent supplies, derived from a Book. */ +/** Builds the lookup maps the strip is handed, derived from a Book. */ function buildLookups(book: Book): { tokenSegmentMap: ReadonlyMap; tokenDocOrder: ReadonlyMap; @@ -270,32 +282,14 @@ function buildLookups(book: Book): { return { tokenSegmentMap, tokenDocOrder, wordTokenByRef }; } -/** - * Minimal required props for ContinuousView. Spread into render calls so tests only need to - * override what they actually care about. The lookup maps are derived from `book` so they always - * agree with what's rendered. - */ -function requiredProps( - book: Book, - overrides?: { focusedTokenRef?: string | undefined }, -): { - book: Book; - editPhraseSegmentId: string | undefined; - focusedTokenRef: string | undefined; - onFocusedTokenRefChange: jest.Mock; - phraseMode: { kind: 'view' }; - setPhraseMode: jest.Mock; - tokenSegmentMap: ReadonlyMap; - tokenDocOrder: ReadonlyMap; - wordTokenByRef: ReadonlyMap; - viewOptions: ViewOptions; -} { +type StripProps = ComponentProps; + +/** Minimal strip props, so a test states only what it actually varies. */ +function requiredProps(book: Book): StripProps { const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); return { book, editPhraseSegmentId: undefined, - focusedTokenRef: overrides?.focusedTokenRef, - onFocusedTokenRefChange: jest.fn(), phraseMode: { kind: 'view' }, setPhraseMode: jest.fn(), tokenSegmentMap, @@ -305,6 +299,52 @@ function requiredProps( }; } +/** What {@link renderStrip} hands back for driving and observing the mounted strip. */ +type Strip = { + /** Every focus the strip wrote, as `(tokenRef, origin)`. */ + focusToken: jest.Mock; + /** Applies a focus from outside the strip, under the origin it should carry. */ + setFocus: (tokenRef: string | undefined, origin: FocusOrigin) => void; + /** Re-renders with `next` merged over the strip's props; call with nothing for a plain re-render. */ + update: (next?: Partial) => void; + container: HTMLElement; +}; + +/** + * Mounts the strip over a real focus store seeded with `focus`, so focus arrives and leaves through + * the store exactly as it does in the app. + */ +function renderStrip( + book: Book, + options?: Readonly<{ focus?: string; props?: Partial }>, +): Strip { + const store = createFocusStore(options?.focus); + const focusToken = jest.fn((tokenRef: string, origin: FocusOrigin) => + store.write(tokenRef, origin), + ); + const actions: FocusActions = { focusToken, selectSegment: jest.fn() }; + let props: StripProps = { ...requiredProps(book), ...options?.props }; + const element = () => ( + + + + ); + + const view = render(element(), withAnalysisStore); + + return { + focusToken, + container: view.container, + setFocus: (tokenRef, origin) => { + act(() => store.write(tokenRef, origin)); + }, + update: (next) => { + props = { ...props, ...next }; + view.rerender(element()); + }, + }; +} + /** Every {@link TrackingResizeObserver} created since the last reset, newest last. */ let resizeObserverInstances: TrackingResizeObserver[] = []; @@ -363,7 +403,7 @@ beforeEach(() => { describe('ContinuousView initial render', () => { it('renders all tokens from all segments as a flat list', () => { const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('the')).toBeInTheDocument(); @@ -373,7 +413,7 @@ describe('ContinuousView initial render', () => { it('renders an inline verse-number superscript at each verse start', () => { const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); // Verses 1 and 2; the first opens chapter 1, so its label is chapter-qualified (`1:1`). const sups = screen.getAllByTestId('verse-superscript'); @@ -402,7 +442,7 @@ describe('ContinuousView initial render', () => { }, ], }); - render(, withAnalysisStore); + renderStrip(splitBook); const sups = screen.getAllByTestId('verse-superscript'); expect(sups.map((s) => s.textContent)).toEqual(['1:1']); @@ -410,14 +450,14 @@ describe('ContinuousView initial render', () => { it('does not render an extension-generated segment separator', () => { const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); expect(screen.queryByText('GEN 1:1')).not.toBeInTheDocument(); }); it('renders a Previous token button and a Next token button', () => { const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), @@ -429,7 +469,7 @@ describe('ContinuousView initial render', () => { it('renders a non-word token via InertTokenChip within the strip', () => { const book = makeMixedBook(); - render(, withAnalysisStore); + renderStrip(book); expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('.')).toBeInTheDocument(); @@ -437,49 +477,63 @@ describe('ContinuousView initial render', () => { it('renders without crashing when book has no word tokens', () => { const book = makeWordFreeBook(); - render(, withAnalysisStore); + renderStrip(book); expect(screen.getByText('.')).toBeInTheDocument(); }); - it('notifies the parent of the initially-focused token on mount when no focus prop is set', () => { + it('names its own focus on mount when nothing resolved one', () => { const book = makeBook(); - const props = requiredProps(book); - render(, withAnalysisStore); + const strip = renderStrip(book); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-0'); + expect(strip.focusToken).toHaveBeenCalledWith('tok-0', 'seed'); }); - it('does not notify the parent on mount when focusedTokenRef is already set', () => { + it('names no focus on mount when one is already resolved', () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-1' }); - expect(props.onFocusedTokenRefChange).not.toHaveBeenCalled(); + expect(strip.focusToken).not.toHaveBeenCalled(); }); - it('marks the phrase containing focusedTokenRef as focused', () => { + it('marks the phrase containing the focused token as focused', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-2' }); 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, briefly naming a token absent - // from the new book; focus must follow the live focusedTokenRef, not collapse to phrase 0. - const book = makeBook(); - const { rerender } = render( - , - withAnalysisStore, + it('falls back to the live focus while the displayed ref names a token this book lacks', () => { + // Through a book change the displayed ref lags by one fade, briefly naming a token absent from + // the mounted book. Seeded with such a ref, so the window has to follow the live focus rather + // than collapse to phrase 0. + const otherBook: Book = { + id: 'MAT', + bookRef: 'MAT', + textVersion: '1', + segments: [ + makeSegment('MAT 1:1', 'Alpha', [makeWordToken('mat-tok-0', 'Alpha')]), + makeSegment('MAT 1:2', 'Beta', [makeWordToken('mat-tok-1', 'Beta')]), + ], + }; + const strip = renderStrip(otherBook, { focus: 'tok-2' }); + + scrollIntoViewMock.mockClear(); + strip.setFocus('mat-tok-1', 'reseed'); + + // The scroll lands on "Beta" (the live focus), never "Alpha" (phrase 0). + 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); + }); - // A different book sharing no token refs: the displayed 'tok-2' is now absent, and - // focusedTokenRef points at the new book's second phrase. + it('centers the focused group after a book swap', () => { + // A book swap drops the group ref setters, which are keyed by absolute group index and so name + // different groups in the new book. Centering afterwards proves the new book's groups took + // setters of their own rather than inheriting dead ones. const otherBook: Book = { id: 'MAT', bookRef: 'MAT', @@ -489,49 +543,47 @@ describe('ContinuousView initial render', () => { makeSegment('MAT 1:2', 'Beta', [makeWordToken('mat-tok-1', 'Beta')]), ], }; + const strip = renderStrip(makeBook(), { focus: 'tok-2' }); scrollIntoViewMock.mockClear(); - rerender(); + strip.update({ book: otherBook, ...buildLookups(otherBook) }); + strip.setFocus('mat-tok-1', 'reseed'); - // The scroll lands on "Beta" (the focusedTokenRef phrase), never "Alpha" (phrase 0). 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); }); }); describe('ContinuousView focus changes', () => { - it('notifies the parent when an out-of-focus phrase box is clicked', async () => { + it('focuses an out-of-focus phrase box when it is clicked', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); const clickedPhraseBox = screen.getByText('beginning').closest('[data-phrase-box="true"]'); if (!clickedPhraseBox) throw new Error('Expected phrase box wrapper for token'); await userEvent.click(clickedPhraseBox); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-2'); + expect(strip.focusToken).toHaveBeenCalledWith('tok-2', 'strip'); }); - it('does not notify the parent when clicking the already-focused phrase box', async () => { + it('moves no focus when the already-focused phrase box is clicked', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); const firstPhraseBox = screen.getByText('In').closest('[data-phrase-box="true"]'); if (!firstPhraseBox) throw new Error('Expected phrase box wrapper for token'); await userEvent.click(firstPhraseBox); - expect(props.onFocusedTokenRefChange).not.toHaveBeenCalled(); + expect(strip.focusToken).not.toHaveBeenCalled(); }); - it('does not notify the parent when clicking the group of an already-focused non-first token', async () => { + it('moves no focus when clicking the group of an already-focused non-first token', async () => { // tok-0/tok-1 grouped into one box (keyed by tok-0) with focus on tok-1: clicking the box stays - // a no-op even though its groupKey differs from focusedTokenRef. + // a no-op even though its group key differs from the focused token. const phraseLink: PhraseAnalysisLink = { ...FIXTURE_STAMPS, analysisId: 'phrase-1', @@ -544,38 +596,35 @@ describe('ContinuousView focus changes', () => { phraseLinkMap.set('tok-0', phraseLink); phraseLinkMap.set('tok-1', phraseLink); const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-1' }); const groupedBox = screen.getByText('In').closest('[data-phrase-box="true"]'); if (!groupedBox) throw new Error('Expected phrase box wrapper for grouped tokens'); await userEvent.click(groupedBox); - expect(props.onFocusedTokenRefChange).not.toHaveBeenCalled(); + expect(strip.focusToken).not.toHaveBeenCalled(); }); - it('notifies the parent when clicking a phrase box while nothing is focused', async () => { + it('focuses the clicked phrase box when nothing was focused', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: undefined }); - render(, withAnalysisStore); + const strip = renderStrip(book); const firstPhraseBox = screen.getByText('In').closest('[data-phrase-box="true"]'); if (!firstPhraseBox) throw new Error('Expected phrase box wrapper for token'); await userEvent.click(firstPhraseBox); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-0'); + // The mount seed already put focus on tok-0, so the click adds no move of its own. + expect(strip.focusToken).toHaveBeenCalledTimes(1); + expect(strip.focusToken).toHaveBeenCalledWith('tok-0', 'seed'); }); }); describe('ContinuousView arrow disabled states', () => { it('disables the prev arrow when focus is on the first phrase', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-0' }); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), @@ -584,10 +633,7 @@ describe('ContinuousView arrow disabled states', () => { it('enables the prev arrow when focus is on a non-first phrase', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-2' }); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), @@ -596,10 +642,7 @@ describe('ContinuousView arrow disabled states', () => { it('disables the next arrow when focus is on the last phrase', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-3' }); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), @@ -608,10 +651,7 @@ describe('ContinuousView arrow disabled states', () => { it('enables the next arrow when focus is on a non-last phrase', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-0' }); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), @@ -620,10 +660,7 @@ describe('ContinuousView arrow disabled states', () => { it('disables both arrows when the book has a single token', () => { const book = makeSingleTokenBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-only' }); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), @@ -635,7 +672,7 @@ describe('ContinuousView arrow disabled states', () => { it('disables both arrows when the book has no word tokens', () => { const book = makeWordFreeBook(); - render(, withAnalysisStore); + renderStrip(book); expect( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), @@ -644,102 +681,146 @@ describe('ContinuousView arrow disabled states', () => { screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ).toBeDisabled(); }); + + it('disables both arrows until the strip adopts a focus it has to travel to', () => { + // The arrows stay on screen through the fade, so without this a press would step from the + // incoming focus while the reader is still looking at the group it left. + jest.useFakeTimers(); + try { + const strip = renderStrip(makeBook(), { focus: 'tok-1' }); + const prev = () => + screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }); + const next = () => + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }); + expect(next()).not.toBeDisabled(); + + strip.setFocus('tok-3', 'list'); + expect(prev()).toBeDisabled(); + expect(next()).toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + expect(prev()).not.toBeDisabled(); + } finally { + jest.useRealTimers(); + } + }); }); describe('ContinuousView arrow navigation', () => { - it('notifies the parent of the next phrase ref when Next is clicked', async () => { + it('focuses the next phrase when Next is clicked', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-1'); + expect(strip.focusToken).toHaveBeenCalledWith('tok-1', 'strip'); }); - it('notifies the parent of the previous phrase ref when Previous is clicked', async () => { + it('focuses the previous phrase when Previous is clicked', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-1' }); await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), ); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-0'); + expect(strip.focusToken).toHaveBeenCalledWith('tok-0', 'strip'); }); it('crosses verse boundaries via the Next arrow', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-1' }); await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('tok-2'); + expect(strip.focusToken).toHaveBeenCalledWith('tok-2', 'strip'); }); it('crosses chapter boundaries via the Next arrow', async () => { const book = makeTwoChapterBook(); - const props = requiredProps(book, { focusedTokenRef: 'ch1-tok-0' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'ch1-tok-0' }); await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - expect(props.onFocusedTokenRefChange).toHaveBeenCalledWith('ch2-tok-0'); + expect(strip.focusToken).toHaveBeenCalledWith('ch2-tok-0', 'strip'); }); it('advances two groups on rapid double-click before re-render', async () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); const next = screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }); await userEvent.click(next); await userEvent.click(next); - expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-1'); - expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-2'); + expect(strip.focusToken).toHaveBeenNthCalledWith(1, 'tok-1', 'strip'); + expect(strip.focusToken).toHaveBeenNthCalledWith(2, 'tok-2', 'strip'); }); - it('steps from the externally-imposed focus, not the stale pending index, after an external change interrupts an in-flight internal nav', async () => { - // An external nav (tok-3) fades while tok-1 is displayed; the user clicks Next mid-fade (internal - // nav in flight, never echoed); a second external change lands back on the still-displayed tok-1. - // Since that equals the displayed ref, the focus-change effect early-returns without clearing the - // in-flight marker, so render-phase external-override detection must resync the pending index — - // otherwise the next step advances from the stale pending index instead of the imposed position. - const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-1' }); - const { rerender } = render(, withAnalysisStore); + it('steps from the regrouped index after a phrase link moves the focused group', async () => { + // Linking earlier tokens into one phrase shifts every later group index while moving no focus. + // A step counting from its own last target would then skip the group beside the focused one. + const book = makeLargeBook(5); + const strip = renderStrip(book, { focus: 'large-tok-1' }); + const next = screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }); - // 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: '%interlinearizer_continuousView_nextToken%' }), + // A move the strip made itself, so it is its own last target that a later step could count from. + await userEvent.click(next); + expect(strip.focusToken).toHaveBeenNthCalledWith(1, 'large-tok-2', 'strip'); + + // Joining the first two tokens pulls the focused token's group index down by one. + addPhraseLinkWithNewIdentity( + makePhraseLink('phrase-1', ['large-tok-0', 'large-tok-1'], ['word0', 'word1']), ); - expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-2'); + strip.update(); - // The parent imposes an external position (not the tok-2 echo) that matches the displayed ref. - rerender(); + await userEvent.click(next); - await userEvent.click( - screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), - ); - expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-0'); + expect(strip.focusToken).toHaveBeenNthCalledWith(2, 'large-tok-3', 'strip'); + }); + + it('steps from a focus it did not choose rather than from its own last target', async () => { + // A step counts from where the strip is heading, so rapid presses accumulate. A focus the strip + // did not choose has to reset that count, or the press after one lands a group off. + jest.useFakeTimers(); + try { + const book = makeBook(); + const strip = renderStrip(book, { focus: 'tok-1' }); + + fireEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ); + expect(strip.focusToken).toHaveBeenNthCalledWith(1, 'tok-2', 'strip'); + + // A focus from outside the strip, given the fade it takes to arrive. + strip.setFocus('tok-3', 'list'); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + fireEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), + ); + expect(strip.focusToken).toHaveBeenNthCalledWith(2, 'tok-2', 'strip'); + } finally { + jest.useRealTimers(); + } }); }); describe('ContinuousView scroll behavior', () => { it('calls scrollIntoView on initial mount', () => { const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'auto', @@ -748,16 +829,15 @@ describe('ContinuousView scroll behavior', () => { }); }); - it('uses instant scroll when focusedTokenRef changes externally', () => { + it('uses instant scroll for a focus it did not choose', () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); scrollIntoViewMock.mockClear(); act(() => { jest.useFakeTimers(); }); - rerender(); + strip.setFocus('tok-3', 'reseed'); act(() => { jest.advanceTimersByTime(600); jest.useRealTimers(); @@ -771,15 +851,14 @@ describe('ContinuousView scroll behavior', () => { // scroll effect's own hold loop must keep the group pinned while late layout (arc padding // settling on the slid window) shifts the strip after the instant snap. const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); act(() => { jest.useFakeTimers(); }); try { // tok-1 shares GEN 1:1 with tok-0, so the active segment is unchanged by this jump. - rerender(); + strip.setFocus('tok-1', 'list'); // Complete the fade-out (RECENTER_FADE_MS) so the displayed focus updates and the instant // snap fires. scrollIntoViewMock.mockClear(); @@ -823,8 +902,7 @@ describe('ContinuousView scroll behavior', () => { try { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); act(() => { jest.useFakeTimers(); @@ -832,7 +910,7 @@ describe('ContinuousView scroll behavior', () => { try { // tok-1 shares GEN 1:1 with tok-0, so the active segment is unchanged: only the scroll // effect's own hold loop keeps the group pinned. - rerender(); + strip.setFocus('tok-1', 'list'); act(() => { jest.advanceTimersByTime(510); }); @@ -872,8 +950,7 @@ describe('ContinuousView scroll behavior', () => { try { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); act(() => { jest.useFakeTimers(); @@ -883,7 +960,7 @@ describe('ContinuousView scroll behavior', () => { // the initial real-timer mount is not deterministically advanceable). tok-1 shares GEN 1:1 // with tok-0, so there's no committed-active-segment flip: only the scroll effect's own hold // pins the group, exercising the same observer lifetime as the initial mount. - rerender(); + strip.setFocus('tok-1', 'request'); // Complete the fade-out (RECENTER_FADE_MS) so the instant snap + hold start. act(() => { jest.advanceTimersByTime(510); @@ -921,17 +998,16 @@ describe('ContinuousView scroll behavior', () => { 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); + const strip = renderStrip(book, { focus: 'tok-0' }); act(() => { jest.useFakeTimers(); }); // External nav into the other verse: the active segment commits instantly behind the fade, so // the slots snap to their new widths rather than animating (which would slide the boxes). - rerender(); + strip.setFocus('tok-3', 'reseed'); - const slotWrapper = container.querySelector('[data-testid="link-slot-icon"]'); + const slotWrapper = strip.container.querySelector('[data-testid="link-slot-icon"]'); if (!(slotWrapper instanceof HTMLElement)) throw new Error('Expected a link-slot icon wrapper'); expect(slotWrapper.style.transitionDuration).toBe('0ms'); @@ -941,30 +1017,49 @@ describe('ContinuousView scroll behavior', () => { }); }); - it('smooth-scrolls for internal nav once the parent echoes the ref back synchronously', async () => { - // The smooth-scroll path needs the displayed focus to agree with the prop and the strip to be - // visible, which only happens with a real (stateful) parent reflecting the ref change back, so - // simulate one here rather than a jest.fn() that never updates the prop. - const book = makeBook(); - const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); - function Parent() { - const [ref, setRef] = useState('tok-0'); - return ( - - ); - } - render(, withAnalysisStore); + it('reveals the strip again when a phrase click supersedes a jump mid-fade', async () => { + // Opacity does not stop pointer events, so a half-faded phrase box is still clickable. + const strip = renderStrip(makeBook(), { focus: 'tok-0' }); + const stripClass = () => screen.getByTestId('strip-fade-wrapper').className; + await waitFor(() => expect(stripClass()).toContain('tw:opacity-100')); + + strip.setFocus('tok-3', 'list'); + expect(stripClass()).toContain('tw:opacity-0'); + + const box = screen.getByText('In').closest('[data-phrase-box="true"]'); + if (!box) throw new Error('Expected phrase box wrapper for token'); + await userEvent.click(box); + + expect(stripClass()).toContain('tw:opacity-100'); + }); + + it('reveals the strip again when entering a phrase mode supersedes a jump mid-fade', async () => { + const phraseLink = makePhraseLink('phrase-1', ['tok-2', 'tok-3'], ['beginning', 'God']); + phraseLinkMap.set('tok-2', phraseLink); + phraseLinkMap.set('tok-3', phraseLink); + const strip = renderStrip(makeBook(), { focus: 'tok-0' }); + const stripClass = () => screen.getByTestId('strip-fade-wrapper').className; + await waitFor(() => expect(stripClass()).toContain('tw:opacity-100')); + + // Glide first: an instant jump's teardown reveals the strip on its way out, which would mask + // whether superseding does. + await userEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ); + await waitFor(() => expect(stripClass()).toContain('tw:opacity-100')); + + strip.setFocus('tok-3', 'reseed'); + expect(stripClass()).toContain('tw:opacity-0'); + + strip.update({ + phraseMode: { kind: 'edit', phraseId: 'phrase-1', originalTokens: phraseLink.tokens }, + }); + + expect(stripClass()).toContain('tw:opacity-100'); + }); + + it('smooth-scrolls for a move it made itself', async () => { + renderStrip(makeBook(), { focus: 'tok-0' }); // Wait for the initial fade-in (strip visible) before navigating; the smooth path is only taken // while the strip is already visible. await waitFor(() => @@ -990,26 +1085,10 @@ describe('ContinuousView scroll behavior', () => { * whether the active-segment relayout has committed. */ function renderHideInactiveCrossing(): () => boolean { - const book = makeBook(); - const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); - function Parent() { - const [ref, setRef] = useState('tok-1'); - return ( - - ); - } - render(, withAnalysisStore); + renderStrip(makeBook(), { + focus: 'tok-1', + props: { viewOptions: { ...allFalseViewOptions, hideInactiveLinkButtons: true } }, + }); // Returns true when the tok-0/tok-1 link icon is rendered and its wrapper is visible. Suppressed // icons stay mounted but hidden via opacity:0, so query the wrapper's style, not spy calls. return () => { @@ -1107,30 +1186,7 @@ describe('ContinuousView scroll behavior', () => { // committed, its in-segment link icon stays mounted through the edit until the scroll settles. const book = makeBook(); const merged = resegmentBook(book, { removedVerseStarts: ['tok-2'], addedStarts: [] }); - const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); const mergedLookups = buildLookups(merged); - let applyBoundaryEdit: () => void = () => {}; - /** Stateful parent that starts on the verse book and swaps to the merged book on demand. */ - function Parent() { - const [ref, setRef] = useState('tok-1'); - const [edited, setEdited] = useState(false); - applyBoundaryEdit = () => setEdited(true); - const lookups = edited ? mergedLookups : { tokenSegmentMap, tokenDocOrder, wordTokenByRef }; - return ( - - ); - } /** Whether the tok-0/tok-1 link icon (in GEN 1:1) is mounted and visible. */ const inSegmentIconMounted = () => { const icon = document.querySelector( @@ -1140,7 +1196,10 @@ describe('ContinuousView scroll behavior', () => { }; jest.useFakeTimers(); try { - render(, withAnalysisStore); + const strip = renderStrip(book, { + focus: 'tok-1', + props: { viewOptions: { ...allFalseViewOptions, hideInactiveLinkButtons: true } }, + }); act(() => { jest.runOnlyPendingTimers(); }); @@ -1158,7 +1217,7 @@ describe('ContinuousView scroll behavior', () => { // unchanged; the reconcile defers rather than commit-and-relayout, so the old segment's // in-segment icon stays mounted. act(() => { - applyBoundaryEdit(); + strip.update({ book: merged, ...mergedLookups }); }); expect(inSegmentIconMounted()).toBe(true); } finally { @@ -1220,10 +1279,7 @@ describe('ContinuousView scroll behavior', () => { it('scrolls with the nearest-block, center-inline placement', () => { const book = makeBook(); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'tok-0' }); expect(scrollIntoViewMock).toHaveBeenCalledWith( expect.objectContaining({ block: 'nearest', inline: 'center' }), @@ -1234,24 +1290,15 @@ describe('ContinuousView scroll behavior', () => { // Inactive link slots hide via visibility:hidden (not max-width collapse), so toggling // hideInactiveLinkButtons doesn't shift layout; simplifyPhrases does, so it re-centers once. const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); scrollIntoViewMock.mockClear(); - rerender( - , - ); + strip.update({ viewOptions: { ...allFalseViewOptions, hideInactiveLinkButtons: true } }); expect(scrollIntoViewMock).not.toHaveBeenCalled(); - rerender( - , - ); + strip.update({ + viewOptions: { ...allFalseViewOptions, hideInactiveLinkButtons: true, simplifyPhrases: true }, + }); expect(scrollIntoViewMock).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'auto', inline: 'center' }), ); @@ -1262,13 +1309,10 @@ describe('ContinuousView scroll behavior', () => { // Morpheme rows beneath tokens can widen phrase boxes, shifting the strip layout, so the // focused group must be snapped back to center when the toggle flips. const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'tok-0' }); scrollIntoViewMock.mockClear(); - rerender( - , - ); + strip.update({ viewOptions: { ...allFalseViewOptions, showMorphology: true } }); expect(scrollIntoViewMock).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'auto', inline: 'center' }), ); @@ -1292,21 +1336,22 @@ describe('ContinuousView segmentation edits', () => { it('keeps the focused segment link buttons active when a merge changes the focused token segment id', () => { const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-2' }); - props.viewOptions = { ...allFalseViewOptions, hideInactiveLinkButtons: true }; - const { container, rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { + focus: 'tok-2', + props: { viewOptions: { ...allFalseViewOptions, hideInactiveLinkButtons: true } }, + }); // Focus sits in GEN 1:2, so the slot between its two tokens is active and visible. - expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + expect(slotOpacity(strip.container, 'tok-2', 'tok-3')).toBe('1'); // Merge GEN 1:2 into GEN 1:1. Token refs survive, so focus stays put, but the focused token's // segment id changes. const merged = resegmentBook(book, { removedVerseStarts: ['tok-2'], addedStarts: [] }); - rerender(); + strip.update({ book: merged, ...buildLookups(merged) }); // The committed active segment must follow the merge; a stale id would suppress every link // button until the next navigation. - expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + expect(slotOpacity(strip.container, 'tok-2', 'tok-3')).toBe('1'); }); }); @@ -1317,7 +1362,7 @@ describe('ContinuousView split marker', () => { * * @param altHeld - Whether Alt is held (defaults to held, so the marker appears). */ - function renderStrip(altHeld = true) { + function renderSplitMarker(altHeld = true) { const book = makeBook(); const dispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; const segmentById = new Map(book.segments.map((seg) => [seg.id, seg])); @@ -1332,7 +1377,12 @@ describe('ContinuousView split marker', () => { render( - + + + , withAnalysisStore, @@ -1341,17 +1391,17 @@ describe('ContinuousView split marker', () => { } it('reveals a split marker on an intra-segment gap while Alt is held', () => { - renderStrip(true); + renderSplitMarker(true); expect(screen.getAllByTestId('boundary-split-marker').length).toBeGreaterThan(0); }); it('reveals no split marker while Alt is not held', () => { - renderStrip(false); + renderSplitMarker(false); expect(screen.queryByTestId('boundary-split-marker')).not.toBeInTheDocument(); }); it('dispatches a split on an Alt+click of the strip marker', () => { - const dispatch = renderStrip(true); + const dispatch = renderSplitMarker(true); // The gap between "In" (tok-0) and "the" (tok-1) inside GEN 1:1 splits before the second word. fireEvent.click(screen.getAllByTestId('boundary-split-marker')[0], { altKey: true }); expect(dispatch.split).toHaveBeenCalledWith('tok-1'); @@ -1372,7 +1422,7 @@ describe('ContinuousView RTL layout', () => { it('uses right-pointing arrow for Previous in RTL', () => { document.documentElement.dir = 'rtl'; const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); const prev = screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%', @@ -1383,7 +1433,7 @@ describe('ContinuousView RTL layout', () => { it('uses left-pointing arrow for Next in RTL', () => { document.documentElement.dir = 'rtl'; const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); const next = screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }); expect(next.textContent).toContain('←'); @@ -1392,7 +1442,7 @@ describe('ContinuousView RTL layout', () => { it('uses left-pointing arrow for Previous in LTR', () => { document.documentElement.dir = 'ltr'; const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); const prev = screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%', @@ -1404,20 +1454,14 @@ describe('ContinuousView RTL layout', () => { describe('ContinuousView phrase window', () => { it('renders the focused phrase from a large book', () => { const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-150' }); expect(screen.getByText('word150')).toBeInTheDocument(); }); it('does not render tokens that fall outside the rendered window', () => { const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-0' }); // tok-299 is well outside the rendered phrase window. expect(screen.queryByText('word299')).not.toBeInTheDocument(); @@ -1443,10 +1487,7 @@ describe('ContinuousView phrase window', () => { // take the whole arc with it and leave the visible fragment with no phrase cue at all. linkFarApartTokens(); const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-150' }); expect(screen.getByText('word190')).toBeInTheDocument(); }); @@ -1454,10 +1495,7 @@ describe('ContinuousView phrase window', () => { it('widens no further than the phrase span it is covering', () => { linkFarApartTokens(); const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-150' }); expect(screen.queryByText('word200')).not.toBeInTheDocument(); }); @@ -1466,8 +1504,7 @@ describe('ContinuousView phrase window', () => { // Widening start-ward mounts groups ahead of the focus at an unchanged scroll offset, the same // shift a resize causes — but the window size is unchanged, so a size-keyed correction misses it. const book = makeLargeBook(300); - const props = requiredProps(book, { focusedTokenRef: 'large-tok-150' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'large-tok-150' }); scrollIntoViewMock.mockClear(); addPhraseLinkWithNewIdentity({ @@ -1479,7 +1516,7 @@ describe('ContinuousView phrase window', () => { { tokenRef: 'large-tok-145', surfaceText: 'word145' }, ], }); - rerender(); + strip.update(); expect(screen.getByText('word110')).toBeInTheDocument(); expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); @@ -1487,10 +1524,7 @@ describe('ContinuousView phrase window', () => { it('leaves an unlinked token at the same distance outside the window', () => { const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-150' }); expect(screen.queryByText('word190')).not.toBeInTheDocument(); }); @@ -1504,10 +1538,7 @@ describe('ContinuousView phrase window', () => { try { const book = makeLargeBook(300); - render( - , - withAnalysisStore, - ); + renderStrip(book, { focus: 'large-tok-150' }); // jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide // enough to ask for more groups, over groups spaced a fixed pitch apart. @@ -1566,8 +1597,7 @@ describe('ContinuousView phrase window', () => { try { const book = makeLargeBook(300); // Focused close enough to the book start that the widened window's start clamps to 0. - const props = requiredProps(book, { focusedTokenRef: 'large-tok-12' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'large-tok-12' }); const viewport = screen.getByTestId('strip-scroll-viewport'); const stripRow = screen.getByTestId('token-strip'); @@ -1616,10 +1646,8 @@ describe('ContinuousView phrase window', () => { fireEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - const emitted = props.onFocusedTokenRefChange.mock.calls.at(-1)?.[0]; - const nextRef = typeof emitted === 'string' ? emitted : undefined; - expect(nextRef).toBe('large-tok-13'); - rerender(); + expect(strip.focusToken).toHaveBeenLastCalledWith('large-tok-13', 'strip'); + strip.update(); layOutGroups(); // Let the step's own glide settle, so the deferred-correction path is not what answers the @@ -1662,8 +1690,7 @@ describe('ContinuousView phrase window', () => { try { const book = makeLargeBook(300); - const props = requiredProps(book, { focusedTokenRef: 'large-tok-150' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'large-tok-150' }); // jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide // enough to ask for more groups, over groups spaced a fixed pitch apart. @@ -1699,15 +1726,13 @@ describe('ContinuousView phrase window', () => { }); layOutGroups(); - // Navigate one phrase forward while that hold is still alive, echoing the new ref back the - // way the parent does so the strip treats it as internal navigation. + // Navigate one phrase forward while that hold is still alive; the strip's own move is the + // path that glides. fireEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - const emitted = props.onFocusedTokenRefChange.mock.calls.at(-1)?.[0]; - const nextRef = typeof emitted === 'string' ? emitted : undefined; - expect(nextRef).toBe('large-tok-151'); - rerender(); + expect(strip.focusToken).toHaveBeenLastCalledWith('large-tok-151', 'strip'); + strip.update(); scrollIntoViewMock.mockClear(); // Report the window slide's own reflow, then run out the frames a restarted hold would use. @@ -1748,8 +1773,7 @@ describe('ContinuousView phrase window', () => { try { const book = makeLargeBook(300); - const props = requiredProps(book, { focusedTokenRef: 'large-tok-150' }); - const { rerender } = render(, withAnalysisStore); + const strip = renderStrip(book, { focus: 'large-tok-150' }); // jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide // enough to ask for more groups, over groups spaced a fixed pitch apart. @@ -1774,14 +1798,11 @@ describe('ContinuousView phrase window', () => { jest.useFakeTimers(); }); try { - // Echoing the emitted ref back the way the parent does is what makes the strip treat this - // as internal navigation, which is the only path that glides rather than snapping. + // A move the strip makes itself is the only path that glides rather than snapping. fireEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - const emitted = props.onFocusedTokenRefChange.mock.calls.at(-1)?.[0]; - const nextRef = typeof emitted === 'string' ? emitted : undefined; - rerender(); + strip.update(); scrollIntoViewMock.mockClear(); const windowObserver = resizeObserverInstances.find((o) => o.targets.includes(viewport)); @@ -1834,7 +1855,7 @@ describe('ContinuousView phrase grouping', () => { ], }); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); const phraseBoxes = document.querySelectorAll('[data-phrase-box="true"]'); // Two tokens grouped → one box; plus the two free tokens from segment 2 → 3 total. @@ -1854,7 +1875,7 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-0', phraseLink); phraseLinkMap.set('tok-2', phraseLink); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); const phraseBoxes = document.querySelectorAll('[data-phrase-id="phrase-1"]'); expect(phraseBoxes).toHaveLength(2); @@ -1877,7 +1898,7 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-0', phraseLink); phraseLinkMap.set('tok-1', phraseLink); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); // Hover the phrase group to set hoveredPhraseId='phrase-1'. const phraseGroupSpan = document.querySelector('[data-phrase-box="true"]')?.parentElement; @@ -1893,13 +1914,11 @@ describe('ContinuousView phrase grouping', () => { expect(screen.getByTestId('arc-split-btn')).toHaveAttribute('data-hovered-phrase-id', ''); }); - it('applies the internal focus transition when the parent reflects a click-driven ref change', async () => { - // A click Next stamps the ref internally-originated; when the parent echoes it back, the - // isInternal branch applies it immediately (no fade-out). The external branch would defer the - // display update behind a fade timeout, leaving 'In' (tok-0) focused right after the rerender. + it('applies a click-driven focus move immediately, with no fade', async () => { + // A move the strip made itself takes the internal branch, which applies it at once. The other + // branch would defer the display update behind a fade timeout, leaving 'In' (tok-0) focused. const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); + renderStrip(book, { focus: 'tok-0' }); // Sanity: tok-0's box ('In') is focused before the click. expect(screen.getByText('In').closest('[data-phrase-box="true"]')).toHaveAttribute( @@ -1910,8 +1929,6 @@ describe('ContinuousView phrase grouping', () => { await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), ); - // Reflect the new ref back as a prop change; the click stamped it internal, so it applies at once. - rerender(); // The displayed focus moved synchronously to tok-1's box ('the') — the internal path. expect(screen.getByText('the').closest('[data-phrase-box="true"]')).toHaveAttribute( @@ -1937,31 +1954,13 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-2', phraseLink); phraseLinkMap.set('tok-3', phraseLink); const book = makeBook(); - const onFocusedTokenRefChange = jest.fn(); - const { rerender } = render( - , - withAnalysisStore, - ); + const strip = renderStrip(book, { focus: 'tok-0' }); // Switch to edit mode for phrase-1. - rerender( - , - ); - // The effect should call onFocusedTokenRefChange with the first token of the phrase. - expect(onFocusedTokenRefChange).toHaveBeenCalledWith('tok-2'); + strip.update({ + phraseMode: { kind: 'edit', phraseId: 'phrase-1', originalTokens: phraseLink.tokens }, + }); + expect(strip.focusToken).toHaveBeenCalledWith('tok-2', 'strip'); }); it('fires phrase group hover enter and leave without throwing', async () => { @@ -1977,7 +1976,7 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-0', phraseLink); phraseLinkMap.set('tok-1', phraseLink); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); // The PhraseGroup wrapper span contains the phrase box. const phraseBox = document.querySelector('[data-phrase-box="true"]'); @@ -2009,7 +2008,7 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-0', phraseLink); phraseLinkMap.set('tok-1', phraseLink); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); await userEvent.click(screen.getByTestId('arc-split-btn')); expect(deletePhrase).toHaveBeenCalledWith('phrase-1'); }); @@ -2023,7 +2022,7 @@ describe('ContinuousView phrase grouping', () => { mergePhrases: jest.fn(), }); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); await userEvent.click(screen.getByTestId('arc-split-btn')); expect(deletePhrase).not.toHaveBeenCalled(); }); @@ -2038,7 +2037,7 @@ describe('ContinuousView phrase grouping', () => { phraseLinkMap.set('tok-0', phraseLink); mockCandidateTokenRefs.current = new Set(['tok-0']); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); // The hovered candidate ref (tok-0) resolves to its phrase, forwarded to ArcOverlay. expect(screen.getByTestId('arc-split-btn')).toHaveAttribute( 'data-candidate-phrase-ids', @@ -2057,7 +2056,7 @@ describe('ContinuousView phrase grouping', () => { // No hovered candidate refs: the phrase exists, but nothing should resolve to it. mockCandidateTokenRefs.current = new Set(); const book = makeBook(); - render(, withAnalysisStore); + renderStrip(book); expect(screen.getByTestId('arc-split-btn')).toHaveAttribute('data-candidate-phrase-ids', ''); }); }); diff --git a/src/__tests__/components/FocusStore.test.tsx b/src/__tests__/components/FocusStore.test.tsx new file mode 100644 index 00000000..f0d3203b --- /dev/null +++ b/src/__tests__/components/FocusStore.test.tsx @@ -0,0 +1,519 @@ +/// +/// + +import { logger } from '@papi/frontend'; +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { act, render, renderHook, screen } from '@testing-library/react'; +import type { Book, Segment, Token } from 'interlinearizer'; +import { useEffect, type ReactNode } from 'react'; +import { + createFocusStore, + FocusProvider, + FocusStoreProvider, + useFocus, + useFocusActions, + useFocusGetter, + type Focus, + type FocusActions, +} from '../../components/FocusStore'; +import { InterlinearNavProvider, useInterlinearNav } from '../../components/InterlinearNavContext'; +import { isWordToken } from '../../types/type-guards'; +import { makeSegment, makeWordToken, type ScrollGroupTuple } from '../test-helpers'; + +/** + * A two-verse GEN book whose first verse holds two word tokens, so a focus can sit on a non-first + * token of the segment that owns the active verse. + */ +function makeBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + makeSegment('GEN 1:1', 'In beginning', [ + makeWordToken('GEN 1:1:0', 'In'), + makeWordToken('GEN 1:1:1', 'beginning', 3), + ]), + makeSegment('GEN 1:2', 'And', [makeWordToken('GEN 1:2:0', 'And')]), + ], + }; +} + +/** The lookups {@link FocusProvider} resolves a focus against, derived from `book`. */ +function buildLookups(book: Book) { + const segmentById = new Map(); + const tokenSegmentMap = new Map(); + const wordTokenByRef = new Map(); + book.segments.forEach((seg) => { + segmentById.set(seg.id, seg); + seg.tokens.forEach((t) => { + tokenSegmentMap.set(t.ref, seg.id); + if (isWordToken(t)) wordTokenByRef.set(t.ref, t); + }); + }); + return { segmentById, tokenSegmentMap, wordTokenByRef }; +} + +/** + * Mounts a {@link FocusProvider} over a scroll-group stub, exposing the focus and navigation + * surfaces plus a `setBook` / `setScrRef` pair for restaging the inputs the resolution rules + * classify on. + */ +function renderFocus(initialBook: Book, initialScrRef: SerializedVerseRef) { + let book = initialBook; + let hostScrRef = initialScrRef; + const setScrRefSpy = jest.fn((next: SerializedVerseRef) => { + hostScrRef = next; + }); + const scrollGroupHook = (): ScrollGroupTuple => [ + hostScrRef, + setScrRefSpy, + undefined, + () => {}, + undefined, + ]; + + let focus: Focus | undefined; + let actions: FocusActions | undefined; + let nav: ReturnType | undefined; + + function Probe() { + focus = useFocus(); + actions = useFocusActions(); + return
; + } + + function Tree() { + nav = useInterlinearNav(); + return ( + + + + ); + } + + const view = render( + + + , + ); + + return { + /** The focus, its origin, and the surfaces a test drives it through. */ + read: () => { + if (!focus || !actions || !nav) throw new Error('The focus harness has not rendered'); + return { ...focus, actions, nav }; + }, + setScrRefSpy, + setBook: (next: Book) => { + book = next; + view.rerender( + + + , + ); + }, + setScrRef: (next: SerializedVerseRef) => { + hostScrRef = next; + view.rerender( + + + , + ); + }, + }; +} + +const GEN_1_1: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; +const GEN_1_2: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 2 }; + +beforeEach(() => { + jest.mocked(logger.warn).mockClear(); +}); + +describe('createFocusStore', () => { + it('seeds the focus it is given as a seed origin', () => { + expect(createFocusStore('GEN 1:1:1').getFocus()).toEqual({ + tokenRef: 'GEN 1:1:1', + origin: 'seed', + }); + }); + + it('applies a write and notifies every subscriber', () => { + const store = createFocusStore(undefined); + const first = jest.fn(); + const second = jest.fn(); + store.subscribe(first); + store.subscribe(second); + + store.write('GEN 1:2:0', 'list'); + + expect(store.getFocus()).toEqual({ tokenRef: 'GEN 1:2:0', origin: 'list' }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('drops a write naming the token already focused, so a reseed onto it wakes nobody', () => { + const store = createFocusStore('GEN 1:1:1'); + const listener = jest.fn(); + store.subscribe(listener); + + store.write('GEN 1:1:1', 'reseed'); + + expect(store.getFocus().origin).toBe('seed'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('stops notifying once unsubscribed', () => { + const store = createFocusStore(undefined); + const listener = jest.fn(); + const unsubscribe = store.subscribe(listener); + + unsubscribe(); + store.write('GEN 1:1:0', 'strip'); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('focus hooks', () => { + /** Wraps children in a provider over `store`, with inert actions. */ + function withStore(store = createFocusStore('GEN 1:1:0')) { + const actions: FocusActions = { focusToken: () => {}, selectSegment: () => {} }; + return { + store, + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }; + } + + it('re-renders a subscriber on a focus move', () => { + const { store, wrapper } = withStore(); + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return useFocus(); + }, + { wrapper }, + ); + + act(() => store.write('GEN 1:2:0', 'list')); + + expect(result.current).toEqual({ tokenRef: 'GEN 1:2:0', origin: 'list' }); + expect(renders).toHaveBeenCalledTimes(2); + }); + + it('leaves a getter-only reader unrendered by a focus move', () => { + const { store, wrapper } = withStore(); + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return useFocusGetter(); + }, + { wrapper }, + ); + + act(() => store.write('GEN 1:2:0', 'list')); + + expect(result.current().tokenRef).toBe('GEN 1:2:0'); + expect(renders).toHaveBeenCalledTimes(1); + }); + + it('hands back the provider actions', () => { + const { wrapper } = withStore(); + const { result } = renderHook(() => useFocusActions(), { wrapper }); + + expect(result.current.focusToken).toBeInstanceOf(Function); + expect(result.current.selectSegment).toBeInstanceOf(Function); + }); + + it.each([ + ['useFocus', useFocus], + ['useFocusGetter', useFocusGetter], + ['useFocusActions', useFocusActions], + ])('%s throws outside a provider', (name, hook) => { + expect(() => renderHook(() => hook())).toThrow(`${name} must be used within a FocusProvider`); + }); +}); + +describe('FocusProvider seeding', () => { + it('seeds the first word token of the segment that owns the active verse', () => { + const harness = renderFocus(makeBook(), GEN_1_2); + + expect(harness.read().tokenRef).toBe('GEN 1:2:0'); + expect(harness.read().origin).toBe('seed'); + }); + + it('seeds nothing when no segment owns the active verse', () => { + const harness = renderFocus(makeBook(), { book: 'GEN', chapterNum: 9, verseNum: 9 }); + + expect(harness.read().tokenRef).toBeUndefined(); + expect(screen.getByTestId('probe')).toHaveAttribute('data-focus', ''); + }); + + it('seeds nothing when the active segment has no word token', () => { + const punctuationOnly: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [makeSegment('GEN 1:1', '', [])], + }; + + expect(renderFocus(punctuationOnly, GEN_1_1).read().tokenRef).toBeUndefined(); + }); +}); + +describe('FocusProvider seeding from a child', () => { + /** Names its own focus on mount when nothing resolved one, as the continuous strip does. */ + function SeedingChild() { + const { tokenRef } = useFocus(); + const { focusToken } = useFocusActions(); + useEffect(() => { + if (tokenRef === undefined) focusToken('GEN 1:2:0', 'seed'); + // Intentionally runs only on mount; do not add deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
; + } + + it('leaves a focus a child named on mount standing', () => { + // Child effects run before the provider's, so the resolution rules see the child's write and + // must leave it alone — neither the book nor the verse has moved since the mount. + const noWordToken: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [makeSegment('GEN 1:1', '', []), ...makeBook().segments.slice(1)], + }; + + render( + [GEN_1_1, () => {}, undefined, () => {}, undefined]} + > + + + + , + ); + + expect(screen.getByTestId('child')).toHaveAttribute('data-focus', 'GEN 1:2:0'); + }); +}); + +describe('FocusProvider focusToken', () => { + it('navigates to the target segment when it does not hold the active verse', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().actions.focusToken('GEN 1:2:0', 'strip')); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'strip' }); + expect(harness.setScrRefSpy).toHaveBeenCalledWith(GEN_1_2); + }); + + it('focuses without navigating within the segment that already holds the active verse', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().actions.focusToken('GEN 1:1:1', 'strip')); + + expect(harness.read().tokenRef).toBe('GEN 1:1:1'); + expect(harness.setScrRefSpy).not.toHaveBeenCalled(); + }); + + it('does not echo a verse from a book the reference has already left', () => { + // The state a cross-book navigation passes through, where echoing this token's verse would + // overwrite the reference that named the new book. + const harness = renderFocus(makeBook(), { book: 'MAT', chapterNum: 1, verseNum: 1 }); + + act(() => harness.read().actions.focusToken('GEN 1:2:0', 'strip')); + + expect(harness.read().tokenRef).toBe('GEN 1:2:0'); + expect(harness.setScrRefSpy).not.toHaveBeenCalled(); + }); +}); + +describe('FocusProvider selectSegment', () => { + it('navigates and focuses the clicked token', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => + harness.read().actions.selectSegment({ book: 'GEN', chapter: 1, verse: 2 }, 'GEN 1:2:0'), + ); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'list' }); + expect(harness.setScrRefSpy).toHaveBeenCalledWith(GEN_1_2); + }); + + it('skips the navigation when the selected verse is already active', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => + harness.read().actions.selectSegment({ book: 'GEN', chapter: 1, verse: 1 }, 'GEN 1:1:1'), + ); + + expect(harness.read().tokenRef).toBe('GEN 1:1:1'); + expect(harness.setScrRefSpy).not.toHaveBeenCalled(); + }); + + it('leaves the focus alone when the whole segment was selected', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().actions.selectSegment({ book: 'GEN', chapter: 1, verse: 2 })); + + expect(harness.read().tokenRef).toBe('GEN 1:1:0'); + expect(harness.setScrRefSpy).toHaveBeenCalledWith(GEN_1_2); + }); +}); + +describe('FocusProvider resolution rules', () => { + it('keeps a focus the re-segmented book still resolves', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + act(() => harness.read().actions.focusToken('GEN 1:1:1', 'strip')); + + // A boundary edit produces a fresh book carrying the same token refs. + const merged: Book = { ...makeBook(), segments: [...makeBook().segments] }; + harness.setBook(merged); + + expect(harness.read().tokenRef).toBe('GEN 1:1:1'); + }); + + it('reseeds to the active verse when the new book cannot resolve the focus', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + act(() => harness.read().actions.focusToken('GEN 1:1:1', 'strip')); + + const retokenized: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: '2', + segments: [makeSegment('GEN 1:1', 'Anew', [makeWordToken('GEN 1:1:9', 'Anew')])], + }; + harness.setBook(retokenized); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:1:9', origin: 'reseed' }); + }); + + it('keeps the focus when its own segment contains the new verse', () => { + const spanning: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + ...makeSegment('GEN 1:1', 'In beginning', [ + makeWordToken('GEN 1:1:0', 'In'), + makeWordToken('GEN 1:1:1', 'beginning', 3), + ]), + verseStarts: [ + { charStart: 0, number: '1', chapter: 1 }, + { charStart: 3, number: '2', chapter: 1 }, + ], + }, + ], + }; + const harness = renderFocus(spanning, GEN_1_1); + act(() => harness.read().actions.focusToken('GEN 1:1:1', 'strip')); + + harness.setScrRef(GEN_1_2); + + expect(harness.read().tokenRef).toBe('GEN 1:1:1'); + }); + + it('reseeds to the new verse when nothing is focused yet', () => { + // The active verse resolves no word token, so the seed leaves focus unset and the verse change + // has no focused segment to test against. + const noWordToken: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [makeSegment('GEN 1:1', '', []), ...makeBook().segments.slice(1)], + }; + const harness = renderFocus(noWordToken, GEN_1_1); + expect(harness.read().tokenRef).toBeUndefined(); + + harness.setScrRef(GEN_1_2); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'reseed' }); + }); + + it('reseeds to the new verse when the focused segment does not contain it', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + harness.setScrRef(GEN_1_2); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'reseed' }); + }); + + it('claims a focus request over the verse reseed landing in the same commit', () => { + // A request moves focus and nothing else, so its caller navigates too. Both land in one commit, + // which is where the two rules would otherwise race. + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => { + harness.read().nav.requestFocusToken('GEN 1:1:1'); + harness.read().nav.navigate(GEN_1_2, 'internal'); + }); + + // The verse change alone would have reseeded to the new verse's first word. + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:1:1', origin: 'request' }); + }); + + it('warns and falls through to the reseed when the book cannot resolve the request', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => { + harness.read().nav.requestFocusToken('GEN 1:1:99'); + harness.read().nav.navigate(GEN_1_2, 'internal'); + }); + + expect(jest.mocked(logger.warn)).toHaveBeenCalledWith( + 'Interlinearizer: focus request "GEN 1:1:99" matched no word token', + ); + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'reseed' }); + }); + + it('claims a request naming the verse already on screen', () => { + // The request count is the only signal such a request gives, since nothing else about + // navigation changes. + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().nav.requestFocusToken('GEN 1:1:1')); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:1:1', origin: 'request' }); + }); + + it('leaves a request naming another book pending', () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().nav.requestFocusToken('MAT 1:1:0')); + + expect(harness.read().tokenRef).toBe('GEN 1:1:0'); + expect(jest.mocked(logger.warn)).not.toHaveBeenCalled(); + }); + + it('keeps a request for another book claimable across a navigation in this one', () => { + // Every run attempts the claim, so a navigation that moves only the verse attempts one too. A + // request naming a book that has yet to mount has to survive that attempt, or it would be lost + // to whatever navigation happened to precede the load it is waiting for. + const matBook: Book = { + id: 'MAT', + bookRef: 'MAT', + textVersion: '1', + segments: [makeSegment('MAT 1:1', 'Alpha', [makeWordToken('MAT 1:1:0', 'Alpha')])], + }; + const harness = renderFocus(makeBook(), GEN_1_1); + act(() => harness.read().nav.requestFocusToken('MAT 1:1:0')); + + harness.setScrRef(GEN_1_2); + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'reseed' }); + + // The book the request named arrives; the claim it has been waiting for is still there to make. + harness.setBook(matBook); + + expect(harness.read()).toMatchObject({ tokenRef: 'MAT 1:1:0', origin: 'request' }); + expect(jest.mocked(logger.warn)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 5c572522..ce79f080 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -98,6 +98,9 @@ const mockDeletePhrase = jest.fn(); */ const mockPhraseLinkById = new Map(); +/** Read once per `Interlinearizer` render, so this doubles as a render counter. */ +let phraseLinkByIdMapReads = 0; + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, /** @@ -115,7 +118,10 @@ jest.mock('../../components/AnalysisStore', () => ({ * Returns the test-owned phrase-link map so straddled-boundary tests can seed phrases the * component's `straddledBoundaryRefs` memo sees. */ - usePhraseLinkByIdMap: () => mockPhraseLinkById, + usePhraseLinkByIdMap: () => { + phraseLinkByIdMapReads += 1; + return mockPhraseLinkById; + }, /** * Returns a getter over the test-owned phrase-link map so force-break tests can seed straddling * phrases. @@ -134,12 +140,21 @@ jest.mock('../../components/ContinuousView', () => ({ * ContinuousView stub; captures its props and the segmentation context (the wrapped, * force-breaking dispatch) so tests can invoke the dispatch directly. */ - default: function ContinuousViewStub(props: CapturedContinuousViewProps) { - capturedContinuousViewProps = props; + default: function ContinuousViewStub( + props: Omit, + ) { + // Read lazily (not via an outer import) because jest.mock factories are hoisted. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { useFocus, useFocusActions } = require('../../components/FocusStore'); + const focusedTokenRef: string | undefined = useFocus().tokenRef; + const { focusToken } = useFocusActions(); + capturedContinuousViewProps = { + ...props, + focusedTokenRef, + onFocusedTokenRefChange: (ref: string) => focusToken(ref, 'strip'), + }; capturedSegmentation = useSegmentation(); - return ( -
- ); + return
; }, })); @@ -1709,6 +1724,18 @@ describe('focus preservation across segmentation edits', () => { expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:2:0'); }); + it('leaves Interlinearizer unrendered by a focus move inside the active verse', () => { + // A move at arrow-step rate must re-render only the views that read focus. + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + render(interlinearizerEl(GEN_TWO_TOKEN_V1_BOOK, scrRef)); + const rendersBefore = phraseLinkByIdMapReads; + + act(() => capturedContinuousViewProps?.onFocusedTokenRefChange('GEN 1:1:3')); + + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:1:3'); + expect(phraseLinkByIdMapReads).toBe(rendersBefore); + }); + it('keeps a deliberately-focused token across a merge into the active verse', () => { const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; const { rerender } = render(interlinearizerEl(GEN_TWO_TOKEN_V1_BOOK, scrRef)); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 61ce1e6b..b429a189 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -23,6 +23,7 @@ import { import useLatestRef from '../hooks/useLatestRef'; import usePhraseWindowHalf from '../hooks/usePhraseWindowHalf'; import MemoizedArcOverlay from './ArcOverlay'; +import { useFocus, useFocusActions, useFocusGetter } from './FocusStore'; import { RECENTER_FADE_MS, RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; /** Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero. */ @@ -100,18 +101,6 @@ type ContinuousViewProps = Readonly<{ book: Book; /** Segment id of the phrase being edited, or `undefined` outside edit mode. */ editPhraseSegmentId: string | undefined; - /** - * Token ref of the currently focused word token, or `undefined` when nothing is focused. The - * strip jumps to the group containing this token and uses it as the single source of truth for - * highlight + slot rules. All scroll position is derived from this value. - */ - focusedTokenRef: string | undefined; - /** - * Called when arrow navigation or a click in the strip should change which token is focused. The - * parent echoes the value back through `focusedTokenRef`; the strip then re-renders with the new - * focus and scrolls into view. - */ - onFocusedTokenRefChange: (ref: string) => void; /** Current phrase-interaction mode; controls token click behavior in the strip. */ phraseMode: PhraseMode; /** Setter for `phraseMode`; passed to phrase boxes so they can transition modes. */ @@ -120,7 +109,7 @@ type ContinuousViewProps = Readonly<{ 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`. */ + /** Word token ref → token lookup; used to resolve the focused word token. */ wordTokenByRef: ReadonlyMap; /** Bundled display toggles forwarded to the strip. */ viewOptions: ViewOptions; @@ -133,16 +122,13 @@ type ContinuousViewProps = Readonly<{ * by one phrase group at a time with smooth scrolling animation. No segment markers, verse labels, * or chapter boundaries are shown — the strip is fully continuous. * - * Scroll position is derived from `focusedTokenRef`: the strip always centers the group containing - * that token. Arrow buttons advance or retreat focus by one group and notify the parent; the parent - * echoes the new ref back through `focusedTokenRef`. The previous/next arrows are disabled when the - * first/last phrase is focused. + * Scroll position is derived from the focused token: the strip always centers the group containing + * it. Arrow buttons advance or retreat focus by one group; scroll and highlight follow the store + * write. The previous/next arrows are disabled when the first/last phrase is focused. */ export default function ContinuousView({ book, editPhraseSegmentId, - focusedTokenRef, - onFocusedTokenRefChange, phraseMode, setPhraseMode, tokenSegmentMap, @@ -150,6 +136,12 @@ export default function ContinuousView({ wordTokenByRef, viewOptions, }: ContinuousViewProps) { + // Focus drives every scroll, highlight and slot decision here; its origin decides whether a + // change glides or fades. See FocusOrigin. + const { tokenRef: focusedTokenRef, origin: focusOrigin } = useFocus(); + const getFocus = useFocusGetter(); + const { focusToken } = useFocusActions(); + const { hideInactiveLinkButtons, simplifyPhrases, showMorphology } = viewOptions; const isRtl = document.documentElement.dir === 'rtl'; @@ -193,10 +185,10 @@ export default function ContinuousView({ }, [phraseGroups]); /** - * 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 - * faded out. For internal nav (arrow buttons, phrase clicks) this is updated immediately so the - * smooth scroll starts on the same frame. + * Token ref that the strip is currently displaying as focused. Lags the live focus through the + * fade-out for a jump it has to travel, so the window/scroll/highlight don't shift until the + * strip has faded out. For its own arrow/click moves this is updated immediately so the smooth + * scroll starts on the same frame. */ const [displayFocusedTokenRef, setDisplayFocusedTokenRef] = useState( focusedTokenRef, @@ -210,8 +202,8 @@ export default function ContinuousView({ * 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. + * back to the live focus first — it is reseeded 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(() => { const resolved = @@ -239,51 +231,17 @@ export default function ContinuousView({ const isInitialLoadInProgressRef = useRef(true); /** - * Token ref that the strip set via `onFocusedTokenRefChange` from internal arrow nav or click. - * When the parent echoes the same value back as `focusedTokenRef`, the focus-change effect - * applies the new ref immediately and smooth-scrolls instead of fade-then-snap. + * Whether the displayed-focus update just applied — or still pending behind the fade — came from + * a move this strip made. Carried from the focus-change effect to the scroll effect, which the + * fade timer can separate. */ - const internalFocusedTokenRefRef = useRef(undefined); - - /** True when the last displayFocusedTokenRef update was triggered by internal navigation. */ const lastDisplayUpdateWasInternalRef = useRef(false); /** - * Tracks the "pending" phrase index for sequential arrow-button presses. Written synchronously by - * `step()` so that a second click before re-render reads the already-advanced value instead of - * the stale rendered `focusPhraseIndex`, preventing rapid double-clicks from advancing only one - * 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). + * Ref mirror of the rendered focus index, read only as the fallback for a step whose live focus + * this book cannot place. A ref, so a step keeps one identity across focus moves. */ - 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 - // focusPhraseIndex. - if (internalFocusedTokenRefRef.current === undefined) { - pendingPhraseIndexRef.current = focusPhraseIndex; - } + const focusPhraseIndexRef = useLatestRef(focusPhraseIndex); /** DOM ref array indexed by group index; used to scroll the focused phrase box into view. */ const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); @@ -452,11 +410,11 @@ export default function ContinuousView({ const scrollViewportRef = useRef(null); /** - * Segment id whose link buttons are currently treated as active, lagging `focusedTokenRef` during - * internal navigation. Toggling this adds/removes inactive link icons, which re-lays out the - * whole strip; deferring it until the smooth scroll settles keeps the animation a pure one-token - * glide with no mid-flight box shifts. For external jumps and the initial mount it tracks the - * focus immediately (the strip is faded out or static, so there is no animation to disturb). + * Segment id whose link buttons are currently treated as active, lagging the live focus while the + * strip glides. Toggling this adds/removes inactive link icons, which re-lays out the whole + * strip; deferring it until the smooth scroll settles keeps the animation a pure one-token glide + * with no mid-flight box shifts. For a jump and for the initial mount it tracks the focus + * immediately (the strip is faded out or static, so there is no animation to disturb). */ const [committedActiveSegmentId, setCommittedActiveSegmentId] = useState( () => (focusedTokenRef !== undefined ? tokenSegmentMap.get(focusedTokenRef) : undefined), @@ -479,9 +437,9 @@ export default function ContinuousView({ }, [targetActiveSegmentIdRef]); /** - * `focusedTokenRef` last seen by the segmentation-reconcile effect below, so it can distinguish - * "the focused token's segment id changed because the segmentation changed" (commit immediately) - * from "focus moved" (the focus-change machinery owns the commit timing). + * The focus last seen by the segmentation-reconcile effect below, so it can distinguish "the + * focused token's segment id changed because the segmentation changed" (commit immediately) from + * "focus moved" (the focus-change machinery owns the commit timing). */ const prevFocusForSegmentationRef = useRef(focusedTokenRef); @@ -522,38 +480,32 @@ export default function ContinuousView({ commitPendingActiveSegment(); }, [tokenSegmentMap, focusedTokenRef, commitPendingActiveSegment]); - /** Ref mirror of `onFocusedTokenRefChange` so callbacks never need it as a dep. */ - 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. - */ - 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. + // Name the initially-focused token on mount so the segment list scrolls the active verse into + // view on first render. Only fires when nothing has resolved a focus already. useEffect(() => { if (focusedTokenRef !== undefined) return; const initialGroup = phraseGroups[focusPhraseIndex]; const initialRef = initialGroup?.tokens[0]?.ref; - if (initialRef !== undefined) onFocusedTokenRefChangeRef.current(initialRef); + if (initialRef !== undefined) focusToken(initialRef, 'seed'); // Intentionally runs only on mount; do not add deps. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const atStart = phraseGroups.length === 0 || focusPhraseIndex === 0; const atEnd = phraseGroups.length === 0 || focusPhraseIndex >= phraseGroups.length - 1; + + /** + * Whether a step would count from a position the reader cannot see: the strip is mid-jump, + * holding the group focus left on screen for {@link RECENTER_FADE_MS} while it fades, and the fade + * does not cover the input that steps through it. + * + * A glide leaves the displayed focus lagging too, but only until the focus-change effect adopts + * it, which React reaches within the same discrete event as the press. Testing the origin rather + * than the lag alone keeps that transient out of the gate on purpose instead of on scheduling: a + * step there counts from the group the strip is already travelling to, and refusing it would drop + * the second of a pair of rapid presses. + */ + const isStepBlocked = focusedTokenRef !== displayFocusedTokenRef && focusOrigin !== 'strip'; const stripOpacityClass = isVisible ? 'tw:opacity-100' : 'tw:opacity-0'; /** Phrase groups mounted on each side of the focus, sized to the strip's visible width. */ @@ -633,9 +585,13 @@ export default function ContinuousView({ : allTokens.length; /** - * Advances focus by `delta` phrases by notifying the parent of the new focused token ref. The - * parent echoes the change back through `focusedTokenRef`, which re-derives `focusPhraseIndex` - * and triggers the scroll effect. Marks the change as internal so the fade is suppressed. + * Advances focus by `delta` phrases, which re-derives `focusPhraseIndex` and triggers the scroll + * effect. + * + * Counts from the focus as of the press, taken from the store rather than from the rendered + * index. That is what makes a second press before the re-render accumulate instead of repeating + * the first, and what keeps a step right when a phrase-link edit has regrouped the strip without + * moving focus. * * @param delta - Number of phrases to move (positive = forward, negative = backward). */ @@ -643,16 +599,20 @@ export default function ContinuousView({ (delta: number) => { /* v8 ignore next -- arrow buttons are disabled when phraseGroups is empty */ if (phraseGroups.length === 0) return; - const nextIndex = pendingPhraseIndexRef.current + delta; + const currentRef = getFocus().tokenRef; + /* v8 ignore next 2 -- a focus always resolves while the strip has groups to step through */ + const from = + (currentRef === undefined ? undefined : groupIndexByTokenRef.get(currentRef)) ?? + focusPhraseIndexRef.current; + const nextIndex = from + delta; /* v8 ignore next -- disabled buttons prevent under/overflow */ const clamped = nextIndex < 0 ? 0 : Math.min(nextIndex, phraseGroups.length - 1); /* v8 ignore next -- disabled buttons prevent clicking when already at boundary */ - if (clamped === pendingPhraseIndexRef.current) return; - pendingPhraseIndexRef.current = clamped; + if (clamped === from) return; const nextRef = phraseGroups[clamped]?.tokens[0]?.ref; - if (nextRef !== undefined) emitInternalFocus(nextRef); + if (nextRef !== undefined) focusToken(nextRef, 'strip'); }, - [phraseGroups, emitInternalFocus], + [phraseGroups, groupIndexByTokenRef, getFocus, focusToken, focusPhraseIndexRef], ); /** Moves focus one phrase backward. */ @@ -661,63 +621,92 @@ export default function ContinuousView({ /** Moves focus one phrase forward. */ const stepNext = useCallback(() => step(1), [step]); - /** Ref mirror of the focus so the select handler can compare against it without a dep on it. */ - const focusedTokenRefRef = useLatestRef(focusedTokenRef); - /** - * Notifies the parent that the user selected the phrase whose first token is `ref`. The parent - * echoes the new token ref back through `focusedTokenRef`; scroll + highlight follow - * automatically. Selecting the already-focused phrase is a no-op. + * Focuses the phrase whose first token is `ref`; scroll and highlight follow. Selecting the + * already-focused phrase is a no-op. * - * Reads the current focus through a ref rather than closing over it, so the handler keeps one - * identity across focus moves and passing it down cannot invalidate a memoized child. + * Reads the focus at click time rather than closing over it, so the handler keeps one identity + * across focus moves and passing it down cannot invalidate a memoized child. * * @param ref - First-token ref (group key) of the selected phrase. */ const handlePhraseSelect = useCallback( (ref: string) => { const targetGroupIndex = groupIndexByTokenRef.get(ref); - const currentFocus = focusedTokenRefRef.current; + const currentFocus = getFocus().tokenRef; + /* v8 ignore next 2 -- a focus is always resolved before a phrase box can be clicked */ const currentGroupIndex = currentFocus === undefined ? undefined : groupIndexByTokenRef.get(currentFocus); if (targetGroupIndex !== undefined && targetGroupIndex === currentGroupIndex) return; - emitInternalFocus(ref); + focusToken(ref, 'strip'); }, - [focusedTokenRefRef, groupIndexByTokenRef, emitInternalFocus], + [getFocus, groupIndexByTokenRef, focusToken], ); /** 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, - // Paratext verse selector, mode switch), fade the strip out, wait for the fade to complete, - // then snap the displayed focus into place so the scroll happens behind the curtain. + /** + * Handle of the fade a jump is waiting out, or `undefined` when none is pending. A ref rather + * than the effect's own cleanup, so cancelling a fade is something a run decides: a cleanup drops + * the timer before the run that superseded it can tell there was one. + */ + const fadeTimeoutRef = useRef | undefined>(undefined); + + /** Drops a pending fade and reports whether there was one to drop. */ + const cancelPendingFade = useCallback(() => { + if (fadeTimeoutRef.current === undefined) return false; + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = undefined; + return true; + }, []); + + // React to focus moves. For a move this strip made, apply the change immediately and + // smooth-scroll. For every other origin, fade the strip out, wait for the fade to complete, then + // snap the displayed focus into place so the scroll happens behind the curtain. + // + // A move supersedes any fade in flight, so it owes the reveal that fade will never run — + // including a move back onto what is already displayed, which has nothing to travel to. useEffect(() => { - if (focusedTokenRef === displayFocusedTokenRef) return undefined; - const isInternal = internalFocusedTokenRefRef.current === focusedTokenRef; - internalFocusedTokenRefRef.current = undefined; + if (focusedTokenRef === displayFocusedTokenRef) { + if (cancelPendingFade()) setIsVisible(true); + return; + } + cancelPendingFade(); + const isInternal = focusOrigin === 'strip'; if (isInternal) { lastDisplayUpdateWasInternalRef.current = true; + setIsVisible(true); setDisplayFocusedTokenRef(focusedTokenRef); - return undefined; + return; } lastDisplayUpdateWasInternalRef.current = false; setIsVisible(false); - const timeout = setTimeout(() => { + fadeTimeoutRef.current = setTimeout(() => { + fadeTimeoutRef.current = undefined; setDisplayFocusedTokenRef(focusedTokenRef); }, RECENTER_FADE_MS); - return () => clearTimeout(timeout); - }, [focusedTokenRef, displayFocusedTokenRef]); + // focusOrigin classifies the move that changed focusedTokenRef, so it is never itself a reason + // to re-run. Reading it unlisted is safe because the origin cannot move while the token ref + // holds still — see FocusStore.write. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusedTokenRef, displayFocusedTokenRef, cancelPendingFade]); + + // Clear a pending fade on unmount so its deferred update never lands on a torn-down tree. + useEffect( + () => () => { + cancelPendingFade(); + }, + [cancelPendingFade], + ); // Scroll the focused phrase into view whenever the displayed focus changes. Smooth-scroll for // internal nav (the displayed ref was updated immediately, so the prop and display agree); snap // for external jumps (the displayed ref was just updated post-fade) and for the initial mount. // - // "Internal" here means *this strip* emitted the change. `useSegmentWindow`'s `consumeInternalNav` - // answers a different question — whether any view in the tree originated the nav — so the two - // classify the same event differently: a segment-list click is internal to the list (it does not - // fade) and external to this strip (it fades, since the strip still has to travel). + // "Internal" here means the move carried this strip's own origin. The segment window asks a + // different question — whether any view in the tree originated the nav — so the two classify the + // same event differently by design. See FocusOrigin. useEffect(() => { // Drop any hold still running: it pins the group the focus just left. Such a hold is armed by // something the focus does not control (a window resize, an active-segment flip) and keeps @@ -901,18 +890,17 @@ export default function ContinuousView({ }, [renderWindowStart, focusPhraseIndex]); // When entering edit or confirm-unlink mode, smooth-scroll to the first group of the active - // phrase by notifying the parent of the new focused token. Scroll then follows automatically - // through focusedTokenRef → focusPhraseIndex. + // phrase by focusing its first token. Scroll then follows through focusPhraseIndex. useEffect(() => { if (phraseMode.kind === 'view') return; const targetPhraseId = phraseMode.phraseId; const group = phraseGroups.find((g) => g.phraseLink?.analysisId === targetPhraseId); const nextRef = group?.tokens[0]?.ref; - /* v8 ignore next -- phrase always has tokens; focusedTokenRef differs at mode entry */ + /* v8 ignore next -- phrase always has tokens; the focus differs at mode entry */ if (nextRef === undefined || nextRef === focusedTokenRef) return; - emitInternalFocus(nextRef); - // phraseGroups and focusedTokenRef are read once per mode change; intentionally not deps so the - // effect only fires on actual mode transitions. emitInternalFocus has a stable identity. + focusToken(nextRef, 'strip'); + // phraseGroups and the focus are read once per mode change; intentionally not deps so the + // effect only fires on actual mode transitions. focusToken has a stable identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [phraseMode]); @@ -987,9 +975,9 @@ export default function ContinuousView({ }); /** - * Group index of the focused token, derived from `focusedTokenRef`. Used per-slot to compute - * `focusedSideIsPrev` from the same source as `focus.focusedPhraseLink` / - * `focus.focusedFreeToken` so link direction and link target can never disagree. + * Group index of the live focused token. Used per-slot to compute `focusedSideIsPrev` from the + * same source as `focus.focusedPhraseLink` / `focus.focusedFreeToken` so link direction and link + * target can never disagree. */ const focusedGroupIndex = useMemo( () => (focusedTokenRef !== undefined ? groupIndexByTokenRef.get(focusedTokenRef) : undefined), @@ -998,12 +986,12 @@ export default function ContinuousView({ /** * Resolved focus context — what's focused, what segment it's in, what phrase it belongs to. Built - * 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. + * from the fade-gated `displayFocusedTokenRef` (not the live focus) 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( () => @@ -1146,7 +1134,7 @@ export default function ContinuousView({ {/* Previous navigation arrow */}
diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx index 37b691a3..b03c88ec 100644 --- a/src/components/SegmentListView.tsx +++ b/src/components/SegmentListView.tsx @@ -1,6 +1,6 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { Canon, type SerializedVerseRef } from '@sillsdev/scripture'; -import type { Book, ScriptureRef, Segment, Token } from 'interlinearizer'; +import type { Book, Segment, Token } from 'interlinearizer'; import { LocateFixed, Merge } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; import { formatReplacementString } from 'platform-bible-utils'; @@ -15,6 +15,7 @@ import { buildSegmentLabels } from '../utils/segment-labels'; import { segmentContainsVerse } from '../utils/verse-ref'; import { buildVerseStartLabels } from '../utils/verse-superscripts'; import { useAltHeldValue } from './AltHeldContext'; +import { useFocus, useFocusActions } from './FocusStore'; import { useSegmentation } from './SegmentationStore'; import MemoizedSegmentView from './SegmentView'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; @@ -124,8 +125,6 @@ type SegmentListViewProps = Readonly<{ * (recenter with a fade). */ segmentationVersion: number; - /** 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; /** @@ -159,8 +158,6 @@ type SegmentListViewProps = Readonly<{ 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. */ @@ -179,7 +176,6 @@ export default function SegmentListView({ book, scrRef, segmentationVersion, - focusedTokenRef, continuousScroll, displayContinuousScroll, onDisplayContinuousScrollChange, @@ -191,11 +187,13 @@ export default function SegmentListView({ hoveredPhraseId, setHoveredPhraseId, editPhraseSegmentId, - onSelect, tokenSegmentMap, tokenDocOrder, wordTokenByRef, }: SegmentListViewProps) { + const { tokenRef: focusedTokenRef } = useFocus(); + const { selectSegment } = useFocusActions(); + const [localizedStrings] = useLocalizedStrings(HEADER_STRING_KEYS); /** * Inline verse-superscript labels for every segment (chapter-qualified where a verse start opens @@ -431,7 +429,7 @@ export default function SegmentListView({ : segmentContainsVerse(seg, displayScrRef) } onHoverPhrase={setHoveredPhraseId} - onSelect={onSelect} + onSelect={selectSegment} phraseMode={phraseMode} setPhraseMode={setPhraseMode} segment={seg} diff --git a/src/hooks/useSegmentWindow.ts b/src/hooks/useSegmentWindow.ts index f2d4f8ff..c63cb389 100644 --- a/src/hooks/useSegmentWindow.ts +++ b/src/hooks/useSegmentWindow.ts @@ -514,9 +514,8 @@ export default function useSegmentWindow({ // selector, scroll group) and recenters with the fade. // // "Internal" here means some view in the tree originated the nav — a wider question than the one - // `ContinuousView`'s own internal-nav check asks, which is whether the strip itself emitted it. - // The two therefore classify the same event differently by design; a click in this list is - // internal by this test and external by that one. + // the strip asks of a focus move, which is whether it emitted that move itself. The two therefore + // classify the same event differently by design. See FocusOrigin. // // A segments-identity change carrying a `segmentationVersion` bump is NOT a navigation: it is a // boundary edit (merge/split from the mounted controls). The window slice already re-renders the