Skip to content

Commit 1a87150

Browse files
Clamp activePhraseIndex, memoize allTokens words as tokenArrays, add coalescing nullish fallback for token chip gloss, extract names prop types for components
1 parent 4ee75af commit 1a87150

8 files changed

Lines changed: 101 additions & 40 deletions

File tree

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const config: Config = {
3030
'!src/**/__tests__/**',
3131
'!src/**/*.test.{ts,tsx}',
3232
'!src/**/*.spec.{ts,tsx}',
33+
'!src/components/component-types.ts',
3334
],
3435

3536
/** Directory for coverage output. */

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,12 @@ describe('ContinuousView rendering', () => {
395395
expect(screen.getByText('.')).toBeInTheDocument();
396396
});
397397

398+
it('renders without crashing when book has no word tokens and activePhraseIndex is set', () => {
399+
render(<ContinuousView book={makeWordFreeBook()} {...requiredProps()} activePhraseIndex={0} />);
400+
401+
expect(screen.getByText('.')).toBeInTheDocument();
402+
});
403+
398404
it('clicking an out-of-focus phrase box brings it into focus', async () => {
399405
render(<ContinuousView book={makeBook()} {...requiredProps()} />);
400406

src/__tests__/components/SegmentView.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,19 @@ const PUNCT_SEGMENT: Segment = {
9595
* @returns An object containing all required SegmentView props set to no-op stubs.
9696
*/
9797
function requiredProps(): {
98+
displayMode: 'token-chip';
99+
focusedTokenId: string | undefined;
98100
glosses: Record<string, string>;
101+
isActive: boolean;
99102
onGlossChange: (tokenId: string, value: string) => void;
100103
onSelect: (ref: ScriptureRef, tokenId?: string) => void;
101104
segment: Segment;
102105
} {
103106
return {
107+
displayMode: 'token-chip',
108+
focusedTokenId: undefined,
104109
glosses: {},
110+
isActive: false,
105111
onGlossChange: jest.fn(),
106112
onSelect: jest.fn(),
107113
segment: WORD_SEGMENT,

src/components/ContinuousView.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Book, ScriptureRef, Token } from 'interlinearizer';
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
import type { GlossHandlers } from './component-types';
34
import MemoizedPhraseBox from './PhraseBox';
45
import MemoizedTokenChip from './TokenChip';
56

@@ -13,6 +14,18 @@ function isWordToken(token: Token): token is Token & { type: 'word' } {
1314
return token.type === 'word';
1415
}
1516

17+
/**
18+
* Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero.
19+
*
20+
* @param index - The raw index to clamp.
21+
* @param len - Length of the target array.
22+
* @returns A safe index guaranteed to be within bounds.
23+
*/
24+
function clampIndex(index: number, len: number): number {
25+
if (len === 0) return 0;
26+
return Math.max(0, Math.min(index, len - 1));
27+
}
28+
1629
/**
1730
* CSS easing for the strip opacity fade-in/out animation. Uses a sine-like curve for a natural feel
1831
* at both ends of the transition.
@@ -31,6 +44,17 @@ const STRIP_FADE_MS = 500;
3144
*/
3245
const PHRASE_WINDOW_HALF = 100;
3346

47+
/** Props for {@link ContinuousView}. */
48+
type ContinuousViewProps = Readonly<
49+
GlossHandlers & {
50+
activePhraseIndex: number | undefined;
51+
activeVerse: ScriptureRef;
52+
book: Book;
53+
onFocusPhraseIndexChange: (index: number) => void;
54+
onVerseChange: (verse: ScriptureRef) => void;
55+
}
56+
>;
57+
3458
/**
3559
* Renders all tokens from every segment in the given book as a single flat, horizontally scrollable
3660
* strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling
@@ -66,15 +90,7 @@ export default function ContinuousView({
6690
onFocusPhraseIndexChange,
6791
onGlossChange,
6892
onVerseChange,
69-
}: Readonly<{
70-
activePhraseIndex: number | undefined;
71-
activeVerse: ScriptureRef;
72-
book: Book;
73-
glosses: Record<string, string>;
74-
onFocusPhraseIndexChange: (index: number) => void;
75-
onGlossChange: (tokenId: string, value: string) => void;
76-
onVerseChange: (verse: ScriptureRef) => void;
77-
}>) {
93+
}: ContinuousViewProps) {
7894
const isRtl = document.documentElement.dir === 'rtl';
7995

8096
const allTokens: Token[] = useMemo(
@@ -104,6 +120,15 @@ export default function ContinuousView({
104120
[allTokens],
105121
);
106122

123+
/**
124+
* Stable single-token arrays indexed by position in `allTokens`, so `MemoizedPhraseBox` receives
125+
* the same array reference across renders and shallow memo comparison holds.
126+
*/
127+
const tokenArrays = useMemo(
128+
() => allTokens.map((token) => (isWordToken(token) ? [token] : [])),
129+
[allTokens],
130+
);
131+
107132
/**
108133
* Ref mirror of `phraseEntries`. Read inside effects and callbacks that need the latest list
109134
* without declaring it as a dependency (which would cause spurious re-runs).
@@ -166,7 +191,7 @@ export default function ContinuousView({
166191
// correctly before the initial-load fade-in fires. Prefer activePhraseIndex (e.g. a focused token
167192
// carried over from segment view) so there is no flash to the verse-start position on mount.
168193
const [focusPhraseIndex, setFocusPhraseIndex] = useState<number>(() => {
169-
if (activePhraseIndex !== undefined) return activePhraseIndex;
194+
if (activePhraseIndex !== undefined) return clampIndex(activePhraseIndex, phraseEntries.length);
170195

171196
const seg = book.segments.find(
172197
(s) =>
@@ -226,10 +251,11 @@ export default function ContinuousView({
226251
return;
227252
}
228253

229-
jumpTargetRef.current = activePhraseIndex;
254+
const clamped = clampIndex(activePhraseIndex, phraseEntriesRef.current.length);
255+
jumpTargetRef.current = clamped;
230256
isExternalJumpInProgressRef.current = true;
231257
setIsVisible(false);
232-
setPendingExternalJumpPhraseIndex(activePhraseIndex);
258+
setPendingExternalJumpPhraseIndex(clamped);
233259
}, [activePhraseIndex]);
234260

235261
// Jump to the first token of the matching segment when the active verse changes.
@@ -466,7 +492,7 @@ export default function ContinuousView({
466492
isFocused={isFocusedPhrase}
467493
onClick={handlePhraseSelect}
468494
onGlossChange={onGlossChange}
469-
tokens={[token]}
495+
tokens={tokenArrays[tokenIndex]}
470496
/>
471497
</span>
472498
);

src/components/Interlinearizer.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
import ContinuousView from './ContinuousView';
55
import MemoizedSegmentView from './SegmentView';
66

7+
/** Props for {@link Interlinearizer}. */
8+
type InterlinearizerProps = Readonly<{
9+
book: Book;
10+
bookSegments: Segment[];
11+
continuousScroll: boolean;
12+
scrRef: SerializedVerseRef;
13+
setScrRef: (newScrRef: SerializedVerseRef) => void;
14+
}>;
15+
716
/**
817
* Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top,
918
* followed by segmented views.
@@ -22,13 +31,7 @@ export default function Interlinearizer({
2231
continuousScroll,
2332
scrRef,
2433
setScrRef,
25-
}: Readonly<{
26-
book: Book;
27-
bookSegments: Segment[];
28-
continuousScroll: boolean;
29-
scrRef: SerializedVerseRef;
30-
setScrRef: (newScrRef: SerializedVerseRef) => void;
31-
}>) {
34+
}: InterlinearizerProps) {
3235
const [glosses, setGlosses] = useState<Record<string, string>>({});
3336
const [focusedTokenId, setFocusedTokenId] = useState<string | undefined>(undefined);
3437

src/components/PhraseBox.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
/** @file Shared phrase-box wrapper used around word tokens. */
22
import type { Token } from 'interlinearizer';
33
import { memo } from 'react';
4+
import type { GlossHandlers } from './component-types';
45
import MemoizedTokenChip from './TokenChip';
56

7+
/** Props for {@link PhraseBox}. */
8+
type PhraseBoxProps = Readonly<
9+
GlossHandlers & {
10+
index?: number;
11+
isFocused: boolean;
12+
onClick?: (index?: number) => void;
13+
tokens: (Token & { type: 'word' })[];
14+
}
15+
>;
16+
617
/**
718
* Wraps one or more tokens in a phrase-level visual container.
819
*
@@ -17,21 +28,15 @@ import MemoizedTokenChip from './TokenChip';
1728
* @param props.tokens - Tokens belonging to this phrase
1829
* @returns A bordered inline container
1930
*/
31+
2032
export function PhraseBox({
2133
glosses,
2234
index,
2335
isFocused = false,
2436
onClick,
2537
onGlossChange,
2638
tokens,
27-
}: Readonly<{
28-
glosses: Record<string, string>;
29-
index?: number;
30-
isFocused: boolean;
31-
onClick?: (index?: number) => void;
32-
onGlossChange: (tokenId: string, value: string) => void;
33-
tokens: (Token & { type: 'word' })[];
34-
}>) {
39+
}: PhraseBoxProps) {
3540
const baseClass = isFocused
3641
? 'tw:inline-flex tw:items-center tw:rounded tw:border-2 tw:border-white tw:bg-muted/30 tw:px-1 tw:py-0.5'
3742
: 'tw:inline-flex tw:items-center tw:rounded tw:border tw:border-border/40 tw:bg-muted/20 tw:px-1 tw:py-0.5';
@@ -40,7 +45,7 @@ export function PhraseBox({
4045
{tokens.map((token) => (
4146
<MemoizedTokenChip
4247
key={token.id}
43-
gloss={glosses[token.id]}
48+
gloss={glosses[token.id] ?? ''}
4449
onFocus={() => onClick?.(index)}
4550
onGlossChange={(value) => onGlossChange(token.id, value)}
4651
token={token}

src/components/SegmentView.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ScriptureRef, Segment, Token } from 'interlinearizer';
22
import { memo, useCallback, useMemo } from 'react';
3+
import type { GlossHandlers } from './component-types';
34
import MemoizedPhraseBox from './PhraseBox';
45
import MemoizedTokenChip from './TokenChip';
56

@@ -23,11 +24,22 @@ function isWordToken(token: Token): token is Token & { type: 'word' } {
2324
*/
2425
export type SegmentDisplayMode = 'token-chip' | 'baseline-text';
2526

27+
/** Props for {@link SegmentView}. */
28+
type SegmentViewProps = Readonly<
29+
GlossHandlers & {
30+
displayMode: SegmentDisplayMode;
31+
focusedTokenId: string | undefined;
32+
isActive: boolean;
33+
onSelect: (ref: ScriptureRef, tokenId?: string) => void;
34+
segment: Segment;
35+
}
36+
>;
37+
2638
/**
2739
* Renders a single segment as either inline token chips or plain baseline text.
2840
*
2941
* @param props - Component props
30-
* @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'`
42+
* @param props.displayMode - Controls how segment content is rendered
3143
* @param props.focusedTokenId - When set, the matching word token's `PhraseBox` is rendered in the
3244
* focused state; only meaningful in `token-chip` mode.
3345
* @param props.glosses - Map from `Token.id` to current English gloss text for tokens in this
@@ -43,22 +55,14 @@ export type SegmentDisplayMode = 'token-chip' | 'baseline-text';
4355
* segment content
4456
*/
4557
export function SegmentView({
46-
displayMode = 'token-chip',
58+
displayMode,
4759
focusedTokenId,
4860
glosses,
4961
isActive,
5062
onGlossChange,
5163
onSelect,
5264
segment,
53-
}: Readonly<{
54-
displayMode?: SegmentDisplayMode;
55-
focusedTokenId?: string;
56-
glosses: Record<string, string>;
57-
isActive?: boolean;
58-
onGlossChange: (tokenId: string, value: string) => void;
59-
onSelect: (ref: ScriptureRef, tokenId?: string) => void;
60-
segment: Segment;
61-
}>) {
65+
}: SegmentViewProps) {
6266
const { book, chapter, verse } = segment.startRef;
6367
const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]);
6468

src/components/component-types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Props shared by components that display and edit token glosses.
3+
*
4+
* @field glosses - Map from `Token.id` to current gloss text.
5+
* @field onGlossChange - Called with the token id and new value when a gloss is edited.
6+
*/
7+
export type GlossHandlers = {
8+
glosses: Record<string, string>;
9+
onGlossChange: (tokenId: string, value: string) => void;
10+
};

0 commit comments

Comments
 (0)