Skip to content

Commit b9dec18

Browse files
alex-rawlings-yycimnasnainaecclaude
authored
Refine segment view (#98)
* Change segment view to allow for infinite scroll within a book * Fix external scrRef change-related issues (UNFINISHED) * Adjust animation/fade timing * Refactor scrRef navigation with new hook * Post-refactor adjustments * Hoist SegmentListView into own component, fix lingering external nav fade/realignment timing issues, add chapter markers and associated setting * Cleanup * Fix snapping issues * Extract useLatestRef/useRecenterSnap hooks, fix verse-0 echo nav * Add shared hooks to reduce odds of drift * Minor improvements * Minor adjustments * Stabilize duplicate results from PAPI scripture picker * Update doc * Fix alignment issues when toggling Continuous Scroll Mode * Prevent scroll from going past final segment * Clear timeout for stale fade-in * Add same verse ref guard to handleSegmentSelect * Improve test coverage * Minor adjustments * Refactor useSegmentWindow hook * Fix flicker after external nav double-sends new ref * Address review findings: navigation edge cases, dropdown stacking, verse-0 dead code (#103) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Cleanup --------- Co-authored-by: D. Ror. <imnasnainaec@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 171afdc commit b9dec18

38 files changed

Lines changed: 5854 additions & 839 deletions

contributions/localizedStrings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
"%interlinearizer_viewOption_continuousScroll%": "Continuous Scroll",
1818
"%interlinearizer_viewOption_hideInactiveLinkButtons%": "Hide out-of-segment link buttons",
1919
"%interlinearizer_viewOption_simplifyPhrases%": "Show phrase controls on focus only",
20+
"%interlinearizer_viewOption_chapterLabelInVerse%": "Show chapter in verse label",
2021
"%interlinearizer_projectSettings_hideInactiveLinkButtons%": "Hide Out-of-Segment Link Buttons",
2122
"%interlinearizer_projectSettings_hideInactiveLinkButtonsDescription%": "Hide link buttons between phrases in segments that are not currently active",
2223
"%interlinearizer_projectSettings_simplifyPhrases%": "Show Phrase Controls on Focus Only",
2324
"%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover",
25+
"%interlinearizer_projectSettings_chapterLabelInVerse%": "Show Chapter in Verse Label",
26+
"%interlinearizer_projectSettings_chapterLabelInVerseDescription%": "Mark chapter boundaries by labeling the first verse of each chapter as chapter:verse instead of showing an inline chapter header above it",
2427
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",
2528

2629
"%interlinearizer_modal_create_title%": "Create Interlinear Project",

contributions/projectSettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
"label": "%interlinearizer_projectSettings_simplifyPhrases%",
1717
"description": "%interlinearizer_projectSettings_simplifyPhrasesDescription%",
1818
"default": false
19+
},
20+
"interlinearizer.chapterLabelInVerse": {
21+
"label": "%interlinearizer_projectSettings_chapterLabelInVerse%",
22+
"description": "%interlinearizer_projectSettings_chapterLabelInVerseDescription%",
23+
"default": false
1924
}
2025
}
2126
}

cspell.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
"affordances",
1717
"appdata",
1818
"bara",
19+
"baselining",
1920
"BBCCCVVV",
21+
"clickability",
22+
"cullable",
2023
"deconflict",
2124
"deconfliction",
2225
"deconflicts",
@@ -36,15 +39,22 @@
3639
"labelable",
3740
"lightningcss",
3841
"morphosyntactic",
42+
"navigations",
3943
"nums",
4044
"okina",
45+
"overscan",
4146
"papi",
4247
"paranext",
4348
"paratext",
4449
"pdpf",
4550
"plusplus",
4651
"punct",
52+
"rebaseline",
53+
"recentered",
54+
"recentering",
55+
"recenters",
4756
"relayout",
57+
"resnap",
4858
"sandboxed",
4959
"scriptio",
5060
"scrollers",
@@ -54,6 +64,7 @@
5464
"Stylesheet",
5565
"typedefs",
5666
"unhover",
67+
"unobserves",
5768
"unphrased",
5869
"unreviewed",
5970
"unsub",

