diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index d837b5b14..ce3b174f1 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -19,13 +19,18 @@ import { type SegmentationContextValue, } from '../../components/SegmentationStore'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; +import { INITIAL_WINDOW_HALF } from '../../hooks/usePhraseWindow'; +import type { SegmentPages } from '../../hooks/useSegmentPages'; +import { STRIP_PREFETCH_SEGMENTS } from '../../hooks/useStripSegments'; import { isWordToken } from '../../types/type-guards'; +import { STRIP_MARGIN_WORDS } from '../../utils/strip-range'; import { FIXTURE_STAMPS, makePhraseLink, makePunctToken, makeSegment, makeWordToken, + segmentPagesOf, } from '../test-helpers'; import { allFalseViewOptions, @@ -291,8 +296,9 @@ function makeRect(left: number, right: number): DOMRect { }; } -/** Builds the lookup maps the strip is handed, derived from a Book. */ +/** Builds the pages and lookup maps the strip is handed, derived from a Book. */ function buildLookups(book: Book): { + pages: SegmentPages; gapTextByWordRef: ReadonlyMap; tokenSegmentMap: ReadonlyMap; tokenDocOrder: ReadonlyMap; @@ -317,23 +323,24 @@ function buildLookups(book: Book): { } }); }); - return { gapTextByWordRef, tokenSegmentMap, tokenDocOrder, wordTokenByRef }; + return { + pages: segmentPagesOf(book), + gapTextByWordRef, + tokenSegmentMap, + tokenDocOrder, + wordTokenByRef, + }; } type StripProps = ComponentProps; /** Minimal strip props, so a test states only what it actually varies. */ function requiredProps(book: Book): StripProps { - const { gapTextByWordRef, tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book); return { - book, + ...buildLookups(book), editPhraseSegmentId: undefined, - gapTextByWordRef, phraseMode: { kind: 'view' }, setPhraseMode: jest.fn(), - tokenSegmentMap, - tokenDocOrder, - wordTokenByRef, viewOptions: { ...allFalseViewOptions }, }; } @@ -587,7 +594,7 @@ describe('ContinuousView initial render', () => { const strip = renderStrip(makeBook(), { focus: 'tok-2' }); scrollIntoViewMock.mockClear(); - strip.update({ book: otherBook, ...buildLookups(otherBook) }); + strip.update(buildLookups(otherBook)); strip.setFocus('mat-tok-1', 'reseed'); const scrolledTexts = scrollIntoViewMock.mock.contexts.map((el) => @@ -1275,7 +1282,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(() => { - strip.update({ book: merged, ...mergedLookups }); + strip.update(mergedLookups); }); expect(inSegmentIconMounted()).toBe(true); } finally { @@ -1380,7 +1387,7 @@ describe('ContinuousView segmentation edits', () => { // 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: [] }); - strip.update({ book: merged, ...buildLookups(merged) }); + strip.update(buildLookups(merged)); // The committed active segment must follow the merge; a stale id would suppress every link // button until the next navigation. @@ -1939,6 +1946,103 @@ describe('ContinuousView wheel scrolling', () => { }); describe('ContinuousView return to focus', () => { + /** + * Scrolls the strip `times` steps toward `direction`: each step extends the mounted window and + * culls every group mounted at the other end. + */ + function scrollStrip(direction: 'ahead' | 'back', times: number) { + screen.getByTestId('strip-scroll-viewport').getBoundingClientRect = () => makeRect(0, 1000); + const culled = direction === 'ahead' ? makeRect(-20000, -19900) : makeRect(20000, 20100); + const sentinel = direction === 'ahead' ? 'strip-trailing-sentinel' : 'strip-leading-sentinel'; + for (let i = 0; i < times; i += 1) { + screen + .getByTestId('token-strip') + .querySelectorAll('[data-phrase-group="true"]') + .forEach((group) => { + group.getBoundingClientRect = () => culled; + }); + act(() => { + global.triggerIntersection(screen.getByTestId(sentinel), true); + }); + } + } + + it('reads no segment back to a focus the strip has been scrolled far ahead of', () => { + const book = makeLargeBook(300); + const asked = new Set(); + const pages: SegmentPages = { + ...segmentPagesOf(book), + segmentAt: (index) => { + asked.add(index); + return book.segments[index]; + }, + }; + renderStrip(book, { focus: 'large-tok-50', props: { pages } }); + scrollStrip('ahead', 10); + asked.clear(); + + scrollStrip('ahead', 1); + + expect(Math.min(...asked)).toBeGreaterThan(50); + }); + + it('enables the previous arrow while the focus is left behind', () => { + renderStrip(makeLargeBook(300), { focus: 'large-tok-50' }); + + scrollStrip('ahead', 10); + + expect( + screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), + ).toBeEnabled(); + }); + + it('keeps the previous arrow disabled while a focus left behind opens the book', () => { + renderStrip(makeLargeBook(300), { focus: 'large-tok-0' }); + + scrollStrip('ahead', 10); + + expect( + screen.getByRole('button', { name: '%interlinearizer_continuousView_previousToken%' }), + ).toBeDisabled(); + }); + + it('keeps the next arrow disabled while a focus left behind closes the book', () => { + renderStrip(makeLargeBook(300), { focus: 'large-tok-299' }); + + scrollStrip('back', 10); + + expect( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ).toBeDisabled(); + }); + + it('centers a focus left behind once asked to return to it', () => { + renderStrip(makeLargeBook(300), { focus: 'large-tok-50' }); + scrollStrip('ahead', 10); + expect(screen.queryByText('word50')).not.toBeInTheDocument(); + scrollIntoViewMock.mockClear(); + + fireEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_returnToFocus%' }), + ); + + const centeredGroups = scrollIntoViewMock.mock.instances.map((el: unknown) => + el instanceof HTMLElement ? el.textContent : undefined, + ); + expect(centeredGroups).toContain('word50'); + }); + + it('steps from a focus left behind', () => { + const strip = renderStrip(makeLargeBook(300), { focus: 'large-tok-50' }); + scrollStrip('ahead', 10); + + fireEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ); + + expect(strip.focusToken).toHaveBeenLastCalledWith('large-tok-51', 'strip'); + }); + it('mounts the focused group again after the strip has been scrolled past it', () => { // The focused group can be culled while the reader scrolls, so the control has to rebuild the // window around it rather than scroll to an element that is no longer there. @@ -2099,6 +2203,28 @@ describe('ContinuousView phrase grouping', () => { expect(strip.focusToken).toHaveBeenCalledWith('tok-2', 'strip'); }); + it('jumps to the first token of a phrase it does not hold when entering edit mode', () => { + const phraseLink: PhraseAnalysisLink = { + ...FIXTURE_STAMPS, + id: 'phrase-far', + analysisId: 'phrase-far', + status: 'approved', + tokens: [ + { tokenRef: 'large-tok-251', surfaceText: 'word251' }, + { tokenRef: 'large-tok-250', surfaceText: 'word250' }, + ], + }; + phraseLinkMap.set('large-tok-250', phraseLink); + phraseLinkMap.set('large-tok-251', phraseLink); + const strip = renderStrip(makeLargeBook(300), { focus: 'large-tok-0' }); + + strip.update({ + phraseMode: { kind: 'edit', phraseId: 'phrase-far', originalTokens: phraseLink.tokens }, + }); + + expect(strip.focusToken).toHaveBeenCalledWith('large-tok-250', 'list'); + }); + it('fires phrase group hover enter and leave without throwing', async () => { const phraseLink: PhraseAnalysisLink = { ...FIXTURE_STAMPS, @@ -2222,3 +2348,170 @@ describe('ContinuousView phrase grouping', () => { expect(screen.getByTestId('arc-split-btn')).toHaveAttribute('data-candidate-phrase-ids', ''); }); }); + +describe('ContinuousView segment pages', () => { + /** Pages of `book` loaded across `[start, end)`, which `loadMore` widens. */ + function pagesLoading(book: Book, start: number, end: number) { + let loaded = { start, end }; + let version = 0; + const listeners = new Set<() => void>(); + const pages: SegmentPages = { + ...segmentPagesOf(book), + segmentAt: (index) => + index >= loaded.start && index < loaded.end ? book.segments[index] : undefined, + subscribe: (onChange) => { + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + loadedVersion: () => version, + }; + const loadMore = (nextStart: number, nextEnd: number) => + act(() => { + loaded = { start: nextStart, end: nextEnd }; + version += 1; + listeners.forEach((listener) => listener()); + }); + return { pages, loadMore }; + } + + it('reads no segment of the book beyond those around what it shows', () => { + const book = makeLargeBook(300); + const asked = new Set(); + const pages: SegmentPages = { + ...segmentPagesOf(book), + segmentAt: (index) => { + asked.add(index); + return book.segments[index]; + }, + }; + + renderStrip(book, { focus: 'large-tok-150', props: { pages } }); + + const reach = INITIAL_WINDOW_HALF + STRIP_MARGIN_WORDS; + expect(Math.min(...asked)).toBeGreaterThanOrEqual(150 - reach); + expect(Math.max(...asked)).toBeLessThanOrEqual(150 + reach); + }); + + it('asks for the pages around what it shows', () => { + const book = makeLargeBook(300); + const { pages } = pagesLoading(book, 0, 300); + + renderStrip(book, { focus: 'large-tok-150', props: { pages } }); + + expect(pages.load).toHaveBeenCalledWith( + 150 - INITIAL_WINDOW_HALF - STRIP_MARGIN_WORDS - STRIP_PREFETCH_SEGMENTS, + 150 + INITIAL_WINDOW_HALF + STRIP_MARGIN_WORDS + 1 + STRIP_PREFETCH_SEGMENTS, + 'strip', + ); + }); + + it('stays faded out after a jump until the page holding its focus loads', () => { + jest.useFakeTimers(); + try { + const book = makeLargeBook(300); + const { pages } = pagesLoading(book, 0, 100); + const strip = renderStrip(book, { focus: 'large-tok-10', props: { pages } }); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + strip.setFocus('large-tok-250', 'list'); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS * 2); + }); + + expect(screen.getByTestId('strip-fade-wrapper').className).toContain('tw:opacity-0'); + expect(screen.queryByText('word250')).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); + + it('shows the focus of a jump once the page holding it loads', () => { + jest.useFakeTimers(); + try { + const book = makeLargeBook(300); + const { pages, loadMore } = pagesLoading(book, 0, 100); + const strip = renderStrip(book, { focus: 'large-tok-10', props: { pages } }); + strip.setFocus('large-tok-250', 'list'); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + loadMore(0, 300); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + + expect(screen.getByText('word250')).toBeInTheDocument(); + expect(screen.getByTestId('strip-fade-wrapper').className).toContain('tw:opacity-100'); + } finally { + jest.useRealTimers(); + } + }); + + it('disables the next arrow at the last group held while the page after it loads', () => { + const book = makeLargeBook(40); + const { pages } = pagesLoading(book, 0, 20); + + renderStrip(book, { focus: 'large-tok-19', props: { pages } }); + + expect( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ).toBeDisabled(); + }); + + it('enables the next arrow once the page after the last group held loads', () => { + const book = makeLargeBook(40); + const { pages, loadMore } = pagesLoading(book, 0, 20); + renderStrip(book, { focus: 'large-tok-19', props: { pages } }); + + loadMore(0, 40); + + expect( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ).toBeEnabled(); + }); + + it('names its own focus once the page it opens on loads after it mounts', () => { + const book = makeLargeBook(300); + const { pages, loadMore } = pagesLoading(book, 100, 300); + const strip = renderStrip(book, { props: { pages } }); + + loadMore(0, 300); + + expect(strip.focusToken).toHaveBeenCalledWith('large-tok-0', 'seed'); + }); + + it('names no focus when one is resolved before the strip holds a group', () => { + const book = makeLargeBook(300); + const { pages, loadMore } = pagesLoading(book, 100, 300); + const strip = renderStrip(book, { props: { pages } }); + strip.setFocus('large-tok-150', 'list'); + + loadMore(0, 300); + + expect(strip.focusToken).not.toHaveBeenCalled(); + }); + + it('keeps showing what it shows when the segments before it load', () => { + const book = makeLargeBook(300); + const { pages, loadMore } = pagesLoading(book, 150, 300); + renderStrip(book, { focus: 'large-tok-150', props: { pages } }); + + loadMore(0, 300); + + expect(screen.getByText('word150')).toBeInTheDocument(); + }); + + it('leaves the scroll alone when the segments before what it shows load', () => { + const book = makeLargeBook(300); + const { pages, loadMore } = pagesLoading(book, 150, 300); + renderStrip(book, { focus: 'large-tok-150', props: { pages } }); + scrollIntoViewMock.mockClear(); + + loadMore(0, 300); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 8e3c11d27..8da1e606c 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -2350,6 +2350,7 @@ describe('segment pages', () => { expect(pages.load).toHaveBeenCalledWith( 29 - INITIAL_WINDOW_HALF - EXTEND_CHUNK, 30 + INITIAL_WINDOW_HALF + EXTEND_CHUNK, + 'list', ); }); @@ -2362,6 +2363,7 @@ describe('segment pages', () => { expect(pages.load).toHaveBeenLastCalledWith( 29 - INITIAL_WINDOW_HALF - EXTEND_CHUNK, 30 + INITIAL_WINDOW_HALF + EXTEND_CHUNK, + 'list', ); }); @@ -2393,7 +2395,7 @@ describe('segment pages', () => { ), ); - expect(pages.load).toHaveBeenCalledWith(49 - EXTEND_CHUNK, 50 + EXTEND_CHUNK); + expect(pages.load).toHaveBeenCalledWith(49 - EXTEND_CHUNK, 50 + EXTEND_CHUNK, 'list'); }); }); diff --git a/src/__tests__/hooks/usePhraseWindow.test.ts b/src/__tests__/hooks/usePhraseWindow.test.ts index a9ba1251c..4d48c5a3f 100644 --- a/src/__tests__/hooks/usePhraseWindow.test.ts +++ b/src/__tests__/hooks/usePhraseWindow.test.ts @@ -44,17 +44,19 @@ function renderPhraseWindow( focusIndex: number, renderedStart?: number, focusKey?: string, + first?: number, ) { const viewport = document.createElement('div'); document.body.appendChild(viewport); const hook = renderHook< ReturnType, - { total: number; focusIndex: number; focusKey?: string } + { total: number; focusIndex: number; focusKey?: string; first?: number } >( - ({ total: t, focusIndex: f, focusKey: k }) => { + ({ total: t, focusIndex: f, focusKey: k, first: s }) => { const viewportRef = useRef(viewport); const renderedStartRef = useRef(renderedStart); return usePhraseWindow({ + first: s, total: t, focusIndex: f, focusKey: k, @@ -62,7 +64,7 @@ function renderPhraseWindow( renderedStartRef, }); }, - { initialProps: { total, focusIndex, focusKey } }, + { initialProps: { total, focusIndex, focusKey, first } }, ); return { ...hook, viewport }; } @@ -145,6 +147,78 @@ describe('usePhraseWindow', () => { expect(result.current.range.end).toBe(20); }); + it('clamps the window at the first group there is', () => { + const { result } = renderPhraseWindow(100, 52, undefined, undefined, 50); + + expect(result.current.range).toEqual({ start: 50, end: 52 + INITIAL_WINDOW_HALF + 1 }); + }); + + it('clamps the window at the last group there is', () => { + const { result } = renderPhraseWindow(20, 68, undefined, undefined, 50); + + expect(result.current.range.end).toBe(70); + }); + + it('extends the window start no further back than the first group there is', () => { + const { result, viewport } = renderPhraseWindow( + 100, + 50 + INITIAL_WINDOW_HALF + 2, + undefined, + undefined, + 50, + ); + const { leading } = mountSentinels(viewport, result.current); + + act(() => { + global.triggerIntersection(leading, true); + }); + + expect(result.current.range.start).toBe(50); + }); + + it('takes no step before the first group there is', () => { + const { result, viewport } = renderPhraseWindow(100, 50, undefined, undefined, 50); + const before = result.current.range; + const { leading } = mountSentinels(viewport, result.current); + + act(() => { + global.triggerIntersection(leading, true); + }); + + expect(result.current.range).toBe(before); + }); + + it('keeps the part of the window still inside groups that move under it', () => { + const { result, rerender } = renderPhraseWindow(100, 50); + + rerender({ first: 47, total: 100, focusIndex: 50 }); + + expect(result.current.range).toEqual({ start: 47, end: 50 + INITIAL_WINDOW_HALF + 1 }); + }); + + it('rebuilds the window around the focus when groups move clear of it', () => { + const { result, rerender } = renderPhraseWindow(100, 50); + + rerender({ first: 200, total: 100, focusIndex: 250 }); + + expect(result.current.range).toEqual({ + start: 250 - INITIAL_WINDOW_HALF, + end: 250 + INITIAL_WINDOW_HALF + 1, + }); + }); + + it('watches the sentinels afresh when groups arrive past the edge the window stopped at', () => { + const { result, viewport, rerender } = renderPhraseWindow(20, 19); + const { trailing } = mountSentinels(viewport, result.current); + const watching = () => global.ioInstances.find((instance) => instance.targets.has(trailing)); + const before = watching(); + + rerender({ total: 40, focusIndex: 19 }); + + expect(watching()).toBeDefined(); + expect(watching()).not.toBe(before); + }); + it('extends the window end when the trailing sentinel comes into view', () => { const { result, viewport } = renderPhraseWindow(200, 100); const before = result.current.range.end; @@ -408,6 +482,17 @@ describe('usePhraseWindow', () => { }); }); + it('rebuilds the window around a focus that jumps clear of it as groups move under it', () => { + const { result, rerender } = renderPhraseWindow(100, 0, undefined, 'tok-0'); + + rerender({ first: 5, total: 100, focusIndex: 50, focusKey: 'tok-50' }); + + expect(result.current.range).toEqual({ + start: 50 - INITIAL_WINDOW_HALF, + end: 50 + INITIAL_WINDOW_HALF + 1, + }); + }); + it('leaves a scrolled-away window alone when an edit renumbers the focus', () => { // Linking two adjacent tokens collapses two groups into one, shifting every later index. The // reader focused none of them, so the strip must stay where they scrolled it. diff --git a/src/__tests__/hooks/useSegmentPages.test.ts b/src/__tests__/hooks/useSegmentPages.test.ts index c278fca01..43f7665ae 100644 --- a/src/__tests__/hooks/useSegmentPages.test.ts +++ b/src/__tests__/hooks/useSegmentPages.test.ts @@ -209,7 +209,7 @@ describe('useSegmentPages', () => { const { result } = renderPages(); await waitFor(() => expect(result.current.pages).toBeDefined()); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(result.current.pages?.segmentAt(99)?.id).toBe('GEN 1:100')); expect(result.current.pages?.segmentAt(2 * PAGE_SIZE)?.id).toBe(`GEN 1:${2 * PAGE_SIZE + 1}`); @@ -220,7 +220,7 @@ describe('useSegmentPages', () => { const { result } = renderPages(); await waitFor(() => expect(result.current.pages).toBeDefined()); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); expect(rangesAskedFor()).toEqual([ @@ -238,7 +238,7 @@ describe('useSegmentPages', () => { await waitFor(() => expect(result.current.pages).toBeDefined()); mockSendCommand.mockClear(); - act(() => result.current.pages?.load(0, 200)); + act(() => result.current.pages?.load(0, 200, 'list')); await waitFor(() => expect(result.current.pages?.segmentAt(199)).toBeDefined()); expect(rangesAskedFor()).toEqual([ @@ -255,7 +255,7 @@ describe('useSegmentPages', () => { result.current.pages?.subscribe(onChange); const version = result.current.pages?.loadedVersion(); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); expect(result.current.pages?.loadedVersion()).not.toBe(version); @@ -268,7 +268,7 @@ describe('useSegmentPages', () => { const onChange = jest.fn(); result.current.pages?.subscribe(onChange)(); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); expect(onChange).not.toHaveBeenCalled(); @@ -280,10 +280,10 @@ describe('useSegmentPages', () => { await answer(requests[0]); await answer(...requests.slice(1)); - act(() => result.current.pages?.load(70, 80)); - act(() => result.current.pages?.load(70, 80)); + act(() => result.current.pages?.load(70, 80, 'list')); + act(() => result.current.pages?.load(70, 80, 'list')); await answer(requests[requests.length - 1]); - act(() => result.current.pages?.load(70, 80)); + act(() => result.current.pages?.load(70, 80, 'list')); expect(pagesAskedFor()).toEqual([0, 1, 2]); }); @@ -293,7 +293,7 @@ describe('useSegmentPages', () => { const { result } = renderPages(); await waitFor(() => expect(result.current.pages).toBeDefined()); - act(() => result.current.pages?.load(-50, 500)); + act(() => result.current.pages?.load(-50, 500, 'list')); await waitFor(() => expect(pagesAskedFor()).toEqual([0, 1, 2, 3])); }); @@ -319,7 +319,7 @@ describe('useSegmentPages', () => { serveHost(BOOK); const { result, rerender } = renderPages(); await waitFor(() => expect(result.current.pages).toBeDefined()); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); mockSendCommand.mockClear(); @@ -330,6 +330,57 @@ describe('useSegmentPages', () => { expect(pagesAskedFor()).toEqual([0, 1, 2, 3]); }); + it("loads the pages last asked for with the outline of the book's edited text", async () => { + let book = BOOK; + serveHost(() => book); + const { result, rerender } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + act(() => result.current.pages?.load(90, 100, 'list')); + await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); + mockSendCommand.mockClear(); + const before = result.current.pages; + + book = makeBook(100, 'v2'); + rerender(pageArgs({ verseBook: book })); + + await waitFor(() => expect(result.current.pages).not.toBe(before)); + expect(result.current.pages?.segmentAt(99)?.id).toBe('GEN 1:100'); + expect(pagesAskedFor()).toEqual([0, 1, 2, 3]); + }); + + it('loads the pages each caller last asked for with the outline for new boundaries', async () => { + const book = makeBook(300); + serveHost(book); + const { result, rerender } = renderPages({ verseBook: book }); + await waitFor(() => expect(result.current.pages).toBeDefined()); + act(() => result.current.pages?.load(90, 100, 'list')); + act(() => result.current.pages?.load(250, 260, 'strip')); + await waitFor(() => expect(result.current.pages?.segmentAt(259)).toBeDefined()); + mockSendCommand.mockClear(); + + rerender(pageArgs({ verseBook: book, segmentation: MERGE_1_2, segmentationVersion: 1 })); + + await waitFor(() => expect(result.current.pages?.segmentationVersion).toBe(1)); + expect(pagesAskedFor()).toEqual([0, 1, 2, 3, 7, 8]); + }); + + it('stops loading the pages a released caller asked for with the outline for new boundaries', async () => { + const book = makeBook(300); + serveHost(book); + const { result, rerender } = renderPages({ verseBook: book }); + await waitFor(() => expect(result.current.pages).toBeDefined()); + act(() => result.current.pages?.load(90, 100, 'list')); + act(() => result.current.pages?.load(250, 260, 'strip')); + await waitFor(() => expect(result.current.pages?.segmentAt(259)).toBeDefined()); + act(() => result.current.pages?.release('strip')); + mockSendCommand.mockClear(); + + rerender(pageArgs({ verseBook: book, segmentation: MERGE_1_2, segmentationVersion: 1 })); + + await waitFor(() => expect(result.current.pages?.segmentationVersion).toBe(1)); + expect(pagesAskedFor()).toEqual([0, 1, 2, 3]); + }); + it('carries the boundary-edit count the outline was asked for at', async () => { const requests = serveHost(BOOK, { held: true }); const { result, rerender } = renderPages({ segmentationVersion: 4 }); @@ -370,7 +421,7 @@ describe('useSegmentPages', () => { const { result, rerender } = renderPages(); await answer(requests[0]); await answer(...requests.slice(1)); - act(() => result.current.pages?.load(96, 100)); + act(() => result.current.pages?.load(96, 100, 'list')); rerender(pageArgs({ segmentation: MERGE_1_2 })); await answer(requests[3]); await answer(...requests.slice(4)); @@ -421,7 +472,7 @@ describe('useSegmentPages', () => { await waitFor(() => expect(result.current.pages).toBeDefined()); book = makeBook(100, 'v2'); - act(() => result.current.pages?.load(90, 100)); + act(() => result.current.pages?.load(90, 100, 'list')); await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); expect(result.current.pages?.segmentAt(99)).toBeUndefined(); @@ -434,7 +485,7 @@ describe('useSegmentPages', () => { await waitFor(() => expect(result.current.pages).toBeDefined()); mockSendCommand.mockResolvedValue(undefined); - act(() => result.current.pages?.load(96, 100)); + act(() => result.current.pages?.load(96, 100, 'list')); await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); }); @@ -600,10 +651,10 @@ describe('useSegmentPages', () => { const { result } = renderPages(); await answer(requests[0]); await answer(...requests.slice(1)); - act(() => result.current.pages?.load(96, 100)); + act(() => result.current.pages?.load(96, 100, 'list')); await fail(requests[2], new Error('busy')); - act(() => result.current.pages?.load(96, 100)); + act(() => result.current.pages?.load(96, 100, 'list')); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('busy')); expect(pagesAskedFor()).toEqual([0, 1, 3, 3]); @@ -620,7 +671,7 @@ describe('useSegmentPages', () => { await answer(requests[0]); await answer(...requests.slice(1)); const unsubscribe = result.current.pages?.subscribe(jest.fn()); - act(() => result.current.pages?.load(96, 100)); + act(() => result.current.pages?.load(96, 100, 'list')); await fail(requests[2], new Error('busy')); return { requests, result, unsubscribe }; } @@ -670,7 +721,7 @@ describe('useSegmentPages', () => { await answer(requests[0]); await answer(...requests.slice(1)); result.current.pages?.subscribe(jest.fn()); - act(() => result.current.pages?.load(0, 200)); + act(() => result.current.pages?.load(0, 200, 'list')); await fail(requests[2], new Error('busy')); await fail(requests[3], new Error('busy')); await advance(PAGE_RETRY_DELAYS_MS[0]); diff --git a/src/__tests__/hooks/useStripGroups.test.ts b/src/__tests__/hooks/useStripGroups.test.ts new file mode 100644 index 000000000..abbc7bc5c --- /dev/null +++ b/src/__tests__/hooks/useStripGroups.test.ts @@ -0,0 +1,151 @@ +/// + +import { renderHook } from '@testing-library/react'; +import type { PhraseAnalysisLink, Segment } from 'interlinearizer'; +import useStripGroups from '../../hooks/useStripGroups'; +import { makePhraseLink, makePunctToken, makeSegment, makeWordToken } from '../test-helpers'; + +/** Verse `verse` of GEN 1: two words and a full stop. */ +function makeVerse(verse: number): Segment { + return makeSegment(`GEN 1:${verse}`, 'a b.', [ + makeWordToken(`${verse}a`, 'a', 0), + makeWordToken(`${verse}b`, 'b', 2), + makePunctToken(`${verse}.`, '.', 3), + ]); +} + +const VERSES = Array.from({ length: 10 }, (_unused, i) => makeVerse(i + 1)); + +const NO_PHRASES = new Map(); + +/** Phrase-link lookup joining verse 2's two words. */ +function joiningVerse2(): Map { + const link = makePhraseLink('p', ['2a', '2b']); + return new Map([ + ['2a', link], + ['2b', link], + ]); +} + +type Props = { segments: readonly Segment[]; links: Map }; + +function renderGroups(segments: readonly Segment[], links = NO_PHRASES) { + return renderHook(({ segments: s, links: l }: Props) => useStripGroups(s, l), { + initialProps: { segments, links }, + }); +} + +describe('useStripGroups', () => { + it("flattens the segments' tokens in order", () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.tokens.map((t) => t.ref)).toEqual(['1a', '1b', '1.', '2a', '2b', '2.']); + }); + + it('groups each word on its own', () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.groups.map((g) => g.tokens.map((t) => t.ref))).toEqual([ + ['1a'], + ['1b'], + ['2a'], + ['2b'], + ]); + }); + + it("groups a phrase's words together", () => { + const { result } = renderGroups(VERSES.slice(0, 2), joiningVerse2()); + + expect(result.current.groups.map((g) => g.tokens.map((t) => t.ref))).toEqual([ + ['1a'], + ['1b'], + ['2a', '2b'], + ]); + }); + + it('numbers the first groups from zero', () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.first).toBe(0); + }); + + it('indexes every word by its group', () => { + const { result } = renderGroups(VERSES.slice(0, 2), joiningVerse2()); + + expect([...result.current.indexByTokenRef]).toEqual([ + ['1a', 0], + ['1b', 1], + ['2a', 2], + ['2b', 2], + ]); + }); + + it("keeps each group's index when segments are held before it", () => { + const { result, rerender } = renderGroups(VERSES.slice(4, 6)); + const before = result.current.indexByTokenRef.get('5a'); + + rerender({ segments: VERSES.slice(2, 6), links: NO_PHRASES }); + + expect(result.current.indexByTokenRef.get('5a')).toBe(before); + expect(result.current.first).toBe(-4); + }); + + it("keeps each group's index when segments before it are dropped", () => { + const { result, rerender } = renderGroups(VERSES.slice(2, 6)); + const before = result.current.indexByTokenRef.get('5a'); + + rerender({ segments: VERSES.slice(4, 8), links: NO_PHRASES }); + + expect(result.current.indexByTokenRef.get('5a')).toBe(before); + }); + + it('numbers groups past every index used when they share no word with those before', () => { + const { result, rerender } = renderGroups(VERSES.slice(0, 2)); + + rerender({ segments: VERSES.slice(6, 8), links: NO_PHRASES }); + + expect(result.current.first).toBeGreaterThan(3); + }); + + it('keeps the groups of a segment held across a change', () => { + const { result, rerender } = renderGroups(VERSES.slice(2, 4)); + const before = result.current.at(result.current.indexByTokenRef.get('3a') ?? -1); + + rerender({ segments: VERSES.slice(2, 6), links: NO_PHRASES }); + + expect(result.current.at(result.current.indexByTokenRef.get('3a') ?? -1)).toBe(before); + }); + + it('regroups the segments when the phrase links change', () => { + const { result, rerender } = renderGroups(VERSES.slice(0, 2)); + + rerender({ segments: VERSES.slice(0, 2), links: joiningVerse2() }); + + expect(result.current.groups).toHaveLength(3); + }); + + it('finds the group at an index', () => { + const { result, rerender } = renderGroups(VERSES.slice(4, 6)); + rerender({ segments: VERSES.slice(2, 6), links: NO_PHRASES }); + + expect(result.current.at(result.current.first)?.tokens[0].ref).toBe('3a'); + }); + + it('finds no group at an index it does not hold', () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.at(4)).toBeUndefined(); + }); + + it("places a group's first token among the tokens", () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.tokenIndexOf(2)).toBe(3); + }); + + it('places the end of the tokens past the last group', () => { + const { result } = renderGroups(VERSES.slice(0, 2)); + + expect(result.current.tokenIndexOf(4)).toBe(6); + }); +}); diff --git a/src/__tests__/hooks/useStripSegments.test.ts b/src/__tests__/hooks/useStripSegments.test.ts new file mode 100644 index 000000000..bc4aa2c8e --- /dev/null +++ b/src/__tests__/hooks/useStripSegments.test.ts @@ -0,0 +1,300 @@ +/// + +import { act, renderHook } from '@testing-library/react'; +import type { Book } from 'interlinearizer'; +import type { SegmentPages } from '../../hooks/useSegmentPages'; +import useStripSegments, { + STRIP_PREFETCH_SEGMENTS, + type UseStripSegmentsArgs, +} from '../../hooks/useStripSegments'; +import { STRIP_MARGIN_WORDS } from '../../utils/strip-range'; +import { makeSegment, makeWordToken, outlineOf } from '../test-helpers'; + +/** A book of `count` one-word verses in chapter 1. */ +function makeBook(count: number): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + duplicateVerseIds: [], + segments: Array.from({ length: count }, (_unused, i) => + makeSegment(`GEN 1:${i + 1}`, 'word', [makeWordToken(tokenAt(i), 'word')]), + ), + }; +} + +/** Ref of the one token of the segment at `index`. */ +function tokenAt(index: number): string { + return `GEN 1:${index + 1}:0`; +} + +const BOOK = makeBook(200); + +/** Every segment the outline places each token in. */ +const TOKEN_SEGMENTS: ReadonlyMap = new Map( + BOOK.segments.map((segment) => [segment.tokens[0].ref, segment.id]), +); + +/** Pages of `book` loaded across `[start, end)`, which `loadMore` widens. */ +function makePages(book: Book, start = 0, end = book.segments.length) { + let loaded = { start, end }; + let version = 0; + const listeners = new Set<() => void>(); + const pages: SegmentPages = { + bookRef: book.bookRef, + outline: outlineOf(book.segments), + segmentationVersion: 0, + segmentAt: (index) => + index >= loaded.start && index < loaded.end ? book.segments[index] : undefined, + load: jest.fn(), + release: jest.fn(), + subscribe: (onChange) => { + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + loadedVersion: () => version, + }; + const loadMore = (nextStart: number, nextEnd: number) => + act(() => { + loaded = { start: nextStart, end: nextEnd }; + version += 1; + listeners.forEach((listener) => listener()); + }); + return { pages, loadMore }; +} + +/** Pages of {@link BOOK}, every one loaded. */ +const LOADED = makePages(BOOK).pages; + +function stripArgs(overrides: Partial): UseStripSegmentsArgs { + return { + pages: LOADED, + focusTokenRef: tokenAt(100), + nextFocusTokenRef: tokenAt(100), + tokenSegmentMap: TOKEN_SEGMENTS, + ...overrides, + }; +} + +function renderStrip(overrides: Partial = {}) { + return renderHook((props: UseStripSegmentsArgs) => useStripSegments(props), { + initialProps: stripArgs(overrides), + }); +} + +/** The outline indexes of the segments held, first and last. */ +function heldRange(segments: readonly { id: string }[]): [number, number] { + const indexOf = (id: string | undefined) => BOOK.segments.findIndex((s) => s.id === id); + return [indexOf(segments[0]?.id), indexOf(segments.at(-1)?.id)]; +} + +describe('useStripSegments', () => { + it('holds the segments around the focus, a margin of words each side', () => { + const { result } = renderStrip(); + + expect(heldRange(result.current.segments)).toEqual([ + 100 - STRIP_MARGIN_WORDS, + 100 + STRIP_MARGIN_WORDS, + ]); + }); + + it('holds the segments around the focus being moved to when the one shown is not in the book', () => { + const { result } = renderStrip({ focusTokenRef: 'EXO 1:1:0', nextFocusTokenRef: tokenAt(150) }); + + expect(heldRange(result.current.segments)).toEqual([ + 150 - STRIP_MARGIN_WORDS, + 150 + STRIP_MARGIN_WORDS, + ]); + }); + + it('holds segments from the start of the book when nothing is focused', () => { + const { result } = renderStrip({ focusTokenRef: undefined, nextFocusTokenRef: undefined }); + + expect(heldRange(result.current.segments)).toEqual([0, STRIP_MARGIN_WORDS]); + }); + + it('holds only the segments whose pages have loaded', () => { + const { result } = renderStrip({ pages: makePages(BOOK, 90, 110).pages }); + + expect(heldRange(result.current.segments)).toEqual([90, 109]); + }); + + it('holds nothing until the page holding the focus has loaded', () => { + const { result } = renderStrip({ pages: makePages(BOOK, 0, 10).pages }); + + expect(result.current.segments).toEqual([]); + }); + + it('holds segments as their pages load', () => { + const { pages, loadMore } = makePages(BOOK, 90, 110); + const { result } = renderStrip({ pages }); + + loadMore(0, 200); + + expect(heldRange(result.current.segments)).toEqual([ + 100 - STRIP_MARGIN_WORDS, + 100 + STRIP_MARGIN_WORDS, + ]); + }); + + it('keeps the segments it holds while pages load elsewhere', () => { + const { pages, loadMore } = makePages(BOOK, 0, 150); + const { result } = renderStrip({ pages }); + const before = result.current.segments; + + loadMore(0, 200); + + expect(result.current.segments).toBe(before); + }); + + it('follows the segments mounted, a margin of words past them', () => { + const { result } = renderStrip(); + + act(() => result.current.setMounted(tokenAt(95), tokenAt(140))); + + expect(heldRange(result.current.segments)).toEqual([ + 95 - STRIP_MARGIN_WORDS, + 140 + STRIP_MARGIN_WORDS, + ]); + }); + + it('holds a focus that stays put within the margin of the segments mounted away from it', () => { + const { result } = renderStrip(); + + act(() => result.current.setMounted(tokenAt(110), tokenAt(120))); + + expect(heldRange(result.current.segments)).toEqual([ + 110 - STRIP_MARGIN_WORDS, + 120 + STRIP_MARGIN_WORDS, + ]); + expect(result.current.isFocusLeftBehind).toBe(false); + }); + + it('lets go of a focus that stays put once the segments mounted move past its margin', () => { + const { result } = renderStrip(); + + act(() => result.current.setMounted(tokenAt(150), tokenAt(160))); + + expect(heldRange(result.current.segments)).toEqual([ + 150 - STRIP_MARGIN_WORDS, + 160 + STRIP_MARGIN_WORDS, + ]); + expect(result.current.isFocusLeftBehind).toBe(true); + }); + + it('holds the segments around a focus left behind again when asked', () => { + const { result } = renderStrip(); + act(() => result.current.setMounted(tokenAt(150), tokenAt(160))); + + act(() => result.current.holdFocus()); + + expect(heldRange(result.current.segments)).toEqual([ + 100 - STRIP_MARGIN_WORDS, + 100 + STRIP_MARGIN_WORDS, + ]); + expect(result.current.isFocusLeftBehind).toBe(false); + }); + + it('keeps following the segments mounted when the focus moves within them', () => { + const { result, rerender } = renderStrip(); + act(() => result.current.setMounted(tokenAt(95), tokenAt(140))); + + rerender(stripArgs({ focusTokenRef: tokenAt(120) })); + + expect(heldRange(result.current.segments)).toEqual([ + 95 - STRIP_MARGIN_WORDS, + 140 + STRIP_MARGIN_WORDS, + ]); + }); + + it('holds only the segments around a focus that moves clear of those mounted', () => { + const { result, rerender } = renderStrip(); + act(() => result.current.setMounted(tokenAt(95), tokenAt(105))); + + rerender(stripArgs({ focusTokenRef: tokenAt(180) })); + + expect(heldRange(result.current.segments)).toEqual([180 - STRIP_MARGIN_WORDS, 199]); + }); + + it('asks for the pages it holds, and some beyond', () => { + const { pages } = makePages(BOOK); + + renderStrip({ pages }); + + expect(pages.load).toHaveBeenCalledWith( + 100 - STRIP_MARGIN_WORDS - STRIP_PREFETCH_SEGMENTS, + 100 + STRIP_MARGIN_WORDS + 1 + STRIP_PREFETCH_SEGMENTS, + 'strip', + ); + }); + + it('asks for the pages around a focus it is moving to', () => { + const { pages } = makePages(BOOK); + + renderStrip({ pages, nextFocusTokenRef: tokenAt(180) }); + + expect(pages.load).toHaveBeenLastCalledWith( + 180 - STRIP_MARGIN_WORDS - STRIP_PREFETCH_SEGMENTS, + 200 + STRIP_PREFETCH_SEGMENTS, + 'strip', + ); + }); + + it('asks for nothing more for a focus it is moving to among the segments it holds', () => { + const { pages } = makePages(BOOK); + + renderStrip({ pages, nextFocusTokenRef: tokenAt(110) }); + + expect(pages.load).toHaveBeenCalledTimes(1); + }); + + it('releases the pages it asked for when it unmounts', () => { + const { pages } = makePages(BOOK); + const { unmount } = renderStrip({ pages }); + + unmount(); + + expect(pages.release).toHaveBeenCalledWith('strip'); + }); + + it('tells a token whose page has loaded', () => { + const { result } = renderStrip({ pages: makePages(BOOK, 90, 110).pages }); + + expect(result.current.hasPage(tokenAt(95))).toBe(true); + }); + + it('tells a token whose page has not loaded', () => { + const { result } = renderStrip({ pages: makePages(BOOK, 90, 110).pages }); + + expect(result.current.hasPage(tokenAt(150))).toBe(false); + }); + + it('tells no page for a token of no segment', () => { + const { result } = renderStrip(); + + expect(result.current.hasPage('elsewhere')).toBe(false); + }); + + it('keeps placing the tokens it holds while the boundaries given lag the outline', () => { + const { result, rerender } = renderStrip(); + const before = result.current.segments; + + // The boundaries move the focus into a split segment that the outline does not list yet. + const split = new Map(TOKEN_SEGMENTS); + split.set(tokenAt(100), 'GEN 1:101:4'); + rerender(stripArgs({ tokenSegmentMap: split })); + + expect(result.current.segments).toBe(before); + }); + + it('places the focus by the boundaries given once a new outline arrives', () => { + const { result, rerender } = renderStrip(); + + rerender(stripArgs({ pages: makePages(BOOK).pages, focusTokenRef: tokenAt(150) })); + + expect(heldRange(result.current.segments)).toEqual([ + 150 - STRIP_MARGIN_WORDS, + 150 + STRIP_MARGIN_WORDS, + ]); + }); +}); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 4dc98de72..b1a547688 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -186,7 +186,7 @@ const pagesBySegments = new WeakMap(); @@ -200,6 +200,7 @@ export function segmentPagesOf(book: Book, segmentationVersion = 0): SegmentPage segmentationVersion, segmentAt: (index) => book.segments[index], load: jest.fn(), + release: jest.fn(), subscribe: () => () => {}, loadedVersion: () => 0, }; diff --git a/src/__tests__/utils/reanchor-analysis.test.ts b/src/__tests__/utils/reanchor-analysis.test.ts index cfda8670c..28cd5d4d9 100644 --- a/src/__tests__/utils/reanchor-analysis.test.ts +++ b/src/__tests__/utils/reanchor-analysis.test.ts @@ -440,6 +440,57 @@ describe('reanchorAnalysisToBook', () => { }); }); + it('stales a phrase whose tokens fall in two segments', () => { + const book = makeVerseBook([ + { sid: 'GEN 1:1', text: 'alpha beta' }, + { sid: 'GEN 1:2', text: 'gamma delta' }, + ]); + const analysis: TextAnalysis = { + ...emptyAnalysis(), + phraseAnalysisLinks: [makePhraseLink('pa-1', ['GEN 1:1:6', 'GEN 1:2:0'], ['beta', 'gamma'])], + }; + + const result = reanchor(analysis, book); + + expect(result.phraseAnalysisLinks[0].status).toBe('stale'); + }); + + it('keeps a phrase across two verses approved while one segment holds both', () => { + const verseBook = makeVerseBook([ + { sid: 'GEN 1:1', text: 'alpha beta' }, + { sid: 'GEN 1:2', text: 'gamma delta' }, + ]); + const merged = resegmentBook(verseBook, { + removedVerseStarts: [verseBook.segments[1].tokens[0].ref], + addedStarts: [], + }); + const analysis: TextAnalysis = { + ...emptyAnalysis(), + phraseAnalysisLinks: [makePhraseLink('pa-1', ['GEN 1:1:6', 'GEN 1:2:0'], ['beta', 'gamma'])], + }; + + const result = reanchor(analysis, merged); + + expect(result.phraseAnalysisLinks[0].status).toBe('approved'); + }); + + it('keeps a stale phrase stale when its tokens place again across two segments', () => { + const verseBook = makeVerseBook([{ sid: 'GEN 1:1', text: 'alpha beta gamma' }]); + const split = resegmentBook(verseBook, { + removedVerseStarts: [], + addedStarts: [{ tokenRef: verseBook.segments[0].tokens[1].ref, surfaceText: 'beta' }], + }); + const staleLink: PhraseAnalysisLink = { + ...makePhraseLink('pa-1', ['GEN 1:1:0', 'GEN 1:1:6'], ['alpha', 'beta']), + status: 'stale', + }; + const analysis: TextAnalysis = { ...emptyAnalysis(), phraseAnalysisLinks: [staleLink] }; + + const result = reanchor(analysis, split); + + expect(result.phraseAnalysisLinks[0].status).toBe('stale'); + }); + it('leaves an already-stale phrase link untouched rather than restamping it', () => { const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'in silence' }]); const staleLink: PhraseAnalysisLink = { diff --git a/src/__tests__/utils/strip-range.test.ts b/src/__tests__/utils/strip-range.test.ts new file mode 100644 index 000000000..9310091de --- /dev/null +++ b/src/__tests__/utils/strip-range.test.ts @@ -0,0 +1,75 @@ +/// + +import type { OutlineSegment } from '../../types/segment-pages'; +import { STRIP_MARGIN_WORDS, loadedRun, stripTarget } from '../../utils/strip-range'; + +/** An outline of `count` segments holding `words` words apiece. */ +function outlineOf(count: number, words: number): OutlineSegment[] { + return Array.from({ length: count }, (_unused, i) => ({ + id: `GEN 1:${i + 1}`, + startRef: { book: 'GEN', chapter: 1, verse: i + 1 }, + verseStarts: [], + tokenCount: words, + wordTokenCount: words, + textLength: words * 5, + })); +} + +describe('stripTarget', () => { + it('holds enough segments each side for the margin of words', () => { + const outline = outlineOf(100, STRIP_MARGIN_WORDS / 4); + + expect(stripTarget(outline, 50, 52)).toEqual({ start: 46, end: 57 }); + }); + + it('holds one segment each side when one holds the margin of words', () => { + const outline = outlineOf(100, STRIP_MARGIN_WORDS); + + expect(stripTarget(outline, 50, 50)).toEqual({ start: 49, end: 52 }); + }); + + it('reaches past segments without words', () => { + const outline = outlineOf(100, 0); + outline[47] = { ...outline[47], wordTokenCount: STRIP_MARGIN_WORDS }; + + expect(stripTarget(outline, 50, 50).start).toBe(47); + }); + + it('stops at the start of the book', () => { + expect(stripTarget(outlineOf(100, 1), 2, 2).start).toBe(0); + }); + + it('stops at the end of the book', () => { + expect(stripTarget(outlineOf(100, 1), 97, 97).end).toBe(100); + }); + + it('holds nothing of an empty book', () => { + expect(stripTarget([], 0, 0)).toEqual({ start: 0, end: 0 }); + }); +}); + +describe('loadedRun', () => { + const loadedFrom = (start: number, end: number) => (index: number) => + index >= start && index < end; + + it('runs as far as the target each side while segments have loaded', () => { + expect(loadedRun(loadedFrom(0, 100), 50, { start: 40, end: 60 })).toEqual({ + start: 40, + end: 60, + }); + }); + + it('stops before a segment that has not loaded', () => { + expect(loadedRun(loadedFrom(45, 55), 50, { start: 40, end: 60 })).toEqual({ + start: 45, + end: 55, + }); + }); + + it('is empty at an anchor that has not loaded', () => { + expect(loadedRun(loadedFrom(0, 10), 50, { start: 40, end: 60 })).toEqual({ + start: 50, + end: 50, + }); + }); +}); diff --git a/src/__tests__/utils/token-layout.test.ts b/src/__tests__/utils/token-layout.test.ts index 7f958bef0..9b08051f2 100644 --- a/src/__tests__/utils/token-layout.test.ts +++ b/src/__tests__/utils/token-layout.test.ts @@ -7,6 +7,7 @@ import { resolveFocusContext, resolveSlotFocus, groupTokens, + groupBookEdges, buildRenderUnits, NO_SLOT_FOCUS, } from '../../utils/token-layout'; @@ -188,6 +189,84 @@ describe('groupTokens', () => { }); }); +describe('groupBookEdges', () => { + /** Book-order and segment lookups for a book whose segments hold the given word refs. */ + function bookOf(...segments: string[][]) { + const tokenDocOrder = new Map(); + const tokenSegmentMap = new Map(); + segments.forEach((refs, index) => + refs.forEach((ref) => { + tokenDocOrder.set(ref, tokenDocOrder.size); + tokenSegmentMap.set(ref, `seg-${index}`); + }), + ); + return { tokenDocOrder, tokenSegmentMap }; + } + + it('places a word opening the book in its first group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b']); + + expect(groupBookEdges('tok-a', tokenDocOrder, tokenSegmentMap, new Map()).isFirst).toBe(true); + }); + + it('places a word after another outside the first group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b']); + + expect(groupBookEdges('tok-b', tokenDocOrder, tokenSegmentMap, new Map()).isFirst).toBe(false); + }); + + it('places a word closing the book in its last group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b']); + + expect(groupBookEdges('tok-b', tokenDocOrder, tokenSegmentMap, new Map()).isLast).toBe(true); + }); + + it('places a word before another outside the last group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b']); + + expect(groupBookEdges('tok-a', tokenDocOrder, tokenSegmentMap, new Map()).isLast).toBe(false); + }); + + it('places a word phrased with every word before it in the first group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b', 'tok-c']); + const link = makePhraseLink('p1', ['tok-a', 'tok-b']); + const phraseLinkByRef = new Map([ + ['tok-a', link], + ['tok-b', link], + ]); + + expect(groupBookEdges('tok-b', tokenDocOrder, tokenSegmentMap, phraseLinkByRef).isFirst).toBe( + true, + ); + }); + + it('places a word phrased with every word after it in the last group', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a', 'tok-b', 'tok-c']); + const link = makePhraseLink('p1', ['tok-b', 'tok-c']); + const phraseLinkByRef = new Map([ + ['tok-b', link], + ['tok-c', link], + ]); + + expect(groupBookEdges('tok-b', tokenDocOrder, tokenSegmentMap, phraseLinkByRef).isLast).toBe( + true, + ); + }); + + it('keeps a word out of the group of a phrase-mate in another segment', () => { + const { tokenDocOrder, tokenSegmentMap } = bookOf(['tok-a'], ['tok-b']); + const link = makePhraseLink('p1', ['tok-a', 'tok-b']); + const phraseLinkByRef = new Map([ + ['tok-a', link], + ['tok-b', link], + ]); + + expect(groupBookEdges('tok-b', tokenDocOrder, tokenSegmentMap, phraseLinkByRef).isFirst).toBe( + false, + ); + }); +}); + describe('buildRenderUnits', () => { it('produces a leading and trailing slot when there is one group', () => { const tok = makeWordToken('tok-a'); diff --git a/src/__tests__/utils/verse-superscripts.test.ts b/src/__tests__/utils/verse-superscripts.test.ts index d92db7c33..46f4bf988 100644 --- a/src/__tests__/utils/verse-superscripts.test.ts +++ b/src/__tests__/utils/verse-superscripts.test.ts @@ -144,7 +144,9 @@ describe('headings', () => { }); it('labels a heading and still qualifies the verse after it at a chapter transition', () => { - expect(buildVerseStartLabelsByTokenRef(book.segments)).toEqual( + expect( + buildVerseStartLabelsByTokenRef(book.segments, buildVerseStartLabels(book.segments)), + ).toEqual( new Map([ ['GEN 2:0/s1:0', 's1'], ['GEN 2:1:0', '2:1'], @@ -153,3 +155,18 @@ describe('headings', () => { ); }); }); + +describe('labels of part of a book', () => { + const book = makeVerseBook([ + { sid: 'GEN 2:1', text: 'One.' }, + { sid: 'GEN 2:2', text: 'Two.' }, + ]); + + it('qualifies chapters by the whole book, wherever the segments labeled begin', () => { + const labels = buildVerseStartLabels(book.segments); + + expect(buildVerseStartLabelsByTokenRef(book.segments.slice(1), labels)).toEqual( + new Map([['GEN 2:2:0', '2']]), + ); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 07878363b..fc9a52344 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -1,5 +1,5 @@ import { useLocalizedStrings } from '@papi/frontend/react'; -import type { Book, Token } from 'interlinearizer'; +import type { Token } from 'interlinearizer'; import { LocateFixed } from 'lucide-react'; import { Button } from 'platform-bible-react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -10,8 +10,13 @@ import type { PhraseMode } from '../types/phrase-mode'; import type { LinkSlot, TokenGroup } from '../types/token-layout'; import type { ViewOptions } from '../types/view-options'; import { resolvedOrEmpty } from '../utils/localized-strings'; -import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/token-layout'; -import { buildVerseStartLabelsByTokenRef, slotVerseLabel } from '../utils/verse-superscripts'; +import { buildRenderUnits, groupBookEdges, resolveFocusContext } from '../utils/token-layout'; +import { sortByDocOrder } from '../utils/phrase-arc'; +import { + buildVerseStartLabels, + buildVerseStartLabelsByTokenRef, + slotVerseLabel, +} from '../utils/verse-superscripts'; import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; import { LinkLabelProvider, PhraseStripProvider } from './PhraseStripContext'; import { PhraseStrip, LINK_SLOT_TRANSITION_MS, type StripItem } from './PhraseStripParts'; @@ -24,16 +29,19 @@ import { } from '../hooks/usePhraseStripSetup'; import useLatestRef from '../hooks/useLatestRef'; import usePhraseWindow from '../hooks/usePhraseWindow'; +import type { SegmentPages } from '../hooks/useSegmentPages'; +import useStripGroups from '../hooks/useStripGroups'; +import useStripSegments from '../hooks/useStripSegments'; import useStripWheel from '../hooks/useStripWheel'; 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. */ -function clampIndex(index: number, len: number): number { +/** Clamps `index` to `[first, first + len - 1]`, returning `first` when `len` is zero. */ +function clampIndex(index: number, first: number, len: number): number { /* v8 ignore next -- only called when len > 0; guard is a defensive invariant */ - if (len === 0) return 0; - return Math.max(0, Math.min(index, len - 1)); + if (len === 0) return first; + return Math.max(first, Math.min(index, first + len - 1)); } /** @@ -94,14 +102,14 @@ type GroupUnit = { kind: 'group'; /** The phrase group to render. */ group: TokenGroup; - /** Absolute index of this group within the full `phraseGroups` array (not the window slice). */ + /** Index of this group among the strip's numbered groups (not the window slice). */ groupIndex: number; }; /** Props for {@link ContinuousView}. */ type ContinuousViewProps = Readonly<{ - /** The full tokenized book whose tokens are streamed into the strip. */ - book: Book; + /** The book's segments, whose pages the strip renders. */ + pages: SegmentPages; /** Segment id of the phrase being edited, or `undefined` outside edit mode. */ editPhraseSegmentId: string | undefined; /** Word token ref → the verbatim baseline text separating it from the previous word. */ @@ -121,11 +129,11 @@ type ContinuousViewProps = Readonly<{ }>; /** - * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable - * strip. Word tokens belonging to the same phrase are joined into a single `PhraseBox`; arcs are - * drawn between discontiguous boxes that share a phrase. Arrow buttons advance or retreat the view - * 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. + * Renders the tokens of the book's segments as a single flat, horizontally scrollable strip, from a + * run of segments around what it shows. Word tokens belonging to the same phrase are joined into a + * single `PhraseBox`; arcs are drawn between discontiguous boxes that share a phrase. Arrow buttons + * advance or retreat the view 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. * * A focus move centers the group holding the focused token, and the arrows move focus by one group * so scroll and highlight follow the store write; they are disabled when the first or last phrase @@ -134,7 +142,7 @@ type ContinuousViewProps = Readonly<{ * brings it back. */ export default function ContinuousView({ - book, + pages, editPhraseSegmentId, gapTextByWordRef, phraseMode, @@ -155,63 +163,65 @@ export default function ContinuousView({ const [localizedStrings] = useLocalizedStrings(STRING_KEYS); - const allTokens: Token[] = useMemo( - () => book.segments.flatMap((seg) => seg.tokens), - [book.segments], - ); - /** - * Verse-start token ref → verse label, over the whole book. Resolved exactly as the segment - * list's labels are, so chapter qualification and the verse-start token resolution match. The - * strip builder marks the slot that begins each verse with its label, and {@link PhraseSlot} - * renders it below the link icon — this is what gives the continuous strip its inline verse - * numbers. + * 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, and until the page holding it has loaded. For its own arrow/click moves + * this is updated immediately so the smooth scroll starts on the same frame. */ - const verseStartLabelByTokenRef = useMemo( - () => buildVerseStartLabelsByTokenRef(book.segments), - [book.segments], + const [displayFocusedTokenRef, setDisplayFocusedTokenRef] = useState( + focusedTokenRef, ); + const { segments, hasPage, setMounted, isFocusLeftBehind, holdFocus } = useStripSegments({ + pages, + focusTokenRef: displayFocusedTokenRef, + nextFocusTokenRef: focusedTokenRef, + tokenSegmentMap, + }); + const committedPhraseLinkByRef = usePhraseLinkMap(); const committedPhraseLinkById = usePhraseLinkByIdMap(); const editPhraseTokens = useEditPhraseTokens(phraseMode); - /** Phrase groups built from the flat token list, respecting the committed phrase-link map. */ - const phraseGroups = useMemo( - () => groupTokens(allTokens, committedPhraseLinkByRef), - [allTokens, committedPhraseLinkByRef], + const { + tokens: allTokens, + groups: phraseGroups, + first: firstGroupIndex, + indexByTokenRef: groupIndexByTokenRef, + at: groupAt, + tokenIndexOf, + } = useStripGroups(segments, committedPhraseLinkByRef); + const lastGroupIndex = firstGroupIndex + phraseGroups.length - 1; + + const verseLabelsBySegmentId = useMemo( + () => buildVerseStartLabels(pages.outline), + [pages.outline], ); - /** Maps each word token ref to the group index that contains it. */ - const groupIndexByTokenRef = useMemo(() => { - const map = new Map(); - phraseGroups.forEach((g, gi) => { - g.tokens.forEach((t) => map.set(t.ref, gi)); - }); - return map; - }, [phraseGroups]); - /** - * 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. + * Verse-start token ref → verse label. Chapters are qualified over the whole outline, exactly as + * the segment list's labels are. The strip builder marks the slot that begins each verse with its + * label, and {@link PhraseSlot} renders it below the link icon — this is what gives the continuous + * strip its inline verse numbers. */ - const [displayFocusedTokenRef, setDisplayFocusedTokenRef] = useState( - focusedTokenRef, + const verseStartLabelByTokenRef = useMemo( + () => buildVerseStartLabelsByTokenRef(segments, verseLabelsBySegmentId), + [segments, verseLabelsBySegmentId], ); /** - * Group index of the displayed focused token, or `0` when nothing is focused. Single source of - * truth for scroll position, windowing, arrow disabled state, and per-group focus highlighting. + * Group index of the displayed focused token, or the first group's when nothing is focused or the + * strip has been scrolled far enough to leave the focus behind. Single source of truth for scroll + * position, windowing, arrow disabled state, and per-group focus highlighting. * * During a book change `displayFocusedTokenRef` lags the new book by one fade (it only catches up * when the fade timeout fires), so for a few frames it names a token from the previous book that - * no longer exists in this book's `groupIndexByTokenRef`. Falling straight back to `0` then parks - * the strip on the new book's very first phrase instead of the verse the user navigated to. Fall - * back to the live 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. + * no longer exists in this book's `groupIndexByTokenRef`. Falling straight back to the first + * group 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 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 = @@ -219,8 +229,16 @@ export default function ContinuousView({ ? groupIndexByTokenRef.get(displayFocusedTokenRef) : undefined) ?? (focusedTokenRef !== undefined ? groupIndexByTokenRef.get(focusedTokenRef) : undefined); - return resolved === undefined ? 0 : clampIndex(resolved, phraseGroups.length); - }, [displayFocusedTokenRef, focusedTokenRef, groupIndexByTokenRef, phraseGroups.length]); + return resolved === undefined + ? firstGroupIndex + : clampIndex(resolved, firstGroupIndex, phraseGroups.length); + }, [ + displayFocusedTokenRef, + focusedTokenRef, + groupIndexByTokenRef, + firstGroupIndex, + phraseGroups.length, + ]); const [isVisible, setIsVisible] = useState(false); @@ -251,49 +269,53 @@ export default function ContinuousView({ */ 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)[]>([]); + /** Mounted group wrappers by group index; used to scroll the focused phrase box into view. */ + const phraseRefs = useRef(new Map()); /** Ref-setter callbacks for {@link phraseRefs}, keyed by the group index each one writes. */ const groupRefSetters = useRef(new Map void>()); /** - * Book that {@link groupRefSetters} holds closures for. They are keyed by absolute group index, - * which a different book reuses for different groups, and the component instance survives a book - * change — so without dropping them here the map would keep growing to the largest book ever - * opened. Cleared during render rather than in an effect: the new book's groups take their - * setters during the render that precedes the effect, and clearing afterward would drop the - * identities those groups are already holding. + * Group indexes {@link groupRefSetters} was last pruned to. Setters of groups the strip no longer + * holds are dropped, or the map would keep growing for as long as the strip is scrolled. Pruned + * during render rather than in an effect: groups take their setters during the render that + * precedes the effect, and pruning afterward could drop identities they are already holding. * - * Only the map is cleared. {@link phraseRefs} is written by ref callbacks, so every unmounting - * group nulls its own index — and a discarded render (an interrupted concurrent render, a - * StrictMode double-invoke) would advance the book id here without repeating the clear, leaving - * the still-mounted old DOM with no refs and every centering call a silent no-op. A discarded - * render costs the map nothing worse than one extra render of the memoized groups. + * Only the map is pruned. {@link phraseRefs} is written by ref callbacks, so every unmounting + * group removes its own index — and a discarded render (an interrupted concurrent render, a + * StrictMode double-invoke) would move the bounds here without repeating the prune, leaving the + * still-mounted old DOM with no refs and every centering call a silent no-op. A discarded render + * costs the map nothing worse than one extra render of the memoized groups. */ - const refsBookIdRef = useRef(book.id); - if (refsBookIdRef.current !== book.id) { - refsBookIdRef.current = book.id; - groupRefSetters.current.clear(); + const prunedBoundsRef = useRef({ first: firstGroupIndex, last: lastGroupIndex }); + if ( + prunedBoundsRef.current.first !== firstGroupIndex || + prunedBoundsRef.current.last !== lastGroupIndex + ) { + prunedBoundsRef.current = { first: firstGroupIndex, last: lastGroupIndex }; + groupRefSetters.current.forEach((_setter, index) => { + if (index < firstGroupIndex || index > lastGroupIndex) groupRefSetters.current.delete(index); + }); } /** * Returns the callback that records a group's wrapper element under `groupIndex`. Each index - * keeps one identity for as long as the strip shows this book, so handing the callback down + * keeps one identity for as long as the strip holds its group, so handing the callback down * cannot invalidate a memoized child on a render that changed nothing else about it. * * One identity per index is safe only because React detaches refs in the mutation phase and * attaches them in the layout phase, for the whole commit rather than per element: a group moving - * into an index another group has just vacated cannot write `null` over the newer element, - * because every detach has already happened by the time any attach runs. Moving these writes into - * a layout effect would give up that ordering guarantee. + * into an index another group has just vacated cannot remove the newer element, because every + * detach has already happened by the time any attach runs. Moving these writes into a layout + * effect would give up that ordering guarantee. */ const getGroupRefSetter = useCallback((groupIndex: number) => { const setters = groupRefSetters.current; let setter = setters.get(groupIndex); if (!setter) { setter = (el: HTMLSpanElement | null) => { - phraseRefs.current[groupIndex] = el; + if (el) phraseRefs.current.set(groupIndex, el); + else phraseRefs.current.delete(groupIndex); }; setters.set(groupIndex, setter); } @@ -317,7 +339,7 @@ export default function ContinuousView({ * explicitly) so the effects that center a snapshot index keep their intentionally-narrow dep * arrays. * - * @param groupIndex - Index into `phraseRefs` of the group to center. + * @param groupIndex - Index in `phraseRefs` of the group to center. * @param behavior - `'auto'` for an instant jump, `'smooth'` for an animated glide. */ const centerGroup = useCallback((groupIndex: number, behavior: ScrollBehavior) => { @@ -327,7 +349,7 @@ export default function ContinuousView({ // cover paths whose own effect has no reason to know about the reader's scroll. /* v8 ignore next -- the fight this prevents needs real layout to shift the focused index, which jsdom does not do */ if (suppressCenteringRef.current) return; - phraseRefs.current[groupIndex]?.scrollIntoView({ + phraseRefs.current.get(groupIndex)?.scrollIntoView({ behavior, block: 'nearest', inline: 'center', @@ -500,19 +522,33 @@ export default function ContinuousView({ commitPendingActiveSegment(); }, [tokenSegmentMap, focusedTokenRef, commitPendingActiveSegment]); - // 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. + // Name the initially-focused token once the strip first holds a group, so the segment list + // scrolls the active verse into view. Only fires when nothing has resolved a focus already. + const hasGroups = phraseGroups.length > 0; + const hasSeededRef = useRef(false); useEffect(() => { - if (focusedTokenRef !== undefined) return; - const initialGroup = phraseGroups[focusPhraseIndex]; - const initialRef = initialGroup?.tokens[0]?.ref; + if (hasSeededRef.current || !hasGroups) return; + hasSeededRef.current = true; + if (getFocus().tokenRef !== undefined) return; + const initialRef = groupAt(focusPhraseIndex)?.tokens[0].ref; 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; + }, [hasGroups, getFocus, groupAt, focusPhraseIndex, focusToken]); + + // The strip holds the groups either side of a focus it holds, so a focus on the first or last group + // held is at the book's edge, or waiting on the page beyond it. + const focusBookEdges = + isFocusLeftBehind && displayFocusedTokenRef !== undefined + ? groupBookEdges( + displayFocusedTokenRef, + tokenDocOrder, + tokenSegmentMap, + committedPhraseLinkByRef, + ) + : undefined; + const atStart = + focusBookEdges?.isFirst ?? (phraseGroups.length === 0 || focusPhraseIndex === firstGroupIndex); + const atEnd = + focusBookEdges?.isLast ?? (phraseGroups.length === 0 || focusPhraseIndex >= lastGroupIndex); /** * Whether a step would count from a position the reader cannot see: the strip is mid-jump, @@ -545,6 +581,7 @@ export default function ContinuousView({ trailingSentinelRef, recenterOnFocus, } = usePhraseWindow({ + first: firstGroupIndex, total: phraseGroups.length, focusIndex: focusPhraseIndex, // Kept in step with the index above, so a phrase link that renumbers the groups ahead of the @@ -560,15 +597,16 @@ export default function ContinuousView({ */ const groupSpanByPhraseId = useMemo(() => { const spans = new Map(); - phraseGroups.forEach((group, index) => { + phraseGroups.forEach((group, local) => { const phraseId = group.phraseLink?.id; if (phraseId === undefined) return; + const index = firstGroupIndex + local; const span = spans.get(phraseId); if (span) span.last = index; else spans.set(phraseId, { first: index, last: index }); }); return spans; - }, [phraseGroups]); + }, [phraseGroups, firstGroupIndex]); /** * The inclusive group-index bounds of the rendered window, widened to cover every fragment of any @@ -581,8 +619,8 @@ export default function ContinuousView({ * geometry, so it stays free of the analysis model. */ const [renderWindowStart, renderWindowEnd] = useMemo(() => { - let start = Math.max(0, scrollWindowRange.start); - let end = Math.min(phraseGroups.length - 1, scrollWindowRange.end - 1); + let start = Math.max(firstGroupIndex, scrollWindowRange.start); + let end = Math.min(lastGroupIndex, scrollWindowRange.end - 1); // Re-scanning after a widening catches a phrase pulled in by the previous one. The bounds only // ever widen, so the loop terminates, and every token of a phrase comes from one segment, so it // cannot run away. @@ -590,7 +628,7 @@ export default function ContinuousView({ while (widened) { widened = false; for (let index = start; index <= end; index += 1) { - const phraseId = phraseGroups[index].phraseLink?.id; + const phraseId = groupAt(index)?.phraseLink?.id; const span = phraseId === undefined ? undefined : groupSpanByPhraseId.get(phraseId); if (span !== undefined && (span.first < start || span.last > end)) { start = Math.min(start, span.first); @@ -600,7 +638,7 @@ export default function ContinuousView({ } } return [start, end]; - }, [scrollWindowRange, phraseGroups, groupSpanByPhraseId]); + }, [scrollWindowRange, firstGroupIndex, lastGroupIndex, groupAt, groupSpanByPhraseId]); // Published during render, not from an effect: a wheel or sentinel delivery can land before this // render's effects run and would place the groups against stale bounds. @@ -614,23 +652,43 @@ export default function ContinuousView({ * loop-damping (which keys off whether a real input changed). */ const renderWindowGroups = useMemo( - () => phraseGroups.slice(renderWindowStart, renderWindowEnd + 1), - [phraseGroups, renderWindowStart, renderWindowEnd], + () => + phraseGroups.slice( + renderWindowStart - firstGroupIndex, + renderWindowEnd - firstGroupIndex + 1, + ), + [phraseGroups, firstGroupIndex, renderWindowStart, renderWindowEnd], ); + useLayoutEffect(() => { + const firstRef = renderWindowGroups[0]?.tokens[0].ref; + const lastRef = renderWindowGroups.at(-1)?.tokens.at(-1)?.ref; + if (firstRef !== undefined && lastRef !== undefined) setMounted(firstRef, lastRef); + }, [renderWindowGroups, setMounted]); + /** * The flat token-index range spanned by the mounted render-window groups, used to slice * `allTokens` for rendering punctuation tokens that appear between phrase groups. */ const renderWindowStartTokenIndex = - phraseGroups.length > 0 && renderWindowStart > 0 - ? phraseGroups[renderWindowStart].firstIndex + phraseGroups.length > 0 && renderWindowStart > firstGroupIndex + ? tokenIndexOf(renderWindowStart) : 0; const renderWindowEndTokenIndex = - phraseGroups.length > 0 && renderWindowEnd < phraseGroups.length - 1 - ? phraseGroups[renderWindowEnd + 1].firstIndex + phraseGroups.length > 0 && renderWindowEnd < lastGroupIndex + ? tokenIndexOf(renderWindowEnd + 1) : allTokens.length; + /** Re-holds a focus that scrolling left behind, and rebuilds the window around it. */ + const bringBackFocus = useCallback(() => { + suppressCenteringRef.current = false; + holdFocus(); + recenterOnFocus(); + }, [holdFocus, recenterOnFocus]); + + /** A step asked for while the focus was left behind, taken once the strip holds it again. */ + const pendingStepRef = useRef(undefined); + /** * Advances focus by `delta` phrases, which re-derives `focusPhraseIndex` and triggers the scroll * effect. @@ -644,6 +702,11 @@ export default function ContinuousView({ */ const step = useCallback( (delta: number) => { + if (isFocusLeftBehind) { + pendingStepRef.current = delta; + bringBackFocus(); + return; + } /* v8 ignore next -- arrow buttons are disabled when phraseGroups is empty */ if (phraseGroups.length === 0) return; const currentRef = getFocus().tokenRef; @@ -653,20 +716,42 @@ export default function ContinuousView({ 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); + const clamped = clampIndex(nextIndex, firstGroupIndex, phraseGroups.length); /* v8 ignore next -- disabled buttons prevent clicking when already at boundary */ if (clamped === from) return; - const nextRef = phraseGroups[clamped]?.tokens[0]?.ref; + const nextRef = groupAt(clamped)?.tokens[0]?.ref; if (nextRef !== undefined) focusToken(nextRef, 'strip'); }, - [phraseGroups, groupIndexByTokenRef, getFocus, focusToken, focusPhraseIndexRef], + [ + isFocusLeftBehind, + bringBackFocus, + phraseGroups.length, + firstGroupIndex, + groupAt, + groupIndexByTokenRef, + getFocus, + focusToken, + focusPhraseIndexRef, + ], ); + const stepRef = useLatestRef(step); + useEffect(() => { + const delta = pendingStepRef.current; + if (isFocusLeftBehind || delta === undefined) return; + pendingStepRef.current = undefined; + stepRef.current(delta); + }, [isFocusLeftBehind, stepRef]); + /** * Brings the strip back to the focused phrase after scrolling has carried it away, leaving the * focus itself where it is. */ const returnToFocus = useCallback(() => { + if (isFocusLeftBehind) { + bringBackFocus(); + return; + } // Asking for the focus back is the reader handing the scroll over, so centering resumes. suppressCenteringRef.current = false; // Rebuild before centering: scrolling may have culled the focused group, leaving nothing on @@ -674,7 +759,14 @@ export default function ContinuousView({ recenterOnFocus(); centerGroup(focusPhraseIndex, 'auto'); holdCentered(focusPhraseIndex); - }, [recenterOnFocus, centerGroup, holdCentered, focusPhraseIndex]); + }, [ + isFocusLeftBehind, + bringBackFocus, + recenterOnFocus, + centerGroup, + holdCentered, + focusPhraseIndex, + ]); /** Moves focus one phrase backward. */ const stepPrev = useCallback(() => step(-1), [step]); @@ -735,11 +827,17 @@ export default function ContinuousView({ */ const fadeTimeoutRef = useRef | undefined>(undefined); + /** A focus the strip has faded out for but cannot show until the page holding it loads. */ + const [awaitedFocusRef, setAwaitedFocusRef] = useState(undefined); + const cancelPendingFade = useCallback(() => { clearTimeout(fadeTimeoutRef.current); fadeTimeoutRef.current = undefined; + setAwaitedFocusRef(undefined); }, []); + const hasPageRef = useLatestRef(hasPage); + // 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. @@ -776,7 +874,9 @@ export default function ContinuousView({ // The clear at the move itself lands a whole fade early, and trailing momentum arrives // through that gap, so the scroll is taken back again here rather than there. suppressCenteringRef.current = false; - setDisplayFocusedTokenRef(focusedTokenRef); + if (focusedTokenRef === undefined || hasPageRef.current(focusedTokenRef)) + setDisplayFocusedTokenRef(focusedTokenRef); + else setAwaitedFocusRef(focusedTokenRef); }, RECENTER_FADE_MS); // 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 @@ -784,6 +884,13 @@ export default function ContinuousView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusedTokenRef, displayFocusedTokenRef, cancelPendingFade]); + const isAwaitedFocusLoaded = awaitedFocusRef !== undefined && hasPage(awaitedFocusRef); + useEffect(() => { + if (!isAwaitedFocusLoaded) return; + setAwaitedFocusRef(undefined); + setDisplayFocusedTokenRef(awaitedFocusRef); + }, [isAwaitedFocusLoaded, awaitedFocusRef]); + // Clear a pending fade on unmount so its deferred update never lands on a torn-down tree. useEffect( () => () => { @@ -800,6 +907,8 @@ export default function ContinuousView({ // different question — whether any view in the tree originated the nav — so the two classify the // same event differently by design. See FocusOrigin. useEffect(() => { + // A focus left behind has no group to scroll to; holding it again runs this to center it. + if (isFocusLeftBehind) return undefined; // 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 // watching the content row long after it, so the window slide this move causes would restart @@ -897,7 +1006,7 @@ export default function ContinuousView({ cancelHold(); setIsVisible(true); }; - }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup, holdCentered]); + }, [isFocusLeftBehind, focusPhraseIndex, commitPendingActiveSegment, centerGroup, holdCentered]); // Keep the focused group pinned dead-center after the deferred active-segment flip. When // `committedActiveSegmentId` flips (after an internal-nav scroll settles), inactive link icons @@ -986,16 +1095,21 @@ export default function ContinuousView({ }, [renderWindowStart, focusPhraseIndex]); // When entering edit or confirm-unlink mode, smooth-scroll to the first group of the active - // phrase by focusing its first token. Scroll then follows through focusPhraseIndex. + // phrase by focusing its first token. Scroll then follows through focusPhraseIndex. A phrase the + // strip does not hold was entered from the list, so the strip jumps to it as it would for any + // list focus. useEffect(() => { if (phraseMode.kind === 'view') return; const targetPhraseId = phraseMode.phraseId; const group = phraseGroups.find((g) => g.phraseLink?.id === targetPhraseId); - const nextRef = group?.tokens[0]?.ref; + const link = group ? undefined : committedPhraseLinkById.get(targetPhraseId); + const nextRef = link + ? sortByDocOrder(link.tokens, tokenDocOrder)[0]?.tokenRef + : group?.tokens[0]?.ref; /* v8 ignore next -- phrase always has tokens; the focus differs at mode entry */ if (nextRef === undefined || nextRef === focusedTokenRef) return; - focusToken(nextRef, 'strip'); - // phraseGroups and the focus are read once per mode change; intentionally not deps so the + focusToken(nextRef, link ? 'list' : 'strip'); + // The phrases 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]); @@ -1180,8 +1294,8 @@ export default function ContinuousView({ /** * Normalized strip items handed to the shared {@link PhraseStrip} body. Each slot's segment ids - * are resolved from the absolute group indices, and each group carries the scroll-into-view ref - * by its absolute group index. + * are resolved from its neighboring groups, and each group carries the scroll-into-view ref by + * its group index. */ const stripItems = useMemo( () => @@ -1189,14 +1303,8 @@ export default function ContinuousView({ if (item.kind === 'slot') { const { prevGroup, nextGroup } = item.slot; const key = `slot-${prevGroup?.tokens[prevGroup.tokens.length - 1]?.ref ?? 'start'}-${nextGroup?.tokens[0]?.ref ?? 'end'}`; - const prevSegmentId = - item.prevGroupIndex !== undefined && phraseGroups[item.prevGroupIndex] !== undefined - ? tokenSegmentMap.get(phraseGroups[item.prevGroupIndex].tokens[0].ref) - : undefined; - const nextSegmentId = - item.nextGroupIndex !== undefined && phraseGroups[item.nextGroupIndex] !== undefined - ? tokenSegmentMap.get(phraseGroups[item.nextGroupIndex].tokens[0].ref) - : undefined; + const prevSegmentId = prevGroup && tokenSegmentMap.get(prevGroup.tokens[0].ref); + const nextSegmentId = nextGroup && tokenSegmentMap.get(nextGroup.tokens[0].ref); return { kind: 'slot', key, @@ -1218,7 +1326,6 @@ export default function ContinuousView({ }), [ renderItems, - phraseGroups, tokenSegmentMap, focusedSideIsPrevByItem, displayFocusedTokenRef, diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 088a05362..d26639ae9 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -266,7 +266,7 @@ export default function Interlinearizer({ {displayContinuousScroll && (
findAnchorIndex(outline, scrRef), [outline, scrRef]); useEffect( - () => load(anchorIndex - EXTEND_CHUNK, anchorIndex + EXTEND_CHUNK + 1), + () => load(anchorIndex - EXTEND_CHUNK, anchorIndex + EXTEND_CHUNK + 1, 'list'), [load, anchorIndex], ); // Pages load a chunk beyond each edge, so an extend finds its segments already there. Asked for // after the anchor's, so the window's range is the one the next outline loads again. useEffect( - () => load(range.start - EXTEND_CHUNK, range.end + EXTEND_CHUNK), + () => load(range.start - EXTEND_CHUNK, range.end + EXTEND_CHUNK, 'list'), [load, range.start, range.end], ); diff --git a/src/hooks/usePhraseWindow.ts b/src/hooks/usePhraseWindow.ts index 5b89f7dab..685b5cd82 100644 --- a/src/hooks/usePhraseWindow.ts +++ b/src/hooks/usePhraseWindow.ts @@ -44,7 +44,9 @@ export type WindowRange = Readonly<{ start: number; end: number }>; /** Arguments for {@link usePhraseWindow}. */ export interface UsePhraseWindowArgs { - /** How many phrase groups the whole book has; the window never runs past it. */ + /** Index of the first phrase group there is to mount; `0` when omitted. */ + first?: number; + /** How many phrase groups there are to mount, from {@link first}; the window never runs past them. */ total: number; /** Index of the focused phrase group, used as the anchor whenever the window recenters. */ focusIndex: number; @@ -75,16 +77,18 @@ export interface UsePhraseWindowResult { trailingSentinelRef: (el: HTMLElement | null) => void; /** * Rebuilds the window centered on the focused group, which a scroll may have culled — leaving - * nothing for a plain scroll-into-view to find. + * nothing for a plain scroll-into-view to find. Centers on the focus and groups of the next + * render, so groups changing in the same update are centered against. */ recenterOnFocus: () => void; } -/** Builds the half-open window range centered on an anchor group, clamped to the book. */ -function buildCenteredRange(anchorIndex: number, total: number): WindowRange { - const start = Math.max(0, anchorIndex - INITIAL_WINDOW_HALF); - const end = Math.min(total, anchorIndex + INITIAL_WINDOW_HALF + 1); - return { start, end }; +/** Builds the half-open window range centered on an anchor group, clamped to `[first, end)`. */ +function buildCenteredRange(anchorIndex: number, first: number, end: number): WindowRange { + return { + start: Math.max(first, anchorIndex - INITIAL_WINDOW_HALF), + end: Math.min(end, anchorIndex + INITIAL_WINDOW_HALF + 1), + }; } /** @@ -100,13 +104,17 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange { * {@link UsePhraseWindowArgs.focusKey}, not by index. */ export default function usePhraseWindow({ + first = 0, total, focusIndex, focusKey, viewportRef, renderedStartRef, }: UsePhraseWindowArgs): UsePhraseWindowResult { - const [range, setRange] = useState(() => buildCenteredRange(focusIndex, total)); + const last = first + total; + const [range, setRange] = useState(() => + buildCenteredRange(focusIndex, first, last), + ); /** * Scroll anchor owed to the next paint after an extend mutates the window: a group that survives @@ -120,8 +128,7 @@ export default function usePhraseWindow({ const pendingExtendAnchorRef = useRef<{ el: Element; left: number } | undefined>(undefined); const rangeRef = useLatestRef(range); - const totalRef = useLatestRef(total); - const focusIndexRef = useLatestRef(focusIndex); + const boundsRef = useLatestRef({ first, last }); /** * The focus the window last reconciled against. Held in state because it is written beside the @@ -129,6 +136,7 @@ export default function usePhraseWindow({ * against a range that never committed would report the window rebuilt when it was not. */ const [reconciledFocus, setReconciledFocus] = useState(focusKey ?? focusIndex); + const [isRecenterRequested, setIsRecenterRequested] = useState(false); // Rebuilt during the render that carries the new focus, so the destination mounts in the same // commit and the centering effects that run after it find it. An effect would paint one frame of @@ -137,20 +145,28 @@ export default function usePhraseWindow({ // Keyed on identity, not index: rebuilding on a renumbered focus would drag a freely-scrolled // strip back to a phrase the reader had deliberately left. const currentFocus = focusKey ?? focusIndex; + let nextRange = range; if (reconciledFocus !== currentFocus) { setReconciledFocus(currentFocus); if (focusIndex < range.start || focusIndex >= range.end) { - setRange(buildCenteredRange(focusIndex, total)); + nextRange = buildCenteredRange(focusIndex, first, last); } } + if (isRecenterRequested) { + setIsRecenterRequested(false); + nextRange = buildCenteredRange(focusIndex, first, last); + } - // A book that shrinks under a window scrolled clear of the focus can strand it past the last - // group, which the reconciliation above does not cover: the focus index is clamped to the book, + // Groups that shrink under a window scrolled clear of the focus can strand it past the last of + // them, which the reconciliation above does not cover: the focus index is clamped to the groups, // so a shrink behind it moves nothing for that guard to notice. The slice is then empty, and an // empty window cannot refill itself, having no mounted group left to measure an extend against. - if (total > 0 && range.start >= total) { - setRange(buildCenteredRange(focusIndex, total)); + if (total > 0 && (nextRange.start >= last || nextRange.end <= first)) { + nextRange = buildCenteredRange(focusIndex, first, last); + } else if (total > 0 && (nextRange.start < first || nextRange.end > last)) { + nextRange = { start: Math.max(first, nextRange.start), end: Math.min(last, nextRange.end) }; } + if (nextRange !== range) setRange(nextRange); /** * Extends the window by up to {@link EXTEND_CHUNK} groups at one edge, culling from the opposite @@ -170,8 +186,8 @@ export default function usePhraseWindow({ const extend = useCallback( (edge: 'leading' | 'trailing') => { const { start, end } = rangeRef.current; - const currentTotal = totalRef.current; - if (edge === 'leading' ? start === 0 : end >= currentTotal) return; + const bounds = boundsRef.current; + if (edge === 'leading' ? start <= bounds.first : end >= bounds.last) return; const viewport = viewportRef.current; /* v8 ignore next -- extend is only reachable through the sentinel observer, which requires the viewport */ if (!viewport) return; @@ -234,14 +250,14 @@ export default function usePhraseWindow({ } const next = edge === 'leading' - ? { start: Math.max(0, start - grow), end: end - cullable } - : { start: start + cullable, end: Math.min(currentTotal, end + grow) }; + ? { start: Math.max(bounds.first, start - grow), end: end - cullable } + : { start: start + cullable, end: Math.min(bounds.last, end + grow) }; // Published to the ref as well as to state, which only a render would refresh, so a second // call before that render reads these bounds rather than the ones they replaced. rangeRef.current = next; setRange(next); }, - [viewportRef, rangeRef, totalRef, renderedStartRef], + [viewportRef, rangeRef, boundsRef, renderedStartRef], ); // Reconcile the scroll position to the freshly-mounted range before the browser paints, so an @@ -259,9 +275,7 @@ export default function usePhraseWindow({ if (delta !== 0) viewport.scrollLeft += delta; }, [range, viewportRef]); - const recenterOnFocus = useCallback(() => { - setRange(buildCenteredRange(focusIndexRef.current, totalRef.current)); - }, [focusIndexRef, totalRef]); + const recenterOnFocus = useCallback(() => setIsRecenterRequested(true), []); // The mounted sentinel elements, held in state so the observer effect re-runs once they attach. // Ref callbacks only record the node; the observe happens in the effect below, which runs after @@ -286,8 +300,9 @@ export default function usePhraseWindow({ // the sentinel nodes are unchanged and may still sit inside the arming margin, so a stale observer // would stay silent however far the strip is scrolled. A fresh observer re-delivers the initial // state, extending one chunk per delivery until the sentinel leaves the margin. The loop - // terminates because a delivery that cannot grow — the book edge or the hard cap — leaves the - // range untouched, and no range change means no re-subscription to deliver again. + // terminates because a delivery that cannot grow — the last group or the hard cap — leaves the + // range untouched, and no range change means no re-subscription to deliver again. Groups arriving + // past an edge the window stopped at re-subscribe it too, since nothing else would. useEffect(() => { const root = viewportRef.current; if (!root || (!leadingSentinel && !trailingSentinel)) return undefined; @@ -308,7 +323,7 @@ export default function usePhraseWindow({ if (leadingSentinel) observer.observe(leadingSentinel); if (trailingSentinel) observer.observe(trailingSentinel); return () => observer.disconnect(); - }, [viewportRef, leadingSentinel, trailingSentinel, range, extendRef]); + }, [viewportRef, leadingSentinel, trailingSentinel, range, first, last, extendRef]); return { range, leadingSentinelRef, trailingSentinelRef, recenterOnFocus }; } diff --git a/src/hooks/useSegmentPages.ts b/src/hooks/useSegmentPages.ts index e764bf72d..cbc330aa2 100644 --- a/src/hooks/useSegmentPages.ts +++ b/src/hooks/useSegmentPages.ts @@ -16,6 +16,9 @@ export const PAGE_SIZE = 32; */ export const PAGE_RETRY_DELAYS_MS: readonly number[] = [1000, 4000, 16000]; +/** A view whose latest page range is kept loaded across outlines. */ +export type PageCaller = 'list' | 'strip'; + function wait(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -36,10 +39,12 @@ export interface SegmentPages { segmentAt: (index: number) => Segment | undefined; /** * Asks for the segments at outline indexes `[start, end)`, clamped to the outline. The latest - * range asked for loads again with the outline any later boundaries give. A page that fails to - * load is retried, after {@link PAGE_RETRY_DELAYS_MS}, while anything is subscribed. + * range that each `caller` asked for is reloaded whenever a new outline loads. A page that fails + * to load is retried, after {@link PAGE_RETRY_DELAYS_MS}, while anything is subscribed. */ - load: (start: number, end: number) => void; + load: (start: number, end: number, caller: PageCaller) => void; + /** Drops the range that `caller` last asked for, so that later outlines do not reload it. */ + release: (caller: PageCaller) => void; /** Calls `onChange` whenever pages load, until the returned function is called. */ subscribe: (onChange: () => void) => () => void; /** A number that changes whenever pages load. */ @@ -157,11 +162,13 @@ type SegmentPagesSource = PageSource & loaded: ReadonlyMap; onTextChanged: () => void; /** Called with each range asked for. */ - onLoad: (start: number, end: number) => void; + onLoad: (start: number, end: number, caller: PageCaller) => void; + /** Called with each caller released. */ + onRelease: (caller: PageCaller) => void; }>; function createSegmentPages(source: SegmentPagesSource): SegmentPages { - const { outline, segmentationVersion, loaded, onTextChanged, onLoad } = source; + const { outline, segmentationVersion, loaded, onTextChanged, onLoad, onRelease } = source; const pages = new Map(loaded); const inFlight = new Set(); const listeners = new Set<() => void>(); @@ -196,14 +203,15 @@ function createSegmentPages(source: SegmentPagesSource): SegmentPages { outline: outline.segments, segmentationVersion, segmentAt: (index) => pages.get(Math.floor(index / PAGE_SIZE))?.[index % PAGE_SIZE], - load: (start, end) => { - onLoad(start, end); + load: (start, end, caller) => { + onLoad(start, end, caller); const missing = pagesCovering(start, end, outline.segments.length).filter( (pageIndex) => !pages.has(pageIndex) && !inFlight.has(pageIndex), ); missing.forEach((pageIndex) => inFlight.add(pageIndex)); runsOf(missing).forEach((run) => loadRun(run)); }, + release: onRelease, subscribe: (onChange) => { listeners.add(onChange); return () => listeners.delete(onChange); @@ -215,7 +223,8 @@ function createSegmentPages(source: SegmentPagesSource): SegmentPages { /** * Serves the segment list of the loaded book from the host: its outline, segmented by the given * boundaries, and the pages of segments asked for. A new outline is published only with the pages - * around the verse the view opens on, and around the segments last asked for, already loaded. + * around the verse the view opens on, and around the segments each caller last asked for, already + * loaded. */ export default function useSegmentPages({ projectId, @@ -235,9 +244,9 @@ export default function useSegmentPages({ const segmentationVersionRef = useLatestRef(segmentationVersion); const scrRefRef = useLatestRef(scrRef); const onTextChangedRef = useLatestRef(onTextChanged); - /** The range last asked for, at the indexes of the outline it was asked of. */ - const lastRangeRef = useRef<{ outline: BookOutline; start: number; end: number } | undefined>( - undefined, + /** The range each caller last asked for, at the indexes of the outline it was asked of. */ + const lastRangesRef = useRef( + new Map(), ); useEffect(() => { @@ -268,10 +277,12 @@ export default function useSegmentPages({ const anchor = findAnchorIndex(outline.segments, scrRefRef.current); const reach = INITIAL_WINDOW_HALF + EXTEND_CHUNK; const pageIndexes = new Set(pagesCovering(anchor - reach, anchor + reach + 1, count)); - // A boundary edit moves the segments after it by at most one place. - const last = lastRangeRef.current; - if (last?.outline.bookRef === bookId && last.outline.textVersion === outline.textVersion) - pagesCovering(last.start - 1, last.end + 1, count).forEach((p) => pageIndexes.add(p)); + // A boundary edit moves the segments after it by at most one place, and a Scripture edit + // seldom by more, so the range shown before either is about where it falls now. + lastRangesRef.current.forEach((last) => { + if (last.outline.bookRef === bookId) + pagesCovering(last.start - 1, last.end + 1, count).forEach((p) => pageIndexes.add(p)); + }); const source = { projectId, outline, segmentationJson }; const loaded = await fetchPages( source, @@ -287,8 +298,11 @@ export default function useSegmentPages({ segmentationVersion: requestedVersion, loaded, onTextChanged: () => onTextChangedRef.current(), - onLoad: (start, end) => { - lastRangeRef.current = { outline, start, end }; + onLoad: (start, end, caller) => { + lastRangesRef.current.set(caller, { outline, start, end }); + }, + onRelease: (caller) => { + lastRangesRef.current.delete(caller); }, }); setPublished({ verseBook, pages }); diff --git a/src/hooks/useStripGroups.ts b/src/hooks/useStripGroups.ts new file mode 100644 index 000000000..c9b785afd --- /dev/null +++ b/src/hooks/useStripGroups.ts @@ -0,0 +1,109 @@ +import type { PhraseAnalysisLink, Segment, Token } from 'interlinearizer'; +import { useMemo, useState } from 'react'; +import type { TokenGroup } from '../types/token-layout'; +import { groupTokens } from '../utils/token-layout'; + +/** + * The continuous strip's phrase groups, numbered so that changing which segments are held does not + * renumber groups that stay held. + */ +export interface StripGroups { + /** Every token of the segments, in book order. */ + tokens: readonly Token[]; + /** The phrase groups of the segments, in book order. */ + groups: readonly TokenGroup[]; + /** Index of `groups[0]`. */ + first: number; + /** Word token ref → index of the group holding it. */ + indexByTokenRef: ReadonlyMap; + /** The group at `index`, if held. */ + at: (index: number) => TokenGroup | undefined; + /** Where in {@link StripGroups.tokens} the first token of the group at `index` sits. */ + tokenIndexOf: (index: number) => number; +} + +/** Groups last numbered, kept to number the next ones against. */ +type Numbered = Readonly<{ + groups: readonly TokenGroup[]; + first: number; + localIndexByTokenRef: ReadonlyMap; +}>; + +/** + * The index that the first of `groups` takes, chosen so that changing which segments are held does + * not renumber groups that stay held, and so that groups sharing no token with `prev` take indexes + * that `prev` never used. + */ +function firstIndexOf(groups: readonly TokenGroup[], prev: Numbered | undefined): number { + if (!prev) return 0; + if (prev.groups === groups) return prev.first; + for (let local = 0; local < groups.length; local += 1) { + const prevLocal = prev.localIndexByTokenRef.get(groups[local].tokens[0].ref); + if (prevLocal !== undefined) return prev.first + prevLocal - local; + } + return prev.first + prev.groups.length + 1; +} + +/** + * Flattens and groups the strip's segments, numbering groups so that changing which segments are + * held does not renumber groups that stay held, and so that a segment that stays held keeps its + * group objects. + */ +export default function useStripGroups( + segments: readonly Segment[], + phraseLinkByRef: Map, +): StripGroups { + const groupsBySegment = useMemo( + () => new WeakMap(), + // Emptied whenever the phrase links change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [phraseLinkByRef], + ); + + const flat = useMemo(() => { + const tokens: Token[] = []; + const groups: TokenGroup[] = []; + const tokenStarts: number[] = []; + segments.forEach((segment) => { + // No phrase spans two segments, so each groups alone. + let segmentGroups = groupsBySegment.get(segment); + if (!segmentGroups) { + segmentGroups = groupTokens(segment.tokens, phraseLinkByRef); + groupsBySegment.set(segment, segmentGroups); + } + segmentGroups.forEach((group) => { + groups.push(group); + tokenStarts.push(tokens.length + group.firstIndex); + }); + tokens.push(...segment.tokens); + }); + const localIndexByTokenRef = new Map(); + groups.forEach((group, local) => + group.tokens.forEach((token) => localIndexByTokenRef.set(token.ref, local)), + ); + return { tokens, groups, tokenStarts, localIndexByTokenRef }; + }, [segments, groupsBySegment, phraseLinkByRef]); + + const [numbered, setNumbered] = useState(); + const first = firstIndexOf(flat.groups, numbered); + if (numbered?.groups !== flat.groups) { + setNumbered({ + groups: flat.groups, + first, + localIndexByTokenRef: flat.localIndexByTokenRef, + }); + } + + return useMemo(() => { + const indexByTokenRef = new Map(); + flat.localIndexByTokenRef.forEach((local, ref) => indexByTokenRef.set(ref, first + local)); + return { + tokens: flat.tokens, + groups: flat.groups, + first, + indexByTokenRef, + at: (index: number) => flat.groups[index - first], + tokenIndexOf: (index: number) => flat.tokenStarts[index - first] ?? flat.tokens.length, + }; + }, [flat, first]); +} diff --git a/src/hooks/useStripSegments.ts b/src/hooks/useStripSegments.ts new file mode 100644 index 000000000..1dbd24333 --- /dev/null +++ b/src/hooks/useStripSegments.ts @@ -0,0 +1,180 @@ +import type { Segment } from 'interlinearizer'; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { loadedRun, stripTarget } from '../utils/strip-range'; +import type { PageCaller, SegmentPages } from './useSegmentPages'; +import useLatestRef from './useLatestRef'; + +/** Segments asked for past each end of the strip's target, so a page loads before it is reached. */ +export const STRIP_PREFETCH_SEGMENTS = 8; + +/** The caller the strip's page ranges are recorded under. */ +const PAGE_CALLER: PageCaller = 'strip'; + +/** Arguments for {@link useStripSegments}. */ +export interface UseStripSegmentsArgs { + pages: SegmentPages; + /** + * The focus the strip shows; its segment is held once loaded, until scrolling leaves it behind. + * One not in the book, as across a book change, gives way to + * {@link UseStripSegmentsArgs.nextFocusTokenRef}. + */ + focusTokenRef: string | undefined; + /** The focus the strip is moving to, whose pages load while the strip fades out. */ + nextFocusTokenRef: string | undefined; + /** Maps every token ref to the id of the segment that contains it. */ + tokenSegmentMap: ReadonlyMap; +} + +/** Return value of {@link useStripSegments}. */ +export interface UseStripSegmentsResult { + /** The loaded run of segments the strip holds, in book order. */ + segments: readonly Segment[]; + /** Whether the segment holding `tokenRef` has loaded. */ + hasPage: (tokenRef: string) => boolean; + /** Names the first and last tokens the strip has mounted, which the held segments follow. */ + setMounted: (firstTokenRef: string, lastTokenRef: string) => void; + /** + * Whether the strip has been scrolled far enough from its focus that the segment holding it is no + * longer held. + */ + isFocusLeftBehind: boolean; + /** Holds the segments around the focus again, wherever the strip has been scrolled. */ + holdFocus: () => void; +} + +/** The mounted edge tokens, and the focus they were mounted around. */ +type Mounted = Readonly<{ first: string; last: string; focus: string | undefined }>; + +/** Segments the strip last held, and the pages they came from. */ +type Held = Readonly<{ pages: SegmentPages; segments: readonly Segment[] }>; + +/** + * Holds the continuous strip's own run of segments: the ones under its mounted groups, with a + * margin each side, as far as their pages have loaded. Also asks for the pages that those segments + * need, and the pages around the word that the focus is moving to. + * + * Before anything is mounted, and once the focus moves clear of what is, the run is held around the + * focus instead, since the strip remounts around it. A focus the strip is scrolled away from is let + * go once it falls outside that margin. + */ +export default function useStripSegments({ + pages, + focusTokenRef, + nextFocusTokenRef, + tokenSegmentMap, +}: Readonly): UseStripSegmentsResult { + useSyncExternalStore(pages.subscribe, pages.loadedVersion); + + const indexById = useMemo( + () => new Map(pages.outline.map((segment, index) => [segment.id, index])), + [pages.outline], + ); + + const [mounted, setMountedState] = useState(); + const [held, setHeld] = useState(); + + // A boundary edit reaches tokenSegmentMap before the outline it gives does, so the segments + // already held place their own tokens. + const heldIndexByTokenRef = useMemo(() => { + const byRef = new Map(); + if (held?.pages !== pages) return byRef; + held.segments.forEach((segment) => { + const index = indexById.get(segment.id); + /* v8 ignore next -- held segments come from this outline */ + if (index === undefined) return; + segment.tokens.forEach((token) => byRef.set(token.ref, index)); + }); + return byRef; + }, [held, pages, indexById]); + + const indexOf = useCallback( + (tokenRef: string | undefined): number | undefined => { + if (tokenRef === undefined) return undefined; + const id = tokenSegmentMap.get(tokenRef); + return ( + heldIndexByTokenRef.get(tokenRef) ?? (id === undefined ? undefined : indexById.get(id)) + ); + }, + [heldIndexByTokenRef, tokenSegmentMap, indexById], + ); + + const displayIndex = indexOf(focusTokenRef); + const focusIndex = displayIndex ?? indexOf(nextFocusTokenRef); + const mountedFirst = indexOf(mounted?.first); + const mountedLast = indexOf(mounted?.last); + const isAroundFocus = + mountedFirst !== undefined && + mountedLast !== undefined && + (mounted?.focus === focusTokenRef || + (focusIndex !== undefined && focusIndex >= mountedFirst && focusIndex <= mountedLast)); + const anchor = isAroundFocus ? mountedFirst : (focusIndex ?? 0); + const target = stripTarget(pages.outline, anchor, isAroundFocus ? mountedLast : anchor); + const isFocusInTarget = + focusIndex !== undefined && focusIndex >= target.start && focusIndex < target.end; + const isFocusLeftBehind = isAroundFocus && displayIndex !== undefined && !isFocusInTarget; + const run = loadedRun( + (index) => pages.segmentAt(index) !== undefined, + isFocusInTarget ? focusIndex : anchor, + target, + ); + + const segments = useMemo( + () => + Array.from({ length: run.end - run.start }, (_unused, i) => run.start + i).flatMap( + (index) => { + const segment = pages.segmentAt(index); + /* v8 ignore next -- the run holds only loaded segments */ + return segment ? [segment] : []; + }, + ), + [pages, run.start, run.end], + ); + if (held?.segments !== segments) setHeld({ pages, segments }); + + useEffect( + () => + pages.load( + target.start - STRIP_PREFETCH_SEGMENTS, + target.end + STRIP_PREFETCH_SEGMENTS, + PAGE_CALLER, + ), + [pages, target.start, target.end], + ); + useEffect(() => () => pages.release(PAGE_CALLER), [pages]); + + const nextIndex = indexOf(nextFocusTokenRef); + const isNextHeld = nextIndex !== undefined && nextIndex >= run.start && nextIndex < run.end; + useEffect(() => { + if (nextIndex === undefined || isNextHeld) return; + const next = stripTarget(pages.outline, nextIndex, nextIndex); + pages.load( + next.start - STRIP_PREFETCH_SEGMENTS, + next.end + STRIP_PREFETCH_SEGMENTS, + PAGE_CALLER, + ); + }, [pages, nextIndex, isNextHeld]); + + const hasPage = useCallback( + (tokenRef: string) => { + const index = indexOf(tokenRef); + return index !== undefined && pages.segmentAt(index) !== undefined; + }, + [indexOf, pages], + ); + + const focusRef = useLatestRef(focusTokenRef); + const setMounted = useCallback( + (first: string, last: string) => { + const focus = focusRef.current; + setMountedState((prev) => + prev?.first === first && prev.last === last && prev.focus === focus + ? prev + : { first, last, focus }, + ); + }, + [focusRef], + ); + const holdFocus = useCallback(() => setMountedState(undefined), []); + + return { segments, hasPage, setMounted, isFocusLeftBehind, holdFocus }; +} diff --git a/src/utils/reanchor-analysis.ts b/src/utils/reanchor-analysis.ts index 5ea32b994..36eaa0b26 100644 --- a/src/utils/reanchor-analysis.ts +++ b/src/utils/reanchor-analysis.ts @@ -485,7 +485,8 @@ const MAX_REANCHOR_PASSES = 8; * against and flips an approval to `'stale'`, so the record survives for review rather than being * silently dropped or silently misattached. Future work should resist growing this into a general * diff — the cost of a wrong match is a gloss on the wrong word, which is worse than an honest - * `'stale'`. + * `'stale'`. A phrase whose tokens fall in more than one of `book`'s segments is staled the same + * way, since no phrase may span a segment boundary. * * Only approvals are staled and only stale links revived, so the pass gives back exactly what it * takes: a stale link whose snapshot places again returns to `'approved'`, restoring an analysis an @@ -589,20 +590,28 @@ function reanchorOnce( return { ...revived, token: result.snapshot, updatedAt: now }; }); + const segmentIdByTokenRef = new Map( + book.segments.flatMap((s) => s.tokens.map((t) => [t.ref, s.id] as const)), + ); + /** Whether a phrase has lost a token or spans segments. */ + const isStranded = (results: readonly ReturnType[]) => + results.some((r) => r.orphaned) || + new Set(results.map((r) => segmentIdByTokenRef.get(r.snapshot.tokenRef))).size > 1; + // Which tokens an occupying phrase holds, so reviving a stale one cannot give a token a second // approved phrase. A token's own parse is no obstacle — only another phrase is. const phraseOccupiedElsewhere = new Set( analysis.phraseAnalysisLinks .filter(occupies) .map((l) => l.tokens.map((token) => reanchorSnapshot(token, anchorMap))) - .filter((results) => !results.some((r) => r.orphaned)) + .filter((results) => !isStranded(results)) .flatMap((results) => results.map((r) => r.snapshot.tokenRef)), ); const phraseAnalysisLinks = analysis.phraseAnalysisLinks.map((link) => { const results = link.tokens.map((token) => reanchorSnapshot(token, anchorMap)); let updated: PhraseAnalysisLink; - if (results.some((r) => r.orphaned)) { + if (isStranded(results)) { // Still moves the tokens that did place, so one it shares with another link stays one word. updated = markStale(link, now); } else { diff --git a/src/utils/strip-range.ts b/src/utils/strip-range.ts new file mode 100644 index 000000000..84008461d --- /dev/null +++ b/src/utils/strip-range.ts @@ -0,0 +1,51 @@ +import type { OutlineSegment } from '../types/segment-pages'; + +/** A half-open `[start, end)` range of outline indexes. */ +export type OutlineRange = Readonly<{ start: number; end: number }>; + +/** + * Words that the continuous strip holds past the segments it shows, on each side. Enough that the + * phrase window can extend once or twice and find its groups already there. + */ +export const STRIP_MARGIN_WORDS = 32; + +/** + * The segments the strip should hold to show outline indexes `first` to `last` inclusive: those, + * and on each side as many more as hold {@link STRIP_MARGIN_WORDS} words, clamped to the outline. + */ +export function stripTarget( + outline: readonly OutlineSegment[], + first: number, + last: number, +): OutlineRange { + let start = Math.max(0, first); + let words = 0; + while (start > 0 && words < STRIP_MARGIN_WORDS) { + start -= 1; + words += outline[start].wordTokenCount; + } + let end = Math.min(outline.length, last + 1); + words = 0; + while (end < outline.length && words < STRIP_MARGIN_WORDS) { + words += outline[end].wordTokenCount; + end += 1; + } + return { start, end }; +} + +/** + * The run of loaded segments within `target` that holds outline index `anchor`, or an empty range + * at `anchor` when its own segment has not loaded. + */ +export function loadedRun( + isLoaded: (index: number) => boolean, + anchor: number, + target: OutlineRange, +): OutlineRange { + if (!isLoaded(anchor)) return { start: anchor, end: anchor }; + let start = anchor; + while (start > target.start && isLoaded(start - 1)) start -= 1; + let end = anchor + 1; + while (end < target.end && isLoaded(end)) end += 1; + return { start, end }; +} diff --git a/src/utils/token-layout.ts b/src/utils/token-layout.ts index 8d5d614d9..817ba1f50 100644 --- a/src/utils/token-layout.ts +++ b/src/utils/token-layout.ts @@ -108,6 +108,31 @@ export function groupTokens( }, []); } +/** + * Whether the group that {@link groupTokens} puts word `tokenRef` in is the first in its book, and + * whether it is the last. + */ +export function groupBookEdges( + tokenRef: string, + tokenDocOrder: ReadonlyMap, + tokenSegmentMap: ReadonlyMap, + phraseLinkByRef: ReadonlyMap, +): { isFirst: boolean; isLast: boolean } { + /* v8 ignore next -- a word of the book always has a place in it */ + const order = tokenDocOrder.get(tokenRef) ?? 0; + const segmentId = tokenSegmentMap.get(tokenRef); + let before = 0; + let after = 0; + // A phrase's words share a group only within one segment. + phraseLinkByRef.get(tokenRef)?.tokens.forEach(({ tokenRef: ref }) => { + const other = tokenDocOrder.get(ref); + if (tokenSegmentMap.get(ref) !== segmentId || other === undefined) return; + if (other < order) before += 1; + else if (other > order) after += 1; + }); + return { isFirst: before === order, isLast: after === tokenDocOrder.size - 1 - order }; +} + /** * Emits an alternating sequence of phrase groups and link slots in document order. A leading slot * always precedes the first group and a trailing slot follows the last, so punctuation at segment diff --git a/src/utils/verse-superscripts.ts b/src/utils/verse-superscripts.ts index a00a36c04..1c523ba15 100644 --- a/src/utils/verse-superscripts.ts +++ b/src/utils/verse-superscripts.ts @@ -79,19 +79,19 @@ export function slotVerseLabel( } /** - * Builds a whole-book lookup from each verse-start token's ref to its inline superscript label, and - * from each heading's first token ref to its {@link headingLabel}. Built once for the whole book, so - * verse boundaries are marked identically everywhere without re-walking the book to key labels by - * token ref. + * Builds a lookup from each verse-start token's ref in `segments` to its inline superscript label + * in `labelsBySegmentId`, the whole book's {@link buildVerseStartLabels}, and from each heading's + * first token ref to its {@link headingLabel}. * - * Empty verses contribute no entry, having no token to carry the number, but are still walked so - * chapter qualification stays correct across them. + * Empty verses contribute no entry, having no token to carry the number. */ -export function buildVerseStartLabelsByTokenRef(segments: readonly Segment[]): Map { - const labelsBySegmentId = buildVerseStartLabels(segments); +export function buildVerseStartLabelsByTokenRef( + segments: readonly Segment[], + labelsBySegmentId: ReadonlyMap, +): Map { const labelByTokenRef = new Map(); segments.forEach((segment) => { - /* v8 ignore next -- buildVerseStartLabels keys off these same segments, so the entry always exists */ + /* v8 ignore next -- the whole book's labels hold every segment of it */ const labels = labelsBySegmentId.get(segment.id) ?? []; const heading = headingLabel(segment); if (heading) labelByTokenRef.set(heading.token.ref, heading.label);