11import type { Book , ScriptureRef , Token } from 'interlinearizer' ;
22import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
3+ import type { GlossHandlers } from './component-types' ;
34import MemoizedPhraseBox from './PhraseBox' ;
45import 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 */
3245const 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 ) ;
0 commit comments