jest.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ const config: Config = {
108108
modulePathIgnorePatterns: ['<rootDir>/dist'],
109109

110110
/** Load @testing-library/jest-dom matchers and browser API stubs for React component tests. */
111-
setupFilesAfterEnv: ['<rootDir>/jest.setup.resize-observer.js', '<rootDir>/jest.setup.ts'],
111+
setupFilesAfterEnv: [
112+
'<rootDir>/jest.setup.resize-observer.js',
113+
'<rootDir>/jest.setup.intersection-observer.js',
114+
'<rootDir>/jest.setup.ts',
115+
],
112116

113117
/** Use jsdom for React component tests; parser tests run fine in jsdom (no DOM use). */
114118
testEnvironment: 'jsdom',
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable no-underscore-dangle */
2+
// jsdom does not implement IntersectionObserver; stub it so hooks that use it don't throw.
3+
// Plain JS to avoid TypeScript/ESLint restrictions on type assertions and class rules.
4+
//
5+
// The stub records every constructed observer and the elements it observes on `global.ioInstances`
6+
// so tests can drive intersections deterministically. `triggerIntersection(el, isIntersecting)`
7+
// finds the observer watching `el` and invokes its callback with a minimal entry.
8+
global.ioInstances = [];
9+
10+
global.IntersectionObserver = function IntersectionObserver(callback, options) {
11+
const targets = new Set();
12+
const instance = {
13+
callback,
14+
options,
15+
targets,
16+
observe(el) {
17+
targets.add(el);
18+
},
19+
unobserve(el) {
20+
targets.delete(el);
21+
},
22+
disconnect() {
23+
targets.clear();
24+
const i = global.ioInstances.indexOf(instance);
25+
if (i !== -1) global.ioInstances.splice(i, 1);
26+
},
27+
takeRecords() {
28+
return [];
29+
},
30+
};
31+
global.ioInstances.push(instance);
32+
return instance;
33+
};
34+
35+
// Fires an intersection for `el` on whichever observer is watching it.
36+
global.triggerIntersection = function triggerIntersection(el, isIntersecting) {
37+
global.ioInstances.forEach((instance) => {
38+
if (instance.targets.has(el)) {
39+
instance.callback([{ target: el, isIntersecting }], instance);
40+
}
41+
});
42+
};

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -375,17 +375,24 @@ const scrollIntoViewMock = jest.fn();
375375
*/
376376
function buildLookups(book: Book): {
377377
tokenSegmentMap: ReadonlyMap<string, string>;
378+
tokenDocOrder: ReadonlyMap<string, number>;
378379
wordTokenByRef: ReadonlyMap<string, Token & { type: 'word' }>;
379380
} {
380381
const tokenSegmentMap = new Map<string, string>();
382+
const tokenDocOrder = new Map<string, number>();
381383
const wordTokenByRef = new Map<string, Token & { type: 'word' }>();
384+
let wordIndex = 0;
382385
book.segments.forEach((seg) => {
383386
seg.tokens.forEach((t) => {
384387
tokenSegmentMap.set(t.ref, seg.id);
385-
if (isWordToken(t)) wordTokenByRef.set(t.ref, t);
388+
if (isWordToken(t)) {
389+
wordTokenByRef.set(t.ref, t);
390+
tokenDocOrder.set(t.ref, wordIndex);
391+
wordIndex += 1;
392+
}
386393
});
387394
});
388-
return { tokenSegmentMap, wordTokenByRef };
395+
return { tokenSegmentMap, tokenDocOrder, wordTokenByRef };
389396
}
390397

391398
/**
@@ -408,11 +415,12 @@ function requiredProps(
408415
phraseMode: { kind: 'view' };
409416
setPhraseMode: jest.Mock;
410417
tokenSegmentMap: ReadonlyMap<string, string>;
418+
tokenDocOrder: ReadonlyMap<string, number>;
411419
wordTokenByRef: ReadonlyMap<string, Token & { type: 'word' }>;
412420
hideInactiveLinkButtons: boolean;
413421
simplifyPhrases: boolean;
414422
} {
415-
const { tokenSegmentMap, wordTokenByRef } = buildLookups(book);
423+
const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book);
416424
return {
417425
book,
418426
editPhraseSegmentId: undefined,
@@ -421,6 +429,7 @@ function requiredProps(
421429
phraseMode: { kind: 'view' },
422430
setPhraseMode: jest.fn(),
423431
tokenSegmentMap,
432+
tokenDocOrder,
424433
wordTokenByRef,
425434
hideInactiveLinkButtons: false,
426435
simplifyPhrases: false,
@@ -524,6 +533,71 @@ describe('ContinuousView initial render', () => {
524533
const focusedBox = screen.getByText('beginning').closest('[data-phrase-box="true"]');
525534
expect(focusedBox).toHaveAttribute('data-focus-state', 'focused');
526535
});
536+
537+
it('falls back to focusedTokenRef when the lagging displayed ref is from another book', () => {
538+
// During a book change displayFocusedTokenRef lags by one fade, so it briefly names a token from
539+
// the previous book that no longer exists in the new book. The focus must follow the live
540+
// focusedTokenRef (the new book's active verse) rather than collapsing to the book's first phrase.
541+
const book = makeBook();
542+
const { rerender } = render(
543+
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'tok-2' })} />,
544+
withAnalysisStore,
545+
);
546+
547+
// Swap to a different book whose token refs share none of the previous book's. The displayed ref
548+
// ('tok-2') is now absent; focusedTokenRef points at the new book's *second* phrase.
549+
const otherBook: Book = {
550+
id: 'MAT',
551+
bookRef: 'MAT',
552+
textVersion: '1',
553+
segments: [
554+
{
555+
id: 'MAT 1:1',
556+
startRef: { book: 'MAT', chapter: 1, verse: 1 },
557+
endRef: { book: 'MAT', chapter: 1, verse: 1 },
558+
baselineText: 'Alpha',
559+
tokens: [
560+
{
561+
ref: 'mat-tok-0',
562+
surfaceText: 'Alpha',
563+
writingSystem: 'en',
564+
type: 'word',
565+
charStart: 0,
566+
charEnd: 5,
567+
},
568+
],
569+
},
570+
{
571+
id: 'MAT 1:2',
572+
startRef: { book: 'MAT', chapter: 1, verse: 2 },
573+
endRef: { book: 'MAT', chapter: 1, verse: 2 },
574+
baselineText: 'Beta',
575+
tokens: [
576+
{
577+
ref: 'mat-tok-1',
578+
surfaceText: 'Beta',
579+
writingSystem: 'en',
580+
type: 'word',
581+
charStart: 0,
582+
charEnd: 4,
583+
},
584+
],
585+
},
586+
],
587+
};
588+
589+
scrollIntoViewMock.mockClear();
590+
rerender(<ContinuousView {...requiredProps(otherBook, { focusedTokenRef: 'mat-tok-1' })} />);
591+
592+
// The scroll target is resolved through focusPhraseIndex, which falls back to focusedTokenRef
593+
// ('mat-tok-1', the second phrase) rather than collapsing to phrase 0. So the element scrolled
594+
// into view is the one containing "Beta", never "Alpha".
595+
const scrolledTexts = scrollIntoViewMock.mock.contexts.map((el) =>
596+
el instanceof HTMLElement ? el.textContent : undefined,
597+
);
598+
expect(scrolledTexts.some((t) => t?.includes('Beta'))).toBe(true);
599+
expect(scrolledTexts.some((t) => t?.includes('Alpha'))).toBe(false);
600+
});
527601
});
528602

529603
// ---------------------------------------------------------------------------
@@ -719,6 +793,31 @@ describe('ContinuousView arrow navigation', () => {
719793
expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-1');
720794
expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-2');
721795
});
796+
797+
it('steps from the externally-imposed focus, not the stale pending index, after an external change interrupts an in-flight internal nav', async () => {
798+
// Sequence: an external nav (tok-3) starts its fade while tok-1 is still displayed; the user
799+
// clicks Next during the fade (internal nav in flight — this parent never echoes it); then a
800+
// second external change lands back on the still-displayed tok-1. Because that value equals the
801+
// displayed ref, the focus-change effect early-returns without clearing the in-flight marker,
802+
// so only the render-phase external-override detection resyncs the pending index. Without it,
803+
// the next step would advance from the stale pending index (group 2 → tok-1) instead of the
804+
// externally-imposed position (group 1 → tok-0).
805+
const book = makeBook();
806+
const props = requiredProps(book, { focusedTokenRef: 'tok-1' });
807+
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
808+
809+
// External nav while idle: the fade starts; the displayed focus is still tok-1.
810+
rerender(<ContinuousView {...props} focusedTokenRef="tok-3" />);
811+
// Internal nav in flight: Next from the displayed group (tok-1) emits tok-2.
812+
await userEvent.click(screen.getByRole('button', { name: 'Next token' }));
813+
expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(1, 'tok-2');
814+
815+
// The parent imposes an external position (not the tok-2 echo) that matches the displayed ref.
816+
rerender(<ContinuousView {...props} focusedTokenRef="tok-1" />);
817+
818+
await userEvent.click(screen.getByRole('button', { name: 'Previous token' }));
819+
expect(props.onFocusedTokenRefChange).toHaveBeenNthCalledWith(2, 'tok-0');
820+
});
722821
});
723822

724823
// ---------------------------------------------------------------------------
@@ -755,13 +854,36 @@ describe('ContinuousView scroll behavior', () => {
755854
expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' }));
756855
});
757856

857+
it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => {
858+
const book = makeBook();
859+
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
860+
const { container, rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
861+
862+
act(() => {
863+
jest.useFakeTimers();
864+
});
865+
// External nav into the other verse: the active segment commits instantly behind the fade, so
866+
// the slots must snap to their new widths rather than animating (which would slide the boxes for
867+
// ~200ms after the strip fades back in).
868+
rerender(<ContinuousView {...{ ...props, focusedTokenRef: 'tok-3' }} />);
869+
870+
const slotWrapper = container.querySelector('[data-link-slot] > span');
871+
if (!(slotWrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span');
872+
expect(slotWrapper.style.transitionDuration).toBe('0ms');
873+
874+
act(() => {
875+
jest.advanceTimersByTime(600);
876+
jest.useRealTimers();
877+
});
878+
});
879+
758880
it('smooth-scrolls for internal nav once the parent echoes the ref back synchronously', async () => {
759881
// The smooth-scroll path requires the displayed focus to already agree with the prop and the
760882
// strip to be visible when the scroll effect runs. That only happens when a real (stateful)
761883
// parent reflects the internal ref change straight back, so simulate one here rather than
762884
// driving the ref via a jest.fn() that never updates the prop.
763885
const book = makeBook();
764-
const { tokenSegmentMap, wordTokenByRef } = buildLookups(book);
886+
const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book);
765887
function Parent() {
766888
const [ref, setRef] = useState<string | undefined>('tok-0');
767889
return (
@@ -773,6 +895,7 @@ describe('ContinuousView scroll behavior', () => {
773895
phraseMode={{ kind: 'view' }}
774896
setPhraseMode={jest.fn()}
775897
tokenSegmentMap={tokenSegmentMap}
898+
tokenDocOrder={tokenDocOrder}
776899
wordTokenByRef={wordTokenByRef}
777900
hideInactiveLinkButtons={false}
778901
simplifyPhrases={false}
@@ -806,7 +929,7 @@ describe('ContinuousView scroll behavior', () => {
806929
*/
807930
function renderHideInactiveCrossing(): () => boolean {
808931
const book = makeBook();
809-
const { tokenSegmentMap, wordTokenByRef } = buildLookups(book);
932+
const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book);
810933
function Parent() {
811934
const [ref, setRef] = useState<string | undefined>('tok-1');
812935
return (
@@ -818,6 +941,7 @@ describe('ContinuousView scroll behavior', () => {
818941
phraseMode={{ kind: 'view' }}
819942
setPhraseMode={jest.fn()}
820943
tokenSegmentMap={tokenSegmentMap}
944+
tokenDocOrder={tokenDocOrder}
821945
wordTokenByRef={wordTokenByRef}
822946
hideInactiveLinkButtons
823947
simplifyPhrases={false}

0 commit comments

Comments
 (0)