{continuousScroll && (
)}
-
- {bookSegments.length === 0 && (
+
+ {chapterSegments.length === 0 && (
No verse data for {scrRef.book} {scrRef.chapterNum}.
)}
- {bookSegments.length > 0 && (
-
- {bookSegments.map((seg) => (
-
- ))}
-
+ {chapterSegments.length > 0 && (
+ <>
+
+
+
+
+
+ {chapterSegments.map((seg) => (
+
+ ))}
+
+ >
)}
);
}
+
+/**
+ * Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top,
+ * followed by segmented views. Wraps the layout in an {@link AnalysisStoreProvider} so all
+ * descendant components can read and write analysis data without prop drilling.
+ *
+ * @param props - Component props
+ * @param props.book - Book data used by the continuous view
+ * @param props.chapterSegments - Segments to render as individual verse views
+ * @param props.continuousScroll - Whether the continuous scroll view is shown
+ * @param props.scrRef - Current scripture reference
+ * @param props.setScrRef - Callback to update the scripture reference
+ * @param props.initialAnalysis - Seed analysis data for the store; not reactive after mount
+ * @param props.analysisLanguage - BCP 47 tag for gloss read/write
+ * @param props.onSaveAnalysis - Called after each gloss write with the updated `TextAnalysis`
+ * @returns The full interlinearizer layout with optional continuous strip and segment list
+ */
+export default function Interlinearizer({
+ initialAnalysis,
+ analysisLanguage,
+ onSaveAnalysis,
+ ...innerProps
+}: InterlinearizerProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx
index 55368cdc..a5220ff1 100644
--- a/src/components/InterlinearizerLoader.tsx
+++ b/src/components/InterlinearizerLoader.tsx
@@ -13,6 +13,7 @@ import Interlinearizer from './Interlinearizer';
import ProjectModals, { type ModalState } from './ProjectModals';
import ScriptureNavControls from './ScriptureNavControls';
+/** Localized string keys used by {@link InterlinearizerLoader}. */
const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%'];
/**
@@ -24,10 +25,10 @@ const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%'
* @param props.projectId - PAPI project ID passed from the host
* @param props.useWebViewScrollGroupScrRef - Hook that exposes the shared scroll-group scripture
* reference and its setter
- * @param props.useWebViewState - Hook for reading and writing values persisted in the WebView's
- * saved state (survives tab restores)
- * @returns The interlinearizer layout: tab toolbar, loading/error states or main view, and any
- * currently open project modal.
+ * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted
+ * by the PAPI host
+ * @returns The toolbar and either an error/loading state or the fully rendered
+ * {@link Interlinearizer}
*/
export default function InterlinearizerLoader({
projectId,
@@ -41,6 +42,11 @@ export default function InterlinearizerLoader({
const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef();
const [interfaceMode] = useSetting('platform.interfaceMode', 'simple');
+ const [interfaceLanguages] = useSetting('platform.interfaceLanguage', ['und']);
+ /* v8 ignore next 3 -- useSetting never returns PlatformError for this key in practice */
+ const analysisLanguage = isPlatformError(interfaceLanguages)
+ ? 'und'
+ : interfaceLanguages[0] || 'und';
const {
isLoading: isSettingLoading,
@@ -182,10 +188,11 @@ export default function InterlinearizerLoader({
) : (
)}
diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx
index e0dd946c..101683f3 100644
--- a/src/components/PhraseBox.tsx
+++ b/src/components/PhraseBox.tsx
@@ -1,65 +1,53 @@
/** @file Shared phrase-box wrapper used around word tokens. */
import type { Token } from 'interlinearizer';
-import { memo } from 'react';
+import { memo, useCallback } from 'react';
import MemoizedTokenChip from './TokenChip';
+/** Props for {@link PhraseBox}. */
+type PhraseBoxProps = Readonly<{
+ /** Index passed back to `onFocusPhrase` to identify which phrase gained focus. */
+ index: number | undefined;
+ /** Whether this phrase is the current navigation focus. */
+ isFocused: boolean;
+ /** Called with `index` when any child gloss input receives focus. */
+ onFocusPhrase: (index?: number) => void;
+ /** Word tokens belonging to this phrase; must all have `type: 'word'`. */
+ tokens: (Token & { type: 'word' })[];
+}>;
+
/**
* Wraps one or more tokens in a phrase-level visual container.
*
* @param props - Component props
- * @param props.index - Index passed back to `onClick` to identify which phrase was clicked
+ * @param props.index - Index passed back to `onFocusPhrase` to identify which phrase was focused
* @param props.isFocused - Whether this phrase is the current navigation focus
- * @param props.onClick - Called with `index` when the phrase is clicked; omit to render a
- * non-interactive span
+ * @param props.onFocusPhrase - Called with `index` when any child gloss input receives focus
* @param props.tokens - Tokens belonging to this phrase
* @returns A bordered inline container
*/
-export function PhraseBox({
- index,
- isFocused = false,
- onClick,
- tokens,
-}: Readonly<{
- index?: number;
- isFocused?: boolean;
- onClick?: (index?: number) => void;
- tokens: Token[];
-}>) {
+export function PhraseBox({ index, isFocused = false, onFocusPhrase, tokens }: PhraseBoxProps) {
const baseClass = isFocused
- ? 'tw:inline-flex tw:items-center tw:rounded tw:border-2 tw:border-border tw:bg-muted/30 tw:px-1 tw:py-0.5'
+ ? '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'
: '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';
- const innerContent = (
-
- {tokens.map((token) => (
-
- ))}
-
- );
- if (onClick) {
- return (
-
- );
- }
+ /** Notifies the parent when a child gloss input receives focus. */
+ const handleFocus = useCallback(() => onFocusPhrase(index), [onFocusPhrase, index]);
return (
-
- {innerContent}
-
+
+ {tokens.map((token) => (
+
+ ))}
+
+
);
}
+/** Memoized version of {@link PhraseBox}; use in render-stable phrase lists. */
const MemoizedPhraseBox = memo(PhraseBox);
export default MemoizedPhraseBox;
diff --git a/src/components/ProjectModals.tsx b/src/components/ProjectModals.tsx
index 93666b24..931db1c8 100644
--- a/src/components/ProjectModals.tsx
+++ b/src/components/ProjectModals.tsx
@@ -5,7 +5,7 @@ import { CreateProjectModal } from './CreateProjectModal';
import { ProjectMetadataModal } from './ProjectMetadataModal';
import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal';
-/** Which modal is currently visible. Only one can be open at a time. */
+/** Which project-related modal is currently open; `'none'` means no modal is visible. */
export type ModalState = 'none' | 'select' | 'create' | 'metadata';
/**
diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx
index be7d1d78..153a4d90 100644
--- a/src/components/SegmentView.tsx
+++ b/src/components/SegmentView.tsx
@@ -1,7 +1,8 @@
import type { ScriptureRef, Segment } from 'interlinearizer';
-import { memo } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { isWordToken } from './component-types';
import MemoizedPhraseBox from './PhraseBox';
-import MemoizedTokenChip from './TokenChip';
+import { MemoizedInertTokenChip } from './TokenChip';
/**
* The two display modes for {@link SegmentView}.
@@ -13,57 +14,125 @@ import MemoizedTokenChip from './TokenChip';
*/
export type SegmentDisplayMode = 'token-chip' | 'baseline-text';
+/** Props for {@link SegmentView}. */
+type SegmentViewProps = Readonly<{
+ /** Controls whether tokens are rendered as chips or as raw baseline text. */
+ displayMode: SegmentDisplayMode;
+ /** Token ref of the word token that should appear focused; `undefined` clears focus. */
+ focusedTokenRef: string | undefined;
+ /** Whether this segment corresponds to the currently active verse. */
+ isActive: boolean;
+ /**
+ * Called when the segment or one of its word tokens is selected. In `baseline-text` mode the
+ * whole segment is clickable and `tokenRef` is omitted; in `token-chip` mode only word tokens
+ * trigger this and `tokenRef` is always provided.
+ */
+ onSelect: (ref: ScriptureRef, tokenRef?: string) => void;
+ /** The segment to render. */
+ segment: Segment;
+}>;
+
/**
* Renders a single segment as either inline token chips or plain baseline text.
*
* @param props - Component props
- * @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'`
+ * @param props.displayMode - Controls how segment content is rendered
+ * @param props.focusedTokenRef - When set, the matching word token's `PhraseBox` is rendered in the
+ * focused state; only meaningful in `token-chip` mode.
* @param props.isActive - Whether this segment is the currently selected verse
- * @param props.onClick - Callback invoked when the segment button is clicked
+ * @param props.onSelect - Required callback invoked when the segment or one of its word tokens is
+ * interacted with. In `baseline-text` mode the whole segment is clickable and `tokenRef` is
+ * omitted. In `token-chip` mode only word tokens trigger this callback and `tokenRef` is always
+ * provided.
* @param props.segment - The segment to render
- * @returns A button containing the segment's verse label and content
+ * @returns A button (baseline-text mode) or div (token-chip mode) containing a verse label and
+ * segment content
*/
export function SegmentView({
- displayMode = 'token-chip',
+ displayMode,
+ focusedTokenRef,
isActive,
- onClick,
+ onSelect,
segment,
-}: Readonly<{
- displayMode?: SegmentDisplayMode;
- isActive?: boolean;
- onClick?: (ref: ScriptureRef) => void;
- segment: Segment;
-}>) {
+}: SegmentViewProps) {
const { book, chapter, verse } = segment.startRef;
+ const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]);
+
+ /**
+ * Forwards a token-chip click (identified by its index in `segment.tokens`) to the parent as a
+ * scripture reference + token id. Stable across renders so `MemoizedPhraseBox` can memoize.
+ *
+ * @param index - Index of the clicked token within `segment.tokens`.
+ */
+ const handleTokenClick = useCallback(
+ (index?: number) => {
+ if (index !== undefined) onSelect(ref, segment.tokens[index].ref);
+ },
+ [onSelect, ref, segment.tokens],
+ );
+
+ /**
+ * Stable single-token arrays for word tokens keyed by position, so `MemoizedPhraseBox` receives
+ * the same reference across renders.
+ */
+ const tokenArrays = useMemo(
+ () => segment.tokens.map((token) => (isWordToken(token) ? [token] : [])),
+ [segment.tokens],
+ );
+ const sharedClassName = isActive
+ ? 'tw:w-full tw:rounded tw:border tw:border-border tw:bg-muted/50 tw:p-2'
+ : 'tw:w-full tw:rounded tw:p-2 tw:transition-colors tw:hover:bg-muted/30';
+
+ const verseLabel = (
+
+ {verse}
+
+ );
+
+ if (displayMode === 'baseline-text') {
+ return (
+
+ );
+ }
+
+ // Intentional: token-chip mode renders a div, not a button. In this mode individual word tokens
+ // (via PhraseBox gloss inputs) are the interactive elements, so the outer container does not need
+ // to be focusable. Keyboard access goes through the gloss inputs inside PhraseBox, not here.
return (
-
+ {verseLabel}
+
+ {segment.tokens.map((token, index) => {
+ if (!isWordToken(token)) return ;
+ return (
+
+ );
+ })}
+
+
);
}
+/** Memoized version of {@link SegmentView}; use in render-stable segment lists. */
const MemoizedSegmentView = memo(SegmentView);
export default MemoizedSegmentView;
diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx
index 430b1776..c613db7d 100644
--- a/src/components/TokenChip.tsx
+++ b/src/components/TokenChip.tsx
@@ -1,25 +1,71 @@
import type { Token } from 'interlinearizer';
-import { memo } from 'react';
+import { memo, useEffect, useState } from 'react';
+import { useGloss, useGlossDispatch } from './AnalysisStore';
/**
- * Renders a single token as an inline chip. Word tokens get a bordered box; non-word tokens (e.g.
- * punctuation) are rendered as muted inline text.
+ * Renders a single word token as an inline chip with an editable gloss input below the surface
+ * text. Gloss value and dispatch are read from {@link AnalysisStoreProvider} context via
+ * {@link useGloss} and {@link useGlossDispatch}. The gloss is written to the store only on blur, and
+ * only when the draft differs from the committed value, to avoid creating empty analysis entries on
+ * focus/blur cycles with no edits.
*
* @param props - Component props
- * @param props.token - The token to render
- * @returns A styled inline span
+ * @param props.token - The word token to render.
+ * @param props.onFocus - Called when the gloss input receives focus.
+ * @returns A styled label containing the surface text and a gloss input.
*/
-export function TokenChip({ token }: Readonly<{ token: Token }>) {
- return token.type === 'word' ? (
-