Skip to content

Commit 814b41f

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 e35c0ff commit 814b41f

5 files changed

Lines changed: 86 additions & 38 deletions

File tree

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 or `index` is `undefined`.
19+
*
20+
* @param index - The raw index to clamp; `undefined` is treated as `0`.
21+
* @param len - Length of the target array.
22+
* @returns A safe index guaranteed to be within bounds.
23+
*/
24+
function clampIndex(index: number | undefined, len: number): number {
25+
if (len === 0) return 0;
26+
return Math.max(0, Math.min(index ?? 0, 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: 13 additions & 9 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,6 +24,17 @@ 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;
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
*
@@ -50,15 +62,7 @@ export function SegmentView({
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)