diff --git a/__mocks__/papi-frontend-react.ts b/__mocks__/papi-frontend-react.ts index 15e2f467..81c2c77f 100644 --- a/__mocks__/papi-frontend-react.ts +++ b/__mocks__/papi-frontend-react.ts @@ -45,6 +45,14 @@ const useProjectSetting = jest false, ]); +/** + * Records already built, keyed by the requested key list, so a repeated call hands back the same + * object. The real hook keeps resolved data in state and so is stable across an unchanged render; + * rebuilding the record every call would defeat any memo keyed on it and make a render-count + * assertion a measurement of this mock rather than of the component. + */ +const localizedStringRecords = new Map>(); + /** * Mock for `useLocalizedStrings`. Maps each requested key to itself so tests receive a * predictable `Record` without a real localization service. @@ -52,10 +60,19 @@ const useProjectSetting = jest * @returns Tuple of `[record, isLoading]` where every key maps to itself and `isLoading` is * `false`. */ -const useLocalizedStrings = jest.fn().mockImplementation((keys: string[]) => [ - Array.isArray(keys) ? keys.reduce>((acc, k) => { acc[k] = k; return acc; }, {}) : {}, - false, -]); +const useLocalizedStrings = jest.fn().mockImplementation((keys: string[]) => { + if (!Array.isArray(keys)) return [{}, false]; + const cacheKey = keys.join(' '); + let record = localizedStringRecords.get(cacheKey); + if (!record) { + record = keys.reduce>((acc, k) => { + acc[k] = k; + return acc; + }, {}); + localizedStringRecords.set(cacheKey, record); + } + return [record, false]; +}); /** * Mock for `useSetting`. Returns `[defaultState, jest.fn(), jest.fn(), false]`, passing diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 047e0811..527533e4 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -300,21 +300,29 @@ function requiredProps( let resizeObserverInstances: TrackingResizeObserver[] = []; /** - * A ResizeObserver test double that records its callback and disconnect state and appends itself to - * {@link resizeObserverInstances}, so a test can fire a simulated late content reflow and assert - * whether the active observer was disconnected. Module-scoped (rather than an inline class per - * test) so the file stays under `max-classes-per-file`. + * A ResizeObserver test double that records what it was pointed at, its callback, and its + * disconnect state, and appends itself to {@link resizeObserverInstances}, so a test can fire a + * simulated late content reflow and assert whether the active observer was disconnected. + * Module-scoped (rather than an inline class per test) so the file stays under + * `max-classes-per-file`. */ class TrackingResizeObserver implements ResizeObserver { /** Whether {@link disconnect} has been called on this instance. */ disconnected = false; + /** + * Elements this instance was pointed at. The view runs several observers at once, so a test picks + * one by the element it watches rather than by creation order. + */ + targets: Element[] = []; + constructor(public callback: ResizeObserverCallback) { resizeObserverInstances.push(this); } - // eslint-disable-next-line @typescript-eslint/class-methods-use-this - observe() {} + observe(target: Element) { + this.targets.push(target); + } // eslint-disable-next-line @typescript-eslint/class-methods-use-this unobserve() {} @@ -1405,6 +1413,197 @@ describe('ContinuousView phrase window', () => { // tok-299 is well outside the rendered phrase window. expect(screen.queryByText('word299')).not.toBeInTheDocument(); }); + + /** Links two tokens far enough apart that only one of them falls inside the starting window. */ + function linkFarApartTokens(): void { + const phraseLink: PhraseAnalysisLink = { + ...FIXTURE_STAMPS, + analysisId: 'phrase-far', + status: 'approved', + tokens: [ + { tokenRef: 'large-tok-150', surfaceText: 'word150' }, + { tokenRef: 'large-tok-190', surfaceText: 'word190' }, + ], + }; + phraseLinkMap.set('large-tok-150', phraseLink); + phraseLinkMap.set('large-tok-190', phraseLink); + } + + it('mounts the far fragment of a discontiguous phrase the window touches', () => { + // An arc runs between two mounted phrase boxes, so a fragment left outside the window would + // take the whole arc with it and leave the visible fragment with no phrase cue at all. + linkFarApartTokens(); + const book = makeLargeBook(300); + render( + , + withAnalysisStore, + ); + + expect(screen.getByText('word190')).toBeInTheDocument(); + }); + + it('widens no further than the phrase span it is covering', () => { + linkFarApartTokens(); + const book = makeLargeBook(300); + render( + , + withAnalysisStore, + ); + + expect(screen.queryByText('word200')).not.toBeInTheDocument(); + }); + + it('leaves an unlinked token at the same distance outside the window', () => { + const book = makeLargeBook(300); + render( + , + withAnalysisStore, + ); + + expect(screen.queryByText('word190')).not.toBeInTheDocument(); + }); + + it('re-centers the focused group when a viewport resize widens the window', () => { + // The focus never moves here, so no focus-keyed centering path fires; without the window-keyed + // one the groups mounting ahead of the focus carry it off the strip. + const originalResizeObserver = global.ResizeObserver; + resizeObserverInstances = []; + global.ResizeObserver = TrackingResizeObserver; + + try { + const book = makeLargeBook(300); + render( + , + withAnalysisStore, + ); + + // jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide + // enough to ask for more groups, over groups spaced a fixed pitch apart. + const viewport = screen.getByTestId('strip-scroll-viewport'); + const stripRow = screen.getByTestId('token-strip'); + const mountedGroups = () => stripRow.querySelectorAll('[data-phrase-group="true"]').length; + Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: 2000 }); + stripRow.querySelectorAll('[data-phrase-group="true"]').forEach((group, index) => { + group.getBoundingClientRect = () => ({ + left: index * 100, + right: index * 100, + top: 0, + bottom: 0, + width: 0, + height: 0, + x: index * 100, + y: 0, + toJSON: () => ({}), + }); + }); + + const groupsBeforeResize = mountedGroups(); + scrollIntoViewMock.mockClear(); + + // Only the render window observes the clipping viewport; the centering hold and the arc pass + // watch content elements, so firing this one exercises the window path alone. + const windowObserver = resizeObserverInstances.find((o) => o.targets.includes(viewport)); + if (!windowObserver) throw new Error('Expected the render window to observe the viewport'); + act(() => { + windowObserver.callback([], { disconnect() {}, observe() {}, unobserve() {} }); + }); + + expect(mountedGroups()).toBeGreaterThan(groupsBeforeResize); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'nearest', + inline: 'center', + }); + } finally { + global.ResizeObserver = originalResizeObserver; + } + }); + + it('stops holding the pre-resize group centered once the reader navigates away', () => { + // A navigation slides the window, which is itself a content resize — so a hold left over from + // the window change restarts its loop, instant-scrolls back to the group the reader just left, + // and parks the strip there. + const originalResizeObserver = global.ResizeObserver; + resizeObserverInstances = []; + global.ResizeObserver = TrackingResizeObserver; + const stubObserver = { disconnect() {}, observe() {}, unobserve() {} }; + + try { + const book = makeLargeBook(300); + const props = requiredProps(book, { focusedTokenRef: 'large-tok-150' }); + const { rerender } = render(, withAnalysisStore); + + // jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide + // enough to ask for more groups, over groups spaced a fixed pitch apart. + const viewport = screen.getByTestId('strip-scroll-viewport'); + const stripRow = screen.getByTestId('token-strip'); + Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: 2000 }); + const layOutGroups = () => { + stripRow.querySelectorAll('[data-phrase-group="true"]').forEach((group, index) => { + group.getBoundingClientRect = () => ({ + left: index * 100, + right: index * 100, + top: 0, + bottom: 0, + width: 0, + height: 0, + x: index * 100, + y: 0, + toJSON: () => ({}), + }); + }); + }; + layOutGroups(); + + act(() => { + jest.useFakeTimers(); + }); + try { + // Widen the window, which arms the hold on the currently-focused group. + const windowObserver = resizeObserverInstances.find((o) => o.targets.includes(viewport)); + if (!windowObserver) throw new Error('Expected the render window to observe the viewport'); + act(() => { + windowObserver.callback([], stubObserver); + }); + layOutGroups(); + + // Navigate one phrase forward while that hold is still alive, echoing the new ref back the + // way the parent does so the strip treats it as internal navigation. + fireEvent.click( + screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }), + ); + const emitted = props.onFocusedTokenRefChange.mock.calls.at(-1)?.[0]; + const nextRef = typeof emitted === 'string' ? emitted : undefined; + expect(nextRef).toBe('large-tok-151'); + rerender(); + + scrollIntoViewMock.mockClear(); + // Report the window slide's own reflow, then run out the frames a restarted hold would use. + resizeObserverInstances + .filter((o) => o.targets.includes(stripRow) && !o.disconnected) + .forEach((observer) => { + act(() => { + observer.callback([], stubObserver); + }); + }); + act(() => { + // Comfortably past the hold's quiet period, so every frame it would use has run. + jest.advanceTimersByTime(300); + }); + + const centeredGroups = scrollIntoViewMock.mock.instances.map((el: unknown) => + el instanceof HTMLElement ? el.textContent : undefined, + ); + expect(centeredGroups).not.toContain('word150'); + } finally { + act(() => { + jest.useRealTimers(); + }); + } + } finally { + global.ResizeObserver = originalResizeObserver; + } + }); }); describe('ContinuousView phrase grouping', () => { diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index b44ce8d1..88c91138 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -1,25 +1,24 @@ /// /// -import { useLocalizedStrings } from '@papi/frontend/react'; import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MorphemeAnalysis } from 'interlinearizer'; import * as AnalysisStore from '../../components/AnalysisStore'; import { MorphemeBox, MorphemeGlossInput } from '../../components/MorphemeBox'; +import { TOKEN_CHIP_LABEL_KEYS } from '../../components/PhraseStripContext'; import { makeWordToken } from '../test-helpers'; jest.mock('../../components/AnalysisStore'); -const LOCALIZED = { - '%interlinearizer_tokenChip_editMorphemes%': 'Edit morpheme breakdown for {token}', - '%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}', +// Resolved templates rather than the bare keys: the tests below target one morpheme's input among +// several, which only the substituted `{form}` distinguishes. +const LABELS = { + ...TOKEN_CHIP_LABEL_KEYS, + editMorphemes: 'Edit morpheme breakdown for {token}', + morphemeGloss: 'Gloss for morpheme {form}', }; -beforeEach(() => { - jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); -}); - const WORD_TOKEN = makeWordToken('GEN 1:1:0', 'hello'); const MORPHEMES: MorphemeAnalysis[] = [ @@ -36,6 +35,7 @@ function renderBox(props: Partial[0]> = {}) { { { { it('renders an empty input when no gloss exists', () => { render( { it('renders the existing gloss value', () => { render( { render( { render( { const onFocus = jest.fn(); render( { render( [0] { } /** - * Wraps `ui` in a {@link PhraseStripProvider} with default context so components that call - * {@link usePhraseStripContext} can render without a provider in the tree. + * Wraps `ui` in a {@link PhraseStripProvider} so components that call {@link usePhraseStripContext} + * can render without a provider in the tree, defaulting every context value and layering the given + * overrides on top — e.g. standing a book's token refs up in `tokenDocOrder`. */ -function withProvider(ui: ReactElement): ReactElement { - return {ui}; +function withProvider( + ui: ReactElement, + overrides: Partial = {}, +): ReactElement { + return {ui}; +} + +/** A `tokenDocOrder` standing the given refs up as the book's word tokens, in the order listed. */ +function docOrder(...refs: string[]): ReadonlyMap { + return new Map(refs.map((ref, index) => [ref, index])); } describe('PhraseSlot', () => { @@ -823,12 +832,39 @@ describe('PhraseStrip', () => { it('shows the gloss input only on the first fragment of a discontiguous phrase', () => { const link = makePhraseLink('p1', ['tok-a', 'tok-b']); const items = [groupItem(link, ['tok-a']), groupItem(link, ['tok-b'])]; - render(withProvider()); + render( + withProvider(, { + tokenDocOrder: docOrder('tok-a', 'tok-b'), + }), + ); const boxes = screen.getAllByRole('button'); expect(boxes[0]).toHaveAttribute('data-gloss', 'true'); expect(boxes[1]).toHaveAttribute('data-gloss', 'false'); }); + it('withholds the gloss input from a later fragment even when the first one is not rendered', () => { + // A windowed strip can mount a later fragment while the first one is outside its window. + const link = makePhraseLink('p1', ['tok-a', 'tok-b']); + const items = [groupItem(link, ['tok-b'])]; + render( + withProvider(, { + tokenDocOrder: docOrder('tok-a', 'tok-b'), + }), + ); + expect(screen.getByRole('button')).toHaveAttribute('data-gloss', 'false'); + }); + + it('moves the gloss input to the next surviving fragment when the phrase starts on a token the book no longer has', () => { + // A baseline edit shifts every later token's ref, so a stored ref can name a token that is gone + // while a further one still resolves. + const link = makePhraseLink('p1', ['tok-a', 'tok-b']); + const items = [groupItem(link, ['tok-b'])]; + render( + withProvider(, { tokenDocOrder: docOrder('tok-b') }), + ); + expect(screen.getByRole('button')).toHaveAttribute('data-gloss', 'true'); + }); + it('marks a group whose token is a hovered-preview candidate as candidate, not highlighted', () => { const items = [groupItem(undefined, ['tok-a'])]; render( diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 41090df4..f3b3635d 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -706,7 +706,20 @@ describe('SegmentView', () => { ]), ); - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); // boxes[0]=tok-a (1st fragment), boxes[1]=tok-b (free), boxes[2]=tok-c (2nd fragment) const boxes = document.querySelectorAll('[data-show-gloss]'); diff --git a/src/__tests__/components/test-helpers.tsx b/src/__tests__/components/test-helpers.tsx index 36ded27e..c6a772f4 100644 Binary files a/src/__tests__/components/test-helpers.tsx and b/src/__tests__/components/test-helpers.tsx differ diff --git a/src/__tests__/hooks/usePhraseWindowHalf.test.tsx b/src/__tests__/hooks/usePhraseWindowHalf.test.tsx new file mode 100644 index 00000000..fa7e0a3a --- /dev/null +++ b/src/__tests__/hooks/usePhraseWindowHalf.test.tsx @@ -0,0 +1,340 @@ +/// + +import { act, render, renderHook, screen } from '@testing-library/react'; +import { useCallback, useRef } from 'react'; +import usePhraseWindowHalf, { + MAX_PHRASE_WINDOW_HALF, + MIN_PHRASE_WINDOW_HALF, +} from '../../hooks/usePhraseWindowHalf'; + +/** Geometry a stubbed strip reports; jsdom lays nothing out, so every dimension is supplied. */ +type StripGeometry = { + /** `clientWidth` the clipping viewport reports. */ + viewportWidth: number; + /** Horizontal distance between one group's left edge and the next's. */ + groupPitch: number; + /** How many group wrappers the content row holds. */ + groupCount: number; +}; + +/** + * Stubs `getBoundingClientRect` on a group so it reports the left edge its index implies. Only + * `left` is read, but the whole rect is returned so nothing else reading it sees a partial object. + */ +function placeGroup(group: HTMLElement, left: number): void { + group.getBoundingClientRect = () => ({ + left, + right: left, + top: 0, + bottom: 0, + width: 0, + height: 0, + x: left, + y: 0, + toJSON: () => ({}), + }); +} + +/** + * Builds a viewport/content pair with the given geometry stubbed onto real elements, so the hook + * reads them through the same DOM properties it uses in a browser. + * + * @returns The two refs the hook takes and the group elements, all attached to the document. + */ +function makeStrip({ viewportWidth, groupPitch, groupCount }: StripGeometry) { + const viewport = document.createElement('div'); + const content = document.createElement('div'); + viewport.appendChild(content); + document.body.appendChild(viewport); + Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: viewportWidth }); + const groups: HTMLElement[] = []; + for (let i = 0; i < groupCount; i += 1) { + const group = document.createElement('span'); + group.setAttribute('data-phrase-group', 'true'); + placeGroup(group, i * groupPitch); + content.appendChild(group); + groups.push(group); + } + return { viewportRef: { current: viewport }, contentRef: { current: content }, groups }; +} + +/** + * Renders the hook over a strip with the given geometry and returns its settled value. The focus + * sits mid-strip, as it does whenever the window is not clipped by an end of the book. + */ +function renderWindowHalf(geometry: StripGeometry): number { + const { viewportRef, contentRef, groups } = makeStrip(geometry); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[Math.floor(groups.length / 2)]), + ); + return result.current; +} + +/** + * Stubs the global ResizeObserver for the duration of one test body, handing it a function that + * fires the most recently constructed observer's callback, and restores the real one afterward. + */ +function withStubbedResizeObserver(run: (notifyResize: () => void) => void): void { + let notify: (() => void) | undefined; + const originalResizeObserver = global.ResizeObserver; + global.ResizeObserver = class implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + notify = () => callback([], this); + } + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + observe() {} + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + unobserve() {} + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + disconnect() {} + }; + + try { + run(() => { + act(() => { + notify?.(); + }); + }); + } finally { + global.ResizeObserver = originalResizeObserver; + } +} + +/** Viewport width the self-sizing strip reports. */ +const SELF_SIZING_VIEWPORT_WIDTH = 1000; + +/** Half-window the self-sizing strip settles on for the pitch it reports. */ +const SELF_SIZING_SETTLED_HALF = 16; + +/** + * A strip that mounts the groups the hook asks for, as the continuous view does, so a measurement + * that depended on how many groups are mounted would feed itself. Groups near the focus keep a + * fixed pitch; the ones further out are deliberately narrower, so a hook that averaged the whole + * row would read a different width at each size. + * + * @param nearFocusPitch - Pitch of the groups within the sample, which is what the hook must read. + */ +function SelfSizingStrip({ nearFocusPitch }: Readonly<{ nearFocusPitch: number }>) { + // eslint-disable-next-line no-null/no-null + const viewportRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const contentRef = useRef(null); + const setViewport = useCallback((element: HTMLDivElement | null) => { + viewportRef.current = element; + if (element) { + Object.defineProperty(element, 'clientWidth', { + configurable: true, + value: SELF_SIZING_VIEWPORT_WIDTH, + }); + } + }, []); + const windowHalf = usePhraseWindowHalf( + viewportRef, + contentRef, + () => document.querySelector('[data-focus-group="true"]') ?? undefined, + ); + const groupCount = 2 * windowHalf + 1; + const focusIndex = windowHalf; + // Groups outside the sampled run sit at a quarter of the pitch, so averaging the whole row would + // read a pitch that shrinks with every group the window adds. + const leftOf = (index: number) => { + const offset = index - focusIndex; + const near = Math.min(Math.abs(offset), 4); + const far = Math.abs(offset) - near; + return Math.sign(offset) * nearFocusPitch * (near + far * 0.25); + }; + return ( +
+
+ {Array.from({ length: groupCount }, (_, index) => ( + { + if (element) placeGroup(element, leftOf(index)); + }} + /> + ))} +
+
+ ); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('usePhraseWindowHalf', () => { + it('sizes the window from the viewport width and the measured group pitch', () => { + // The viewport spans ten group pitches; the window is that count scaled by the viewports kept + // per side, rounded up to the step. + expect(renderWindowHalf({ viewportWidth: 1000, groupPitch: 100, groupCount: 20 })).toBe(16); + }); + + it('rounds up to the step, so a small viewport change leaves the window alone', () => { + const narrower = renderWindowHalf({ viewportWidth: 980, groupPitch: 100, groupCount: 20 }); + const wider = renderWindowHalf({ viewportWidth: 1000, groupPitch: 100, groupCount: 20 }); + expect(narrower).toBe(wider); + }); + + it('reads the same pitch in an RTL strip, where document order runs right to left', () => { + expect(renderWindowHalf({ viewportWidth: 1000, groupPitch: -100, groupCount: 20 })).toBe(16); + }); + + it('clamps up to the minimum when the viewport holds only a few groups', () => { + expect(renderWindowHalf({ viewportWidth: 100, groupPitch: 100, groupCount: 20 })).toBe( + MIN_PHRASE_WINDOW_HALF, + ); + }); + + it('clamps down to the maximum when the measurement asks for more', () => { + expect(renderWindowHalf({ viewportWidth: 100_000, groupPitch: 100, groupCount: 20 })).toBe( + MAX_PHRASE_WINDOW_HALF, + ); + }); + + it('keeps the starting window while every group measures at the same place', () => { + // An unlaid-out strip reports a zero pitch, which would otherwise divide out to an unbounded + // group count and clamp straight to the maximum. + expect(renderWindowHalf({ viewportWidth: 1000, groupPitch: 0, groupCount: 20 })).toBe( + MIN_PHRASE_WINDOW_HALF, + ); + }); + + it('keeps the starting window while the strip holds fewer than two groups', () => { + expect(renderWindowHalf({ viewportWidth: 1000, groupPitch: 100, groupCount: 1 })).toBe( + MIN_PHRASE_WINDOW_HALF, + ); + }); + + it('leaves the window unset while either element is unmounted', () => { + const { result } = renderHook(() => + // eslint-disable-next-line no-null/no-null + usePhraseWindowHalf({ current: null }, { current: null }, () => undefined), + ); + expect(result.current).toBe(MIN_PHRASE_WINDOW_HALF); + }); + + it('slides the sample forward when the focus sits at the start of the book', () => { + const { viewportRef, contentRef, groups } = makeStrip({ + viewportWidth: 1000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[0]), + ); + // The run has nowhere to reach behind the focus, so it takes the groups ahead of it instead and + // reads the same pitch a mid-book focus would. + expect(result.current).toBe(16); + }); + + it('slides the sample back when the focus sits at the end of the book', () => { + const { viewportRef, contentRef, groups } = makeStrip({ + viewportWidth: 1000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[groups.length - 1]), + ); + expect(result.current).toBe(16); + }); + + it('samples around the mounted row when the focused group has not been recorded', () => { + const { viewportRef, contentRef } = makeStrip({ + viewportWidth: 1000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => undefined), + ); + expect(result.current).toBe(16); + }); + + it('widens the window when the viewport grows', () => { + withStubbedResizeObserver((notifyResize) => { + const { viewportRef, contentRef, groups } = makeStrip({ + viewportWidth: 1000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[10]), + ); + expect(result.current).toBe(16); + + Object.defineProperty(viewportRef.current, 'clientWidth', { + configurable: true, + value: 2000, + }); + notifyResize(); + + expect(result.current).toBe(32); + }); + }); + + it('narrows the window when the viewport shrinks', () => { + withStubbedResizeObserver((notifyResize) => { + const { viewportRef, contentRef, groups } = makeStrip({ + viewportWidth: 2000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[10]), + ); + expect(result.current).toBe(32); + + Object.defineProperty(viewportRef.current, 'clientWidth', { + configurable: true, + value: 1000, + }); + notifyResize(); + + expect(result.current).toBe(16); + }); + }); + + it('narrows the window when the content widens at an unchanged viewport width', () => { + withStubbedResizeObserver((notifyResize) => { + const { viewportRef, contentRef, groups } = makeStrip({ + viewportWidth: 1000, + groupPitch: 100, + groupCount: 20, + }); + const { result } = renderHook(() => + usePhraseWindowHalf(viewportRef, contentRef, () => groups[10]), + ); + expect(result.current).toBe(16); + + // Turning morpheme rows on widens every group without moving the panel. + groups.forEach((group, index) => placeGroup(group, index * 200)); + notifyResize(); + + expect(result.current).toBe(8); + }); + }); + + it('settles on the size its own sample measures, whatever the window mounts around it', () => { + withStubbedResizeObserver((notifyResize) => { + render(); + expect(screen.getAllByTestId('phrase-group')).toHaveLength(2 * SELF_SIZING_SETTLED_HALF + 1); + + // Every group the window just mounted is narrower than the sampled ones, so a measurement + // that averaged the whole row would read a smaller pitch and widen again on each pass. + notifyResize(); + notifyResize(); + + expect(screen.getAllByTestId('phrase-group')).toHaveLength(2 * SELF_SIZING_SETTLED_HALF + 1); + }); + }); +}); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 6d715212..02fa2191 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -4,7 +4,10 @@ import type { Book, InterlinearProject, PhraseAnalysisLink, Segment, Token } fro import { UnsubscriberAsyncList } from 'platform-bible-utils'; import { tokenizeBook } from 'parsers/papi/bookTokenizer'; import type { RawBook } from 'parsers/papi/usjBookExtractor'; -import type { PhraseStripContextValue } from '../components/PhraseStripContext'; +import { + TOKEN_CHIP_LABEL_KEYS, + type PhraseStripContextValue, +} from '../components/PhraseStripContext'; import { emptyAnalysis } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; @@ -89,6 +92,7 @@ export function makePhraseStripContext( boundaryMergeAltHint: '', boundarySplitLabel: '', glossPlaceholder: '', + tokenChipLabels: TOKEN_CHIP_LABEL_KEYS, skipLinkTransition: false, ...overrides, }; diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index efc7f86a..cb0286f2 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -21,6 +21,7 @@ import { usePhraseStripContextValue, } from '../hooks/usePhraseStripSetup'; import useLatestRef from '../hooks/useLatestRef'; +import usePhraseWindowHalf from '../hooks/usePhraseWindowHalf'; import MemoizedArcOverlay from './ArcOverlay'; import { RECENTER_FADE_MS, RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; @@ -50,12 +51,6 @@ const SCROLL_SETTLE_FALLBACK_MS = 600; */ const HOLD_CENTERED_MAX_MS = 2_000; -/** - * Number of phrase slots rendered on each side of the focused phrase. Chosen large enough that no - * realistic viewport can ever render all tokens simultaneously. - */ -const PHRASE_WINDOW_HALF = 100; - /** * Localized string keys this view needs. Hoisted to module scope so the reference passed to * `useLocalizedStrings` is stable across renders. A fresh array literal each render makes the PAPI @@ -293,6 +288,47 @@ export default function ContinuousView({ /** DOM ref array indexed by group index; used to scroll the focused phrase box into view. */ const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); + /** Ref-setter callbacks for {@link phraseRefs}, keyed by the group index each one writes. */ + const groupRefSetters = useRef(new Map void>()); + + /** + * Book that {@link phraseRefs} and {@link groupRefSetters} hold entries for. Both 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 both would keep growing to the + * largest book ever opened. Cleared during render rather than in an effect: refs for the new + * book's groups are written during the commit that precedes the effect, and clearing afterward + * would erase them. + */ + const refsBookIdRef = useRef(book.id); + if (refsBookIdRef.current !== book.id) { + refsBookIdRef.current = book.id; + phraseRefs.current = []; + groupRefSetters.current.clear(); + } + + /** + * 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 + * 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. + */ + const getGroupRefSetter = useCallback((groupIndex: number) => { + const setters = groupRefSetters.current; + let setter = setters.get(groupIndex); + if (!setter) { + setter = (el: HTMLSpanElement | null) => { + phraseRefs.current[groupIndex] = el; + }; + setters.set(groupIndex, setter); + } + return setter; + }, []); + /** Ref to the token-strip row; the content row and mouse-leave target. */ // eslint-disable-next-line no-null/no-null const stripRowRef = useRef(null); @@ -315,6 +351,14 @@ export default function ContinuousView({ }); }, []); + /** + * Cancel function of the most recently started {@link holdCentered}, so a hold can be dropped by + * whatever supersedes it: a newer hold, or a focus move that makes the held group the wrong one. + * What sits here may already have been canceled — canceling twice is a no-op, so nothing clears + * it. + */ + const activeHoldCancelRef = useRef<(() => void) | undefined>(undefined); + /** * Holds the group at `groupIndex` centered while the strip settles after an instant jump or the * committed-active-segment flip. Re-centers every animation frame — and, crucially, keeps holding @@ -337,11 +381,16 @@ export default function ContinuousView({ * {@link HOLD_CENTERED_MAX_MS} window (a bound so a strip that never stabilizes can't hold the * observer forever), and any reflow within it restarts the tick loop to re-center. * + * Starting a hold supersedes any hold already running, so callers need not cancel first. A hold + * pins one specific group index, and two alive at once would re-center to different places on + * alternating frames, leaving the strip wherever the later tick happened to land. + * * @returns A cancel function that stops the loop, the observer, and the hard-deadline timer; call * it from the owning effect's cleanup. */ const holdCentered = useCallback( (groupIndex: number) => { + activeHoldCancelRef.current?.(); // Quiet deadline for the tick loop only; extended on each reflow. Seeded one quiet period out // so a reflow-free jump still holds briefly. let quietDeadline = performance.now() + LINK_SLOT_TRANSITION_MS; @@ -377,11 +426,13 @@ export default function ContinuousView({ observer.disconnect(); stopped = true; }, HOLD_CENTERED_MAX_MS); - return () => { + const cancel = () => { clearTimeout(hardStopTimer); cancelAnimationFrame(rafId); observer.disconnect(); }; + activeHoldCancelRef.current = cancel; + return cancel; }, [centerGroup], ); @@ -492,9 +543,56 @@ export default function ContinuousView({ const atEnd = phraseGroups.length === 0 || focusPhraseIndex >= phraseGroups.length - 1; const stripOpacityClass = isVisible ? 'tw:opacity-100' : 'tw:opacity-0'; - /** The inclusive group-index bounds of the rendered window. */ - const renderWindowStart = Math.max(0, focusPhraseIndex - PHRASE_WINDOW_HALF); - const renderWindowEnd = Math.min(phraseGroups.length - 1, focusPhraseIndex + PHRASE_WINDOW_HALF); + /** Phrase groups mounted on each side of the focus, sized to the strip's visible width. */ + const phraseWindowHalf = usePhraseWindowHalf( + scrollViewportRef, + stripRowRef, + () => phraseRefs.current[focusPhraseIndex] ?? undefined, + ); + + /** + * First and last group index of each phrase, so the window can mount every fragment of a phrase + * it touches. Only a discontiguous phrase spans more than one group. + */ + const groupSpanByPhraseId = useMemo(() => { + const spans = new Map(); + phraseGroups.forEach((group, index) => { + const phraseId = group.phraseLink?.analysisId; + if (phraseId === undefined) return; + const span = spans.get(phraseId); + if (span) span.last = index; + else spans.set(phraseId, { first: index, last: index }); + }); + return spans; + }, [phraseGroups]); + + /** + * The inclusive group-index bounds of the rendered window, widened to cover every fragment of any + * phrase it touches. An arc is drawn between two mounted phrase boxes, so a phrase with one + * fragment left outside loses its arc altogether — including the leg that would have crossed the + * viewport — leaving the visible fragment with no phrase cue. + */ + const [renderWindowStart, renderWindowEnd] = useMemo(() => { + let start = Math.max(0, focusPhraseIndex - phraseWindowHalf); + let end = Math.min(phraseGroups.length - 1, focusPhraseIndex + phraseWindowHalf); + // 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. + let widened = true; + while (widened) { + widened = false; + for (let index = start; index <= end; index += 1) { + const phraseId = phraseGroups[index].phraseLink?.analysisId; + const span = phraseId === undefined ? undefined : groupSpanByPhraseId.get(phraseId); + if (span !== undefined && (span.first < start || span.last > end)) { + start = Math.min(start, span.first); + end = Math.max(end, span.last); + widened = true; + } + } + } + return [start, end]; + }, [focusPhraseIndex, phraseWindowHalf, phraseGroups, groupSpanByPhraseId]); /** * The groups in the rendered window. Memoized on the bounds (and the source groups) so the array @@ -550,22 +648,29 @@ export default function ContinuousView({ /** Moves focus one phrase forward. */ const stepNext = useCallback(() => step(1), [step]); + /** Ref mirror of the focus so the select handler can compare against it without a dep on it. */ + const focusedTokenRefRef = useLatestRef(focusedTokenRef); + /** * Notifies the parent that the user selected the phrase whose first token is `ref`. The parent * echoes the new token ref back through `focusedTokenRef`; scroll + highlight follow - * automatically. + * automatically. Selecting the already-focused phrase is a no-op. + * + * Reads the current focus through a ref rather than closing over it, so the handler keeps one + * identity across focus moves and passing it down cannot invalidate a memoized child. * * @param ref - First-token ref (group key) of the selected phrase. */ const handlePhraseSelect = useCallback( (ref: string) => { const targetGroupIndex = groupIndexByTokenRef.get(ref); + const currentFocus = focusedTokenRefRef.current; const currentGroupIndex = - focusedTokenRef === undefined ? undefined : groupIndexByTokenRef.get(focusedTokenRef); + currentFocus === undefined ? undefined : groupIndexByTokenRef.get(currentFocus); if (targetGroupIndex !== undefined && targetGroupIndex === currentGroupIndex) return; emitInternalFocus(ref); }, - [focusedTokenRef, groupIndexByTokenRef, emitInternalFocus], + [focusedTokenRefRef, groupIndexByTokenRef, emitInternalFocus], ); /** Splits a phrase arc at a token boundary and dispatches the resulting phrase-store writes. */ @@ -596,6 +701,12 @@ export default function ContinuousView({ // internal nav (the displayed ref was updated immediately, so the prop and display agree); snap // for external jumps (the displayed ref was just updated post-fade) and for the initial mount. useEffect(() => { + // Drop any hold still running: it pins the group the focus just left. Such a hold is armed by + // something the focus does not control (a window resize, an active-segment flip) and keeps + // watching the content row long after it, so the window slide this move causes would restart + // it and instant-scroll back every frame — overriding the glide below and leaving the strip + // parked on the phrase the reader navigated away from. + activeHoldCancelRef.current?.(); const isInternal = lastDisplayUpdateWasInternalRef.current; lastDisplayUpdateWasInternalRef.current = false; const isInitialLoad = isInitialLoadInProgressRef.current; @@ -711,6 +822,24 @@ export default function ContinuousView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [simplifyPhrases, showMorphology]); + // Re-center the focused group when the render window changes size. A wider window mounts groups + // *ahead* of the focus as well as behind it at an unchanged scroll offset, sliding the focused + // group sideways by their combined width — on a panel drag, far enough to carry the phrase the + // reader is working on off the strip. The focus itself has not moved, so no focus-keyed centering + // path fires, and the browser does not absorb it either: scroll anchoring adjusts the block axis + // only, and this strip scrolls on the inline axis. A layout effect, so the correction is in place + // before the shifted frame is painted rather than showing as a jump. The correction then holds: + // the groups the window just mounted finish laying out their glosses, morpheme rows, and arcs + // over the following frames, and every such reflow left of the focus shifts it again. A resize + // happens while the strip is otherwise idle, so no other hold is alive to absorb that late shift. + useLayoutEffect(() => { + centerGroup(focusPhraseIndex, 'auto'); + return holdCentered(focusPhraseIndex); + // focusPhraseIndex is intentionally excluded: it has its own scroll effect above. centerGroup + // and holdCentered are stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phraseWindowHalf]); + // When entering edit or confirm-unlink mode, smooth-scroll to the first group of the active // phrase by notifying the parent of the new focused token. Scroll then follows automatically // through focusedTokenRef → focusPhraseIndex. @@ -938,12 +1067,7 @@ export default function ContinuousView({ key: group.tokens[0].ref, group, isFocused: group.tokens.some((t) => t.ref === displayFocusedTokenRef), - // New closure per recomputation; React briefly nulls and reassigns each ref, but the - // cycle is synchronous and harmless. If renders become hot, move the assignment into - // MemoizedPhraseGroup (pass phraseRefs + groupIndex as props instead of a callback). - groupRef: (el: HTMLSpanElement | null) => { - phraseRefs.current[groupIndex] = el; - }, + groupRef: getGroupRefSetter(groupIndex), }; }), [ @@ -953,6 +1077,7 @@ export default function ContinuousView({ focusedSideIsPrevByItem, displayFocusedTokenRef, verseStartLabelByTokenRef, + getGroupRefSetter, ], ); @@ -976,7 +1101,12 @@ export default function ContinuousView({ data-testid="strip-scroll-viewport" ref={scrollViewportRef} className="tw:relative tw:flex-1" - style={{ overflowX: 'hidden', overflowY: 'visible' }} + // Hidden on both axes rather than clipped: the element has to stay a scroll container for + // `scrollIntoView` to center a phrase in it, which `overflow: clip` would give up. The + // block axis cannot be `visible` either — CSS computes a lone `visible` to `auto` when the + // other axis is neither `visible` nor `clip`, which would let a scrollbar appear and shrink + // the width the render-window measurement reads. + style={{ overflowX: 'hidden', overflowY: 'hidden' }} > {/* Previous fade overlay — only rendered when the previous arrow is enabled */} {!atStart && ( diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index b093dd28..02b2cd47 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -1,17 +1,9 @@ import type { MorphemeAnalysis, Token } from 'interlinearizer'; -import { useLocalizedStrings } from '@papi/frontend/react'; import { PopoverAnchor } from 'platform-bible-react'; import { formatReplacementString } from 'platform-bible-utils'; import { type MouseEvent, useEffect, useState } from 'react'; import { useMorphemeGlossDispatch, useReportGlossEditing } from './AnalysisStore'; - -const MORPHEME_GLOSS_STRING_KEYS = [ - '%interlinearizer_morphemeGloss_label%', -] as const satisfies `%${string}%`[]; - -const MORPHEME_BOX_STRING_KEYS = [ - '%interlinearizer_tokenChip_editMorphemes%', -] as const satisfies `%${string}%`[]; +import { TOKEN_CHIP_LABEL_KEYS, type TokenChipLabels } from './PhraseStripContext'; /** * Inline _display_ of an analyzed token's morpheme breakdown. The popover where forms are actually @@ -40,6 +32,7 @@ export function MorphemeBox({ popoverOpen, onEditBreakdown, onGlossFocus, + labels = TOKEN_CHIP_LABEL_KEYS, }: Readonly<{ /** The analyzed word token whose breakdown is shown. */ token: Token & { type: 'word' }; @@ -59,18 +52,19 @@ export function MorphemeBox({ * focusing one must move the view's focus just as focusing that input does. */ onGlossFocus: () => void; + /** + * Accessible labels for this box and its gloss inputs, resolved once per strip. Defaults to the + * unresolved keys, which is what they show until the strip's lookup lands. + */ + labels?: TokenChipLabels; }>) { - const [localizedStrings] = useLocalizedStrings(MORPHEME_BOX_STRING_KEYS); // Hovering anywhere in the box tints the whole forms row: clicking any cell opens the same // whole-breakdown editor, so the affordance is breakdown-wide, not per-morpheme. Tracking hover // on the container (rather than per cell) avoids a one-frame un-tint as the pointer crosses the // gap between adjacent form cells. const [isFormsHovered, setIsFormsHovered] = useState(false); - const editLabel = formatReplacementString( - localizedStrings['%interlinearizer_tokenChip_editMorphemes%'], - { token: token.surfaceText }, - ); + const editLabel = formatReplacementString(labels.editMorphemes, { token: token.surfaceText }); return ( @@ -139,6 +133,7 @@ export function MorphemeBox({ analysisLanguage={analysisLanguage} column={i + 1} disabled={disabled} + glossLabelTemplate={labels.morphemeGloss} morpheme={m} onFocus={onGlossFocus} tokenRef={token.ref} @@ -162,6 +157,7 @@ export function MorphemeGlossInput({ disabled, column, onFocus, + glossLabelTemplate = TOKEN_CHIP_LABEL_KEYS.morphemeGloss, }: Readonly<{ morpheme: MorphemeAnalysis; /** The token ref gloss writes are dispatched against. */ @@ -173,11 +169,16 @@ export function MorphemeGlossInput({ column: number; /** Called when the input receives focus, so the containing chip can report its token as focused. */ onFocus: () => void; + /** + * Accessible label for this input, with `{form}` still to be substituted for the morpheme's form. + * Resolved once per strip. Defaults to the unresolved key, which is what the input shows until + * the strip's lookup lands. + */ + glossLabelTemplate?: string; }>) { const committed = morpheme.gloss?.[analysisLanguage] ?? ''; const dispatchMorphemeGloss = useMorphemeGlossDispatch(); const [draft, setDraft] = useState(committed); - const [localizedStrings] = useLocalizedStrings(MORPHEME_GLOSS_STRING_KEYS); useEffect(() => { setDraft(committed); @@ -188,10 +189,7 @@ export function MorphemeGlossInput({ return ( ; + +/** + * The localize key each {@link TokenChipLabels} field resolves from. Doubles as the pre-resolution + * value of the bundle: the localization hook yields every key as its own value until its lookup + * lands, so a chip rendered before (or without) a strip's fetch shows exactly what it would show in + * that first frame. + */ +export const TOKEN_CHIP_LABEL_KEYS = { + glossLabel: '%interlinearizer_tokenChip_glossLabel%', + showSuggestions: '%interlinearizer_tokenChip_showSuggestions%', + defineMorphemes: '%interlinearizer_tokenChip_defineMorphemes%', + editMorphemes: '%interlinearizer_tokenChip_editMorphemes%', + morphemeGloss: '%interlinearizer_morphemeGloss_label%', + acceptSuggestion: '%interlinearizer_suggestion_accept%', + promoteSuggestion: '%interlinearizer_suggestion_promote%', +} as const satisfies Record; + /** * The stable, strip-wide context for one render of a token row: a single value is built per render * and provided around the row via {@link PhraseStripProvider}, reaching every phrase group and link @@ -102,6 +142,8 @@ export type PhraseStripContextValue = Readonly<{ * resolves (one strip-wide reflow at most, behind the initial fade). */ glossPlaceholder: string; + /** Labels every word token's chip formats for itself, resolved once per strip. */ + tokenChipLabels: TokenChipLabels; /** * When `true`, the sliding-door transition on link-slot wrappers is suppressed (duration set to * 0ms). Set during external navigation and initial mount so the layout snaps to its final state diff --git a/src/components/PhraseStripParts.tsx b/src/components/PhraseStripParts.tsx index 0fcb1cd3..f7a1e1db 100644 --- a/src/components/PhraseStripParts.tsx +++ b/src/components/PhraseStripParts.tsx @@ -1,4 +1,4 @@ -import type { Token } from 'interlinearizer'; +import type { PhraseAnalysisLink, Token } from 'interlinearizer'; import { Merge, Split } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; import { memo } from 'react'; @@ -388,6 +388,9 @@ export function PhraseSlot({ ); } +/** Memoized version of {@link PhraseSlot}; use in render-stable strips. */ +export const MemoizedPhraseSlot = memo(PhraseSlot); + // #endregion // #region PhraseGroup @@ -462,6 +465,9 @@ export const MemoizedPhraseGroup = memo(function PhraseGroup({ // The strip wrapper is `pointer-events-none` so its padding gaps let arc-split button clicks // through to the buttons beneath; re-enable events on the actual phrase content here. className="tw:pointer-events-auto" + // One element per group whatever the group renders as, so a count of these is a count of + // groups — unlike phrase boxes, of which a discontiguous phrase contributes several. + data-phrase-group="true" onMouseEnter={ allowHover ? () => { @@ -582,6 +588,22 @@ type PhraseStripProps = Readonly<{ onFocusPhrase: (groupKey: string) => void; }>; +/** + * Picks the fragment that carries a discontiguous phrase's single gloss input, so a phrase whose + * stored first token a baseline edit stranded keeps somewhere to show its gloss. Independent of how + * much of the strip is mounted, so the gloss does not travel between fragments as a windowed strip + * slides. + * + * @returns The ref of the phrase's earliest token the book still has, or `undefined` when it has + * none of them — which is also when no fragment of the phrase renders. + */ +function resolveGlossOwnerRef( + phraseLink: PhraseAnalysisLink, + tokenDocOrder: ReadonlyMap, +): string | undefined { + return phraseLink.tokens.find((t) => tokenDocOrder.has(t.tokenRef))?.tokenRef; +} + /** * Renders a complete phrase strip from normalized {@link StripItem}s: the alternating sequence of * {@link PhraseSlot}s and {@link PhraseGroup}s. Every per-group derivation (gloss-input @@ -601,12 +623,11 @@ export function PhraseStrip({ setHoveredGroupKey, onFocusPhrase, }: PhraseStripProps) { - const { simplifyPhrases } = usePhraseStripContext(); - const seenPhraseIds = new Set(); + const { simplifyPhrases, tokenDocOrder } = usePhraseStripContext(); return items.map((item) => { if (item.kind === 'slot') { return ( - void; @@ -100,8 +97,8 @@ export function TokenChip({ isSplitFree?: boolean; showMorphology?: boolean; glossPlaceholder?: string; + labels?: TokenChipLabels; }>) { - const [localizedStrings] = useLocalizedStrings(STRING_KEYS); const committedGloss = useGloss(token.ref); const onGlossChange = useGlossDispatch(); const morphemes = useMorphemes(token.ref); @@ -416,6 +413,7 @@ export function TokenChip({