From 0a7ae1ecd7d016228813fa1a8e64cad3224cb747 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 1 May 2026 14:12:46 -0400 Subject: [PATCH 01/27] feat: add interlinearizer.continuousScroll project setting with toggle UI --- __mocks__/platform-bible-react.tsx | 35 +++++++++++ contributions/localizedStrings.json | 6 +- contributions/projectSettings.json | 13 +++- .../interlinearizer.web-view.test.tsx | 61 ++++++++++++++++++- src/components/ContinuousScrollToggle.tsx | 36 +++++++++++ src/interlinearizer.web-view.tsx | 50 ++++++++------- src/types/interlinearizer.d.ts | 8 +++ 7 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 src/components/ContinuousScrollToggle.tsx diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 0266fdd..711b36c 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -82,3 +82,38 @@ export function BookChapterControl({ ); } + +export function Switch({ + checked, + id, + onCheckedChange, +}: Readonly<{ + checked?: boolean; + id?: string; + onCheckedChange?: (checked: boolean) => void; +}>): ReactElement { + return ( + onCheckedChange?.(e.target.checked)} + type="checkbox" + /> + ); +} + +export function Label({ + children, + className, + htmlFor, +}: Readonly<{ + children?: ReactNode; + className?: string; + htmlFor?: string; +}>): ReactElement { + return ( + + ); +} diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 2ebf8ee..afb5f36 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -4,7 +4,11 @@ "en": { "%interlinearizer_dialog_open_title%": "Open Interlinearizer", "%interlinearizer_dialog_open_prompt%": "Choose a project to open in the Interlinearizer:", - "%interlinearizer_openForProject%": "Open Interlinearizer for this Project" + "%interlinearizer_openForProject%": "Open Interlinearizer for this Project", + "%interlinearizer_projectSettings_title%": "Interlinearizer", + "%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll", + "%interlinearizer_projectSettings_continuousScrollDescription%": "Display tokens in a continuous horizontal scroll strip instead of chapter-segmented rows", + "%interlinearizer_continuousScrollToggle%": "Continuous Scroll" } } } diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json index fe51488..5f9160b 100644 --- a/contributions/projectSettings.json +++ b/contributions/projectSettings.json @@ -1 +1,12 @@ -[] +[ + { + "label": "%interlinearizer_projectSettings_title%", + "properties": { + "interlinearizer.continuousScroll": { + "label": "%interlinearizer_projectSettings_continuousScroll%", + "description": "%interlinearizer_projectSettings_continuousScrollDescription%", + "default": true + } + } + } +] diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index fd6f65f..91bd876 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -168,9 +168,18 @@ function mockBookData(value: unknown, isLoading = false): void { })); } -/** Configures useProjectSetting to return the given writing system tag. */ +/** + * Configures useProjectSetting to return the given writing system tag for the languageTag key; all + * other keys receive their defaultState so the continuousScroll setting gets its default (true). + */ function mockWritingSystem(tag: string | PlatformError = 'en'): void { - jest.mocked(useProjectSetting).mockReturnValue([tag, jest.fn(), jest.fn(), false]); + jest + .mocked(useProjectSetting) + .mockImplementation((_projectId, key, defaultState) => + key === 'platform.languageTag' + ? [tag, jest.fn(), jest.fn(), false] + : [defaultState, jest.fn(), jest.fn(), false], + ); } describe('InterlinearizerWebView', () => { @@ -370,4 +379,52 @@ describe('InterlinearizerWebView', () => { expect(mockBookUSJ.mock.calls.length).toBeGreaterThanOrEqual(2); refsPassed.slice(1).forEach((ref) => expect(ref).toBe(refsPassed[0])); }); + + it('renders the continuous scroll toggle when a project is linked', () => { + mockBookData({}); + render(); + + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('does not render the continuous scroll toggle when no project is linked', () => { + render(); + + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('continuous scroll toggle is checked when the setting is true', () => { + mockBookData({}); + render(); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('continuous scroll toggle is unchecked when the setting is false', () => { + mockBookData({}); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') return [false, jest.fn(), jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render(); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('clicking the continuous scroll toggle calls setContinuousScroll with the new value', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render(); + + await userEvent.click(screen.getByRole('checkbox')); + + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + }); }); diff --git a/src/components/ContinuousScrollToggle.tsx b/src/components/ContinuousScrollToggle.tsx new file mode 100644 index 0000000..985b46b --- /dev/null +++ b/src/components/ContinuousScrollToggle.tsx @@ -0,0 +1,36 @@ +import { useLocalizedStrings, useProjectSetting } from '@papi/frontend/react'; +import { Label, Switch } from 'platform-bible-react'; +import { useId, useMemo } from 'react'; + +const STRING_KEYS = ['%interlinearizer_continuousScrollToggle%'] as const; + +/** + * Checkbox toggle that reads and writes the `interlinearizer.continuousScroll` project setting. + * + * @param props - Component props + * @param props.projectId - PAPI project ID whose setting to bind + * @returns A labeled checkbox bound to the continuous-scroll project setting + */ +export default function ContinuousScrollToggle({ projectId }: Readonly<{ projectId: string }>) { + const [continuousScroll, setContinuousScroll] = useProjectSetting( + projectId, + 'interlinearizer.continuousScroll', + true, + ); + + const [localizedStrings] = useLocalizedStrings(useMemo(() => [...STRING_KEYS], [])); + const switchId = useId(); + + return ( +
+ setContinuousScroll?.(checked)} + /> + +
+ ); +} diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 4c4a120..4bca62e 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -17,6 +17,7 @@ import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; import { tokenizeBook } from 'parsers/papi/bookTokenizer'; import type { Book, Segment } from 'interlinearizer'; import { logger } from '@papi/frontend'; +import ContinuousScrollToggle from './components/ContinuousScrollToggle'; const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; @@ -223,27 +224,34 @@ globalThis.webViewComponent = function InterlinearizerWebView({ return (
- - } - endAreaChildren={ - - } - onSelectProjectMenuItem={() => {}} - onSelectViewInfoMenuItem={() => {}} - /> +
+ + } + endAreaChildren={ + + } + onSelectProjectMenuItem={() => {}} + onSelectViewInfoMenuItem={() => {}} + /> + {projectId && ( +
+ +
+ )} +
{projectId ? ( diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 009c166..8a5c39b 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -4,6 +4,14 @@ */ declare module 'papi-shared-types' { + export interface ProjectSettingTypes { + /** + * When true, the Interlinearizer displays a continuous horizontal token scroll strip above the + * chapter segments. When false, only chapter segments are shown in token-chip mode. + */ + 'interlinearizer.continuousScroll': boolean; + } + export interface CommandHandlers { /** * Opens the Interlinearizer for the project associated with the given WebView ID. Called from From 018fe1270b405422446a6ee0abdfe8210867e74f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 1 May 2026 17:24:13 -0400 Subject: [PATCH 02/27] feat: extract SegmentView component with token-chip and baseline-text display modes --- src/__tests__/SegmentView.test.tsx | 119 ++++++++++++++++++ .../interlinearizer.web-view.test.tsx | 43 +++++-- src/components/SegmentView.tsx | 53 ++++++++ src/components/TokenChip.tsx | 21 ++++ src/interlinearizer.web-view.tsx | 60 +-------- 5 files changed, 230 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/SegmentView.test.tsx create mode 100644 src/components/SegmentView.tsx create mode 100644 src/components/TokenChip.tsx diff --git a/src/__tests__/SegmentView.test.tsx b/src/__tests__/SegmentView.test.tsx new file mode 100644 index 0000000..6756321 --- /dev/null +++ b/src/__tests__/SegmentView.test.tsx @@ -0,0 +1,119 @@ +/** @file Unit tests for components/SegmentView.tsx. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Segment } from 'interlinearizer'; +import SegmentView from '../components/SegmentView'; + +/** A word token segment. */ +const WORD_SEGMENT: Segment = { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { id: 'tok-0', surfaceText: 'In', writingSystem: 'en', type: 'word', charStart: 0, charEnd: 2 }, + { + id: 'tok-1', + surfaceText: 'the', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 6, + }, + ], +}; + +/** A segment with a single punctuation (non-word) token. */ +const PUNCT_SEGMENT: Segment = { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: '.', + tokens: [ + { + id: 'tok-p', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], +}; + +describe('SegmentView', () => { + it('renders word token chips in token-chip mode (default)', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('the')).toBeInTheDocument(); + }); + + it('renders non-word (punctuation) tokens in token-chip mode', () => { + render(); + + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('renders explicit token-chip mode the same as the default', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('renders baselineText in baseline-text mode', () => { + render(); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + }); + + it('does not render individual tokens in baseline-text mode', () => { + render(); + + expect(screen.queryByText('In')).not.toBeInTheDocument(); + expect(screen.queryByText('the')).not.toBeInTheDocument(); + }); + + it('shows the verse number label', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('sets aria-current="true" when isActive is true', () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true'); + }); + + it('does not set aria-current when isActive is false', () => { + render(); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + }); + + it('does not set aria-current when isActive is omitted', () => { + render(); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + }); + + it('calls onClick when the button is clicked', async () => { + const handleClick = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onClick is omitted and button is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + // No assertion needed — test passes if no error is thrown + }); +}); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index 91bd876..78382c6 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -169,17 +169,20 @@ function mockBookData(value: unknown, isLoading = false): void { } /** - * Configures useProjectSetting to return the given writing system tag for the languageTag key; all - * other keys receive their defaultState so the continuousScroll setting gets its default (true). + * Configures useProjectSetting for the languageTag and continuousScroll keys. All other keys + * receive their defaultState. + * + * @param tag - Writing system tag returned for `platform.languageTag` + * @param continuousScroll - Value returned for `interlinearizer.continuousScroll`; defaults to + * `false` so existing token-chip rendering tests are unaffected */ -function mockWritingSystem(tag: string | PlatformError = 'en'): void { - jest - .mocked(useProjectSetting) - .mockImplementation((_projectId, key, defaultState) => - key === 'platform.languageTag' - ? [tag, jest.fn(), jest.fn(), false] - : [defaultState, jest.fn(), jest.fn(), false], - ); +function mockWritingSystem(tag: string | PlatformError = 'en', continuousScroll = false): void { + jest.mocked(useProjectSetting).mockImplementation((_projectId, key, defaultState) => { + if (key === 'platform.languageTag') return [tag, jest.fn(), jest.fn(), false]; + if (key === 'interlinearizer.continuousScroll') + return [continuousScroll, jest.fn(), jest.fn(), false]; + return [defaultState, jest.fn(), jest.fn(), false]; + }); } describe('InterlinearizerWebView', () => { @@ -395,6 +398,7 @@ describe('InterlinearizerWebView', () => { it('continuous scroll toggle is checked when the setting is true', () => { mockBookData({}); + mockWritingSystem('en', true); render(); expect(screen.getByRole('checkbox')).toBeChecked(); @@ -412,6 +416,25 @@ describe('InterlinearizerWebView', () => { expect(screen.getByRole('checkbox')).not.toBeChecked(); }); + it('renders segments in baseline-text mode when continuousScroll is true', () => { + mockBookData({}); + mockWritingSystem('en', true); + render(); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + }); + + it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); + mockWritingSystem('en', true); + render(); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.getByText('And the earth.')).toBeInTheDocument(); + }); + it('clicking the continuous scroll toggle calls setContinuousScroll with the new value', async () => { mockBookData({}); const mockSetContinuousScroll = jest.fn(); diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx new file mode 100644 index 0000000..a13e849 --- /dev/null +++ b/src/components/SegmentView.tsx @@ -0,0 +1,53 @@ +import type { Segment } from 'interlinearizer'; +import TokenChip from './TokenChip'; + +/** The two display modes for {@link SegmentView}. */ +export type SegmentDisplayMode = 'token-chip' | 'baseline-text'; + +/** + * Renders a single segment as either inline token chips or plain baseline text. + * + * @param props - Component props + * @param props.segment - The segment to render + * @param props.isActive - Whether this segment is the currently selected verse + * @param props.onClick - Callback invoked when the segment button is clicked + * @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'` + * @returns A button containing the segment's verse label and content + */ +export default function SegmentView({ + segment, + isActive, + onClick, + displayMode = 'token-chip', +}: Readonly<{ + segment: Segment; + isActive?: boolean; + onClick?: () => void; + displayMode?: SegmentDisplayMode; +}>) { + return ( + + ); +} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx new file mode 100644 index 0000000..2207b50 --- /dev/null +++ b/src/components/TokenChip.tsx @@ -0,0 +1,21 @@ +import type { Token } from 'interlinearizer'; + +/** + * 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. + * + * @param props - Component props + * @param props.token - The token to render + * @returns A styled inline span + */ +export default function TokenChip({ token }: Readonly<{ token: Token }>) { + return token.type === 'word' ? ( + + {token.surfaceText} + + ) : ( + + {token.surfaceText} + + ); +} diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 4bca62e..00db255 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -15,67 +15,13 @@ import { } from 'platform-bible-react'; import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; import { tokenizeBook } from 'parsers/papi/bookTokenizer'; -import type { Book, Segment } from 'interlinearizer'; +import type { Book } from 'interlinearizer'; import { logger } from '@papi/frontend'; import ContinuousScrollToggle from './components/ContinuousScrollToggle'; +import SegmentView from './components/SegmentView'; const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; -/** - * Renders the tokens of a single segment as inline chips. - * - * @param props - Component props - * @param props.segment - The segment whose tokens to render - * @param props.isActive - Whether this segment is the currently selected verse - * @param props.onClick - Callback invoked when the segment button is clicked - * @returns A button containing the segment's verse label and token chips - */ -function SegmentView({ - segment, - isActive, - onClick, -}: Readonly<{ - segment: Segment; - isActive?: boolean; - onClick?: () => void; -}>) { - return ( - - ); -} - /** * Fetches the USJ book for the given project, tokenizes it, and renders all segments in the current * chapter. Shows loading / error states while data is in flight or unavailable. @@ -106,6 +52,7 @@ function ProjectBookFetcher({ ); const [writingSystem] = useProjectSetting(projectId, 'platform.languageTag', ''); + const [continuousScroll] = useProjectSetting(projectId, 'interlinearizer.continuousScroll', true); const [book, tokenizeError] = useMemo((): [ Book | undefined, @@ -184,6 +131,7 @@ function ProjectBookFetcher({ key={seg.id} segment={seg} isActive={seg.startRef.verse === scrRef.verseNum} + displayMode={continuousScroll === true ? 'baseline-text' : 'token-chip'} onClick={() => setScrRef({ book: seg.startRef.book, From 6b90d0c2704914193b44487e1e6b5076fc448380 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 08:34:34 -0400 Subject: [PATCH 03/27] Add ContinuousView core (continuous token strip, smooth incremental arrows, boundary/fade behavior) --- src/__tests__/ContinuousView.test.tsx | 375 ++++++++++++++++++++++++++ src/components/ContinuousView.tsx | 118 ++++++++ 2 files changed, 493 insertions(+) create mode 100644 src/__tests__/ContinuousView.test.tsx create mode 100644 src/components/ContinuousView.tsx diff --git a/src/__tests__/ContinuousView.test.tsx b/src/__tests__/ContinuousView.test.tsx new file mode 100644 index 0000000..ae22aa3 --- /dev/null +++ b/src/__tests__/ContinuousView.test.tsx @@ -0,0 +1,375 @@ +/** @file Unit tests for components/ContinuousView.tsx. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Book } from 'interlinearizer'; +import ContinuousView from '../components/ContinuousView'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +/** Factory for a single-chapter book with two segments each having two word tokens. */ +function makeBook(overrides?: Partial): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the', + tokens: [ + { + id: 'tok-0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + { + id: 'tok-1', + surfaceText: 'the', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 6, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'beginning God', + tokens: [ + { + id: 'tok-2', + surfaceText: 'beginning', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 9, + }, + { + id: 'tok-3', + surfaceText: 'God', + writingSystem: 'en', + type: 'word', + charStart: 10, + charEnd: 13, + }, + ], + }, + ], + ...overrides, + }; +} + +/** A two-chapter book: chapter 1 has one segment, chapter 2 has one segment. */ +function makeTwoChapterBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Alpha', + tokens: [ + { + id: 'ch1-tok-0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'GEN 2:1', + startRef: { book: 'GEN', chapter: 2, verse: 1 }, + endRef: { book: 'GEN', chapter: 2, verse: 1 }, + baselineText: 'Beta', + tokens: [ + { + id: 'ch2-tok-0', + surfaceText: 'Beta', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }, + ], + }; +} + +/** A book with exactly one token (minimal edge case). */ +function makeSingleTokenBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Word', + tokens: [ + { + id: 'tok-only', + surfaceText: 'Word', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// scrollIntoView mock +// --------------------------------------------------------------------------- + +const scrollIntoViewMock = jest.fn(); + +beforeAll(() => { + // jsdom does not implement scrollIntoView. + HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; +}); + +beforeEach(() => { + scrollIntoViewMock.mockClear(); +}); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('ContinuousView rendering', () => { + it('renders all tokens from all segments as a flat list', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('the')).toBeInTheDocument(); + expect(screen.getByText('beginning')).toBeInTheDocument(); + expect(screen.getByText('God')).toBeInTheDocument(); + }); + + it('renders tokens from both chapters in a two-chapter book', () => { + render(); + + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('does not render any verse label or segment separator', () => { + render(); + + // No verse numbers or colons that would indicate verse labels + expect(screen.queryByText('1:1')).not.toBeInTheDocument(); + expect(screen.queryByText('1:2')).not.toBeInTheDocument(); + // Segment ids should not appear as text + expect(screen.queryByText('GEN 1:1')).not.toBeInTheDocument(); + }); + + it('renders a Previous token button and a Next token button', () => { + render(); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Arrow disabled states +// --------------------------------------------------------------------------- + +describe('ContinuousView arrow disabled states', () => { + it('disables the left arrow on initial render (book start)', () => { + render(); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + it('enables the right arrow on initial render when there are multiple tokens', () => { + render(); + + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + }); + + it('disables both arrows when the book has exactly one token', () => { + render(); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + }); + + it('enables the left arrow after clicking right once', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('disables the right arrow when advanced to the last token', async () => { + render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + // 4 tokens total: advance 3 times to reach index 3 (last) + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + + expect(nextBtn).toBeDisabled(); + }); + + it('re-enables the right arrow after going left from the last token', async () => { + render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + // Now at end + expect(nextBtn).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(nextBtn).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Fade overlays +// --------------------------------------------------------------------------- + +describe('ContinuousView fade overlays', () => { + it('does not render left fade at book start', () => { + const { container } = render(); + + // Left fade gradient is tw-from-background (left-to-right gradient) + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const leftFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-r'), + ); + expect(leftFades).toHaveLength(0); + }); + + it('renders right fade at book start (right side is enabled)', () => { + const { container } = render(); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const rightFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-l'), + ); + expect(rightFades).toHaveLength(1); + }); + + it('renders left fade after moving away from book start', async () => { + const { container } = render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const leftFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-r'), + ); + expect(leftFades).toHaveLength(1); + }); + + it('does not render right fade at book end', async () => { + const { container } = render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const rightFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-l'), + ); + expect(rightFades).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-chapter traversal +// --------------------------------------------------------------------------- + +describe('ContinuousView cross-chapter traversal', () => { + it('indexes tokens across chapter boundaries in segment order', () => { + render(); + + // Both chapter tokens should be present + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('can navigate across a chapter boundary with the right arrow', async () => { + render(); + + // Only one token per chapter, so clicking right once reaches chapter 2's token (index 1 = last) + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + // Right arrow should now be disabled (at end = last token = chapter 2 token) + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + // Left arrow should be enabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Smooth-scroll intent +// --------------------------------------------------------------------------- + +describe('ContinuousView smooth-scroll intent', () => { + it('calls scrollIntoView with smooth behaviour when right arrow is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + }); + + it('calls scrollIntoView with smooth behaviour when left arrow is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + scrollIntoViewMock.mockClear(); + + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + }); + + it('does not call scrollIntoView when a disabled arrow is clicked', async () => { + render(); + scrollIntoViewMock.mockClear(); + + // Left arrow is disabled at start — clicking it should be a no-op + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx new file mode 100644 index 0000000..1613921 --- /dev/null +++ b/src/components/ContinuousView.tsx @@ -0,0 +1,118 @@ +/** @file Continuous horizontal token-strip viewer for a full book. */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Book, Token } from 'interlinearizer'; +import TokenChip from './TokenChip'; + +/** + * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable + * strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling + * animation. No segment markers, verse labels, or chapter boundaries are shown — the strip is fully + * continuous. + * + * Edge behaviour: + * + * - Left arrow is disabled (and left fade suppressed) when the first token is focused. + * - Right arrow is disabled (and right fade suppressed) when the last token is focused. + * + * @param props - Component props + * @param props.book - The full tokenized book whose tokens should be streamed + * @returns A horizontal token strip with left/right navigation arrows and edge-fade overlays + */ +export default function ContinuousView({ book }: Readonly<{ book: Book }>) { + const allTokens: Token[] = useMemo( + () => book.segments.flatMap((seg) => seg.tokens), + [book.segments], + ); + + const [focusIndex, setFocusIndex] = useState(0); + + // Reset strip position whenever the book identity changes. + const prevBookIdRef = useRef(book.id); + useEffect(() => { + if (prevBookIdRef.current !== book.id) { + prevBookIdRef.current = book.id; + setFocusIndex(0); + } + }, [book.id]); + + // One ref slot per token so we can call scrollIntoView on the focused one. + const tokenRefs = useRef<(HTMLSpanElement | null)[]>([]); + + const atStart = focusIndex === 0; + const atEnd = allTokens.length === 0 || focusIndex >= allTokens.length - 1; + + const goLeft = useCallback(() => { + if (!atStart) setFocusIndex((i) => i - 1); + }, [atStart]); + + const goRight = useCallback(() => { + if (!atEnd) setFocusIndex((i) => i + 1); + }, [atEnd]); + + useEffect(() => { + tokenRefs.current[focusIndex]?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + }, [focusIndex]); + + return ( +
+ {/* Left navigation arrow */} + + + {/* Scrollable token strip */} +
+ {/* Left fade overlay — only rendered when the left arrow is enabled */} + {!atStart && ( + + ); +} From 250923472ea8cb2863330223763539b4a24a8b25 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 08:51:02 -0400 Subject: [PATCH 04/27] Compose ContinuousView above chapter rows and finalize conditional display rules --- .../interlinearizer.web-view.test.tsx | 50 +++++++++++++++++++ src/components/ContinuousView.tsx | 4 +- src/interlinearizer.web-view.tsx | 5 ++ src/tailwind.css | 11 ++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index 78382c6..593f2a1 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -18,6 +18,10 @@ import { tokenizeBook } from 'parsers/papi/bookTokenizer'; jest.mock('parsers/papi/bookTokenizer'); jest.mock('parsers/papi/usjBookExtractor'); +jest.mock('../components/ContinuousView', () => ({ + __esModule: true, + default: () =>
, +})); /** * Matches the PlatformError shape from platform-bible-utils (discriminated by @@ -450,4 +454,50 @@ describe('InterlinearizerWebView', () => { expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); }); + + it('renders ContinuousView when continuousScroll is true and book is loaded', () => { + mockBookData({}); + mockWritingSystem('en', true); + render(); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + }); + + it('does not render ContinuousView when continuousScroll is false', () => { + mockBookData({}); + mockWritingSystem('en', false); + render(); + + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + }); + + it('does not render ContinuousView when continuousScroll defaults to true but book is still loading', () => { + mockBookData(undefined, true); + mockWritingSystem('en', true); + render(); + + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + }); + + it('does not render ContinuousView when there is a book error', () => { + mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); + mockWritingSystem('en', true); + render(); + + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + }); + + it('renders ContinuousView above the chapter segment rows when both are present', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); + mockWritingSystem('en', true); + const { container } = render(); + + const continuousView = screen.getByTestId('continuous-view'); + // All interactive elements in DOM order; ContinuousView's div must precede the segment buttons + const allElements = Array.from( + container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'), + ); + expect(allElements[0]).toBe(continuousView); + }); }); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 1613921..8717647 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -88,8 +88,8 @@ export default function ContinuousView({ book }: Readonly<{ book: Book }>) { /> )} - {/* Inner flex row — overflow-x scroll */} -
+ {/* Inner flex row */} +
{allTokens.map((token, index) => ( Loading…

)} + {!bookError && !tokenizeError && !isLoading && book && continuousScroll === true && ( + + )} + {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && (

No verse data for {scrRef.book} {scrRef.chapterNum}. diff --git a/src/tailwind.css b/src/tailwind.css index 9ada30f..dccbf8c 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -2,6 +2,17 @@ @tailwind components; @tailwind utilities; +@layer utilities { + .no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } +} + /* #region shared with https://github.com/paranext/paranext-core/blob/main/lib/platform-bible-react/src/index.css */ @layer base { From efdd31027b211df55ffa332ce579171e2356d5fa Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 09:02:08 -0400 Subject: [PATCH 05/27] Propagate verse change from ContinuousView arrow navigation to scroll group --- src/__tests__/ContinuousView.test.tsx | 159 ++++++++++++++++++++++++++ src/components/ContinuousView.tsx | 97 +++++++++++++++- src/interlinearizer.web-view.tsx | 8 +- 3 files changed, 262 insertions(+), 2 deletions(-) diff --git a/src/__tests__/ContinuousView.test.tsx b/src/__tests__/ContinuousView.test.tsx index ae22aa3..ae42f78 100644 --- a/src/__tests__/ContinuousView.test.tsx +++ b/src/__tests__/ContinuousView.test.tsx @@ -373,3 +373,162 @@ describe('ContinuousView smooth-scroll intent', () => { expect(scrollIntoViewMock).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// activeVerse / verse-jump behaviour +// --------------------------------------------------------------------------- + +describe('ContinuousView activeVerse verse-jump', () => { + it('positions at focusIndex 0 when activeVerse matches the first segment', () => { + render( + , + ); + + // At index 0 the left arrow should be disabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + it('jumps to the first token of the second segment when activeVerse points there', () => { + // makeBook() has 4 tokens: index 0,1 in segment GEN 1:1 and index 2,3 in GEN 1:2 + const { rerender } = render( + , + ); + + rerender( + , + ); + + // focusIndex is now 2 (first token of segment 2), so left arrow should be enabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('jumps across a chapter boundary to the second chapter segment', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + // Chapter 2 starts at index 1 (the last token), so right arrow should be disabled + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('calls scrollIntoView when activeVerse changes', () => { + const { rerender } = render( + , + ); + scrollIntoViewMock.mockClear(); + + rerender( + , + ); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + }); + + it('does not jump when activeVerse is undefined', () => { + render(); + + // Without activeVerse the strip stays at focusIndex 0 + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + it('does not jump when activeVerse does not match any segment', () => { + render( + , + ); + + // No matching segment — strip stays at focusIndex 0 + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// onVerseChange outbound propagation +// --------------------------------------------------------------------------- + +describe('ContinuousView onVerseChange propagation', () => { + it('calls onVerseChange when the right arrow crosses into a new verse', async () => { + // makeBook(): segment GEN 1:1 has tokens at index 0,1; GEN 1:2 starts at index 2 + const handleVerseChange = jest.fn(); + render(); + + // Advance twice to reach index 2 (first token of GEN 1:2) + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + }); + + it('calls onVerseChange when the left arrow crosses back into a prior verse', async () => { + const handleVerseChange = jest.fn(); + render( + , + ); + handleVerseChange.mockClear(); + + // focusIndex is at 2 (first token of GEN 1:2); go left to cross back to GEN 1:1 + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }); + }); + + it('does not call onVerseChange for multiple arrow clicks within the same verse', async () => { + const handleVerseChange = jest.fn(); + render(); + handleVerseChange.mockClear(); + + // index 0 → index 1: both are in GEN 1:1, no verse change + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); + + it('does not call onVerseChange when activeVerse prop drives the jump', () => { + const handleVerseChange = jest.fn(); + const { rerender } = render( + , + ); + handleVerseChange.mockClear(); + + rerender( + , + ); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); + + it('calls onVerseChange with the chapter-2 verse when crossing the chapter boundary', async () => { + const handleVerseChange = jest.fn(); + render(); + handleVerseChange.mockClear(); + + // ch1 has 1 token (index 0), ch2 starts at index 1 — one click crosses the boundary + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 2, verse: 1 }); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 8717647..699caa9 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -3,6 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Book, Token } from 'interlinearizer'; import TokenChip from './TokenChip'; +/** A verse coordinate used to drive the strip's scroll position. */ +export interface VerseCoordinate { + book: string; + chapter: number; + verse: number; +} + /** * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable * strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling @@ -14,16 +21,53 @@ import TokenChip from './TokenChip'; * - Left arrow is disabled (and left fade suppressed) when the first token is focused. * - Right arrow is disabled (and right fade suppressed) when the last token is focused. * + * When `activeVerse` changes the strip jumps to the first token of the matching segment. When arrow + * navigation crosses a verse boundary `onVerseChange` is called with the new verse coordinate. + * * @param props - Component props * @param props.book - The full tokenized book whose tokens should be streamed + * @param props.activeVerse - Optional verse coordinate; when it changes the strip scrolls to the + * first token of the matching segment + * @param props.onVerseChange - Called when arrow navigation moves the focus into a new verse * @returns A horizontal token strip with left/right navigation arrows and edge-fade overlays */ -export default function ContinuousView({ book }: Readonly<{ book: Book }>) { +export default function ContinuousView({ + book, + activeVerse, + onVerseChange, +}: Readonly<{ + book: Book; + activeVerse?: VerseCoordinate; + onVerseChange?: (verse: VerseCoordinate) => void; +}>) { const allTokens: Token[] = useMemo( () => book.segments.flatMap((seg) => seg.tokens), [book.segments], ); + /** Maps each segment id to the index of its first token in `allTokens`. */ + const segmentStartIndex = useMemo(() => { + const { map } = book.segments.reduce( + (acc, seg) => { + acc.map.set(seg.id, acc.offset); + return { map: acc.map, offset: acc.offset + seg.tokens.length }; + }, + { map: new Map(), offset: 0 }, + ); + return map; + }, [book.segments]); + + /** + * Maps a flat token index to the segment that owns it. Stored in a ref so that a new book object + * reference (same content) does not cause the verse-change effect to re-fire. + */ + const tokenSegment = useMemo( + () => book.segments.flatMap((seg) => seg.tokens.map(() => seg)), + [book.segments], + ); + const tokenSegmentRef = useRef(tokenSegment); + tokenSegmentRef.current = tokenSegment; + const [focusIndex, setFocusIndex] = useState(0); // Reset strip position whenever the book identity changes. @@ -35,6 +79,57 @@ export default function ContinuousView({ book }: Readonly<{ book: Book }>) { } }, [book.id]); + /** + * When activeVerse triggers a programmatic jump we record the target index here. The verse-change + * effect checks this before firing `onVerseChange` so the jump is not echoed back. Using the + * target index (rather than a boolean) avoids a race where the flag gets consumed by an unrelated + * tokenSegment reference change before the focusIndex state update arrives. + */ + const jumpTargetRef = useRef(undefined); + + // Jump to the first token of the matching segment when the active verse changes. + useEffect(() => { + if (!activeVerse) return; + const seg = book.segments.find( + (s) => + s.startRef.book === activeVerse.book && + s.startRef.chapter === activeVerse.chapter && + s.startRef.verse === activeVerse.verse, + ); + if (!seg) return; + const idx = segmentStartIndex.get(seg.id); + if (idx !== undefined) { + jumpTargetRef.current = idx; + setFocusIndex(idx); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeVerse?.book, activeVerse?.chapter, activeVerse?.verse]); + + // Fire onVerseChange when arrow navigation crosses into a new verse. + // Initialise to the first segment so the initial render does not trigger the callback. + const lastReportedSegIdRef = useRef( + book.segments.length > 0 ? book.segments[0].id : undefined, + ); + useEffect(() => { + // Suppress echo-back when the change was driven by an incoming activeVerse prop. + if (jumpTargetRef.current === focusIndex) { + jumpTargetRef.current = undefined; + return; + } + jumpTargetRef.current = undefined; + const seg = tokenSegmentRef.current[focusIndex]; + if (!seg || seg.id === lastReportedSegIdRef.current) return; + lastReportedSegIdRef.current = seg.id; + onVerseChange?.({ + book: seg.startRef.book, + chapter: seg.startRef.chapter, + verse: seg.startRef.verse, + }); + // onVerseChange and tokenSegmentRef are intentionally excluded — callers must stabilize the + // reference (useCallback) and tokenSegmentRef is a ref so changes are always current. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusIndex]); + // One ref slot per token so we can call scrollIntoView on the focused one. const tokenRefs = useRef<(HTMLSpanElement | null)[]>([]); diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 3ca94fa..724742a 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -120,7 +120,13 @@ function ProjectBookFetcher({ )} {!bookError && !tokenizeError && !isLoading && book && continuousScroll === true && ( - + + setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }) + } + /> )} {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( From 635bf6835df5ec2bfc3fc09df132803d278f9a21 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 09:24:50 -0400 Subject: [PATCH 06/27] Add PhraseBox-based phrase navigation and clickable focus behavior --- src/__tests__/ContinuousView.test.tsx | 14 +++ src/components/ContinuousView.tsx | 124 +++++++++++++++++--------- src/components/PhraseBox.tsx | 56 ++++++++++++ src/components/SegmentView.tsx | 25 +++--- 4 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 src/components/PhraseBox.tsx diff --git a/src/__tests__/ContinuousView.test.tsx b/src/__tests__/ContinuousView.test.tsx index ae42f78..87d94a3 100644 --- a/src/__tests__/ContinuousView.test.tsx +++ b/src/__tests__/ContinuousView.test.tsx @@ -193,6 +193,20 @@ describe('ContinuousView rendering', () => { expect(screen.getByRole('button', { name: 'Previous token' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Next token' })).toBeInTheDocument(); }); + + it('clicking an out-of-focus phrase box brings it into focus', async () => { + render(); + + const clickedToken = screen.getByText('beginning'); + const clickedPhraseBox = clickedToken.closest('[data-phrase-box="true"]'); + if (!clickedPhraseBox) throw new Error('Expected phrase box wrapper for token'); + expect(clickedPhraseBox).toHaveAttribute('data-focus-state', 'default'); + + await userEvent.click(clickedPhraseBox); + + expect(clickedPhraseBox).toHaveAttribute('data-focus-state', 'focused'); + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 699caa9..f3b8556 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Book, Token } from 'interlinearizer'; import TokenChip from './TokenChip'; +import PhraseBox from './PhraseBox'; /** A verse coordinate used to drive the strip's scroll position. */ export interface VerseCoordinate { @@ -25,19 +26,19 @@ export interface VerseCoordinate { * navigation crosses a verse boundary `onVerseChange` is called with the new verse coordinate. * * @param props - Component props - * @param props.book - The full tokenized book whose tokens should be streamed * @param props.activeVerse - Optional verse coordinate; when it changes the strip scrolls to the * first token of the matching segment + * @param props.book - The full tokenized book whose tokens should be streamed * @param props.onVerseChange - Called when arrow navigation moves the focus into a new verse * @returns A horizontal token strip with left/right navigation arrows and edge-fade overlays */ export default function ContinuousView({ - book, activeVerse, + book, onVerseChange, }: Readonly<{ - book: Book; activeVerse?: VerseCoordinate; + book: Book; onVerseChange?: (verse: VerseCoordinate) => void; }>) { const allTokens: Token[] = useMemo( @@ -45,11 +46,12 @@ export default function ContinuousView({ [book.segments], ); - /** Maps each segment id to the index of its first token in `allTokens`. */ + /** Maps each segment id to the index of its first word token in `allTokens`. */ const segmentStartIndex = useMemo(() => { const { map } = book.segments.reduce( (acc, seg) => { - acc.map.set(seg.id, acc.offset); + const firstWordIndex = seg.tokens.findIndex((t) => t.type === 'word'); + if (firstWordIndex >= 0) acc.map.set(seg.id, acc.offset + firstWordIndex); return { map: acc.map, offset: acc.offset + seg.tokens.length }; }, { map: new Map(), offset: 0 }, @@ -57,25 +59,48 @@ export default function ContinuousView({ return map; }, [book.segments]); - /** - * Maps a flat token index to the segment that owns it. Stored in a ref so that a new book object - * reference (same content) does not cause the verse-change effect to re-fire. - */ + /** The navigable phrase entries (currently one per word token). */ + const phraseEntries = useMemo( + () => + allTokens + .map((token, tokenIndex) => ({ token, tokenIndex })) + .filter((entry) => entry.token.type === 'word'), + [allTokens], + ); + const phraseEntriesRef = useRef(phraseEntries); + phraseEntriesRef.current = phraseEntries; + + /** Flat token index -> phrase index lookup for focused rendering. */ + const phraseIndexByTokenIndex = useMemo( + () => + phraseEntries.reduce((acc, entry, phraseIndex) => { + acc.set(entry.tokenIndex, phraseIndex); + return acc; + }, new Map()), + [phraseEntries], + ); + + /** Flat token index -> owning segment lookup. */ const tokenSegment = useMemo( () => book.segments.flatMap((seg) => seg.tokens.map(() => seg)), [book.segments], ); + + /** + * Maps a flat token index to the segment that owns it. Stored in a ref so that a new book object + * reference (same content) does not cause the verse-change effect to re-fire. + */ const tokenSegmentRef = useRef(tokenSegment); tokenSegmentRef.current = tokenSegment; - const [focusIndex, setFocusIndex] = useState(0); + const [focusPhraseIndex, setFocusPhraseIndex] = useState(0); // Reset strip position whenever the book identity changes. const prevBookIdRef = useRef(book.id); useEffect(() => { if (prevBookIdRef.current !== book.id) { prevBookIdRef.current = book.id; - setFocusIndex(0); + setFocusPhraseIndex(0); } }, [book.id]); @@ -99,8 +124,11 @@ export default function ContinuousView({ if (!seg) return; const idx = segmentStartIndex.get(seg.id); if (idx !== undefined) { - jumpTargetRef.current = idx; - setFocusIndex(idx); + const phraseIndex = phraseIndexByTokenIndex.get(idx); + if (phraseIndex === undefined) return; + + jumpTargetRef.current = phraseIndex; + setFocusPhraseIndex(phraseIndex); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeVerse?.book, activeVerse?.chapter, activeVerse?.verse]); @@ -108,16 +136,18 @@ export default function ContinuousView({ // Fire onVerseChange when arrow navigation crosses into a new verse. // Initialise to the first segment so the initial render does not trigger the callback. const lastReportedSegIdRef = useRef( - book.segments.length > 0 ? book.segments[0].id : undefined, + phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined, ); useEffect(() => { // Suppress echo-back when the change was driven by an incoming activeVerse prop. - if (jumpTargetRef.current === focusIndex) { + if (jumpTargetRef.current === focusPhraseIndex) { jumpTargetRef.current = undefined; return; } jumpTargetRef.current = undefined; - const seg = tokenSegmentRef.current[focusIndex]; + const focusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; + if (!focusedPhrase) return; + const seg = tokenSegmentRef.current[focusedPhrase.tokenIndex]; if (!seg || seg.id === lastReportedSegIdRef.current) return; lastReportedSegIdRef.current = seg.id; onVerseChange?.({ @@ -128,39 +158,39 @@ export default function ContinuousView({ // onVerseChange and tokenSegmentRef are intentionally excluded — callers must stabilize the // reference (useCallback) and tokenSegmentRef is a ref so changes are always current. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusIndex]); + }, [focusPhraseIndex]); - // One ref slot per token so we can call scrollIntoView on the focused one. - const tokenRefs = useRef<(HTMLSpanElement | null)[]>([]); + // One ref slot per phrase so we can call scrollIntoView on the focused one. + const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); - const atStart = focusIndex === 0; - const atEnd = allTokens.length === 0 || focusIndex >= allTokens.length - 1; + const atStart = phraseEntries.length === 0 || focusPhraseIndex === 0; + const atEnd = phraseEntries.length === 0 || focusPhraseIndex >= phraseEntries.length - 1; const goLeft = useCallback(() => { - if (!atStart) setFocusIndex((i) => i - 1); + if (!atStart) setFocusPhraseIndex((i) => i - 1); }, [atStart]); const goRight = useCallback(() => { - if (!atEnd) setFocusIndex((i) => i + 1); + if (!atEnd) setFocusPhraseIndex((i) => i + 1); }, [atEnd]); useEffect(() => { - tokenRefs.current[focusIndex]?.scrollIntoView({ + phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ behavior: 'smooth', - inline: 'center', block: 'nearest', + inline: 'center', }); - }, [focusIndex]); + }, [focusPhraseIndex]); return (

{/* Left navigation arrow */} @@ -185,26 +215,40 @@ export default function ContinuousView({ {/* Inner flex row */}
- {allTokens.map((token, index) => ( - { - tokenRefs.current[index] = el; - }} - > - - - ))} + {allTokens.map((token, tokenIndex) => { + if (token.type !== 'word') return ; + + const phraseIndex = phraseIndexByTokenIndex.get(tokenIndex); + const isFocusedPhrase = phraseIndex !== undefined && phraseIndex === focusPhraseIndex; + return ( + { + if (phraseIndex !== undefined) phraseRefs.current[phraseIndex] = el; + }} + > + { + if (phraseIndex !== undefined && phraseIndex !== focusPhraseIndex) { + setFocusPhraseIndex(phraseIndex); + } + }} + tokens={[token]} + /> + + ); + })}
{/* Right navigation arrow */} diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx new file mode 100644 index 0000000..893d0b4 --- /dev/null +++ b/src/components/PhraseBox.tsx @@ -0,0 +1,56 @@ +/** @file Shared phrase-box wrapper used around word tokens. */ +import type { Token } from 'interlinearizer'; +import TokenChip from './TokenChip'; + +/** + * Wraps one or more tokens in a phrase-level visual container. + * + * @param props - Component props + * @param props.isFocused - Whether this phrase is the current navigation focus + * @param props.tokens - Tokens belonging to this phrase + * @returns A bordered inline container + */ +export default function PhraseBox({ + isFocused = false, + onClick, + tokens, +}: Readonly<{ + isFocused?: boolean; + onClick?: () => void; + tokens: Token[]; +}>) { + 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 tw-border-border/40 tw-bg-muted/20 tw-px-1 tw-py-0.5'; + const innerContent = ( + + {tokens.map((token) => ( + + ))} + + ); + + if (onClick) { + return ( + + ); + } + + return ( + + {innerContent} + + ); +} diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index a13e849..9cb7332 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -1,5 +1,6 @@ import type { Segment } from 'interlinearizer'; import TokenChip from './TokenChip'; +import PhraseBox from './PhraseBox'; /** The two display modes for {@link SegmentView}. */ export type SegmentDisplayMode = 'token-chip' | 'baseline-text'; @@ -8,26 +9,25 @@ export type SegmentDisplayMode = 'token-chip' | 'baseline-text'; * Renders a single segment as either inline token chips or plain baseline text. * * @param props - Component props - * @param props.segment - The segment to render + * @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'` * @param props.isActive - Whether this segment is the currently selected verse * @param props.onClick - Callback invoked when the segment button is clicked - * @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'` + * @param props.segment - The segment to render * @returns A button containing the segment's verse label and content */ export default function SegmentView({ - segment, + displayMode = 'token-chip', isActive, onClick, - displayMode = 'token-chip', + segment, }: Readonly<{ - segment: Segment; + displayMode?: SegmentDisplayMode; isActive?: boolean; onClick?: () => void; - displayMode?: SegmentDisplayMode; + segment: Segment; }>) { return ( From ac7813bc6e14027b2b5660dd3256f37f3e515e7f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 09:35:57 -0400 Subject: [PATCH 07/27] Use optimistic continuous-scroll state across webview --- __mocks__/platform-bible-react.tsx | 3 ++ src/__tests__/ContinuousScrollToggle.test.tsx | 36 +++++++++++++ .../interlinearizer.web-view.test.tsx | 25 +++++++++ src/components/ContinuousScrollToggle.tsx | 31 ++++++----- src/interlinearizer.web-view.tsx | 51 ++++++++++++++++--- 5 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/ContinuousScrollToggle.test.tsx diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 711b36c..ba35476 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -85,16 +85,19 @@ export function BookChapterControl({ export function Switch({ checked, + disabled, id, onCheckedChange, }: Readonly<{ checked?: boolean; + disabled?: boolean; id?: string; onCheckedChange?: (checked: boolean) => void; }>): ReactElement { return ( onCheckedChange?.(e.target.checked)} type="checkbox" diff --git a/src/__tests__/ContinuousScrollToggle.test.tsx b/src/__tests__/ContinuousScrollToggle.test.tsx new file mode 100644 index 0000000..8f7be73 --- /dev/null +++ b/src/__tests__/ContinuousScrollToggle.test.tsx @@ -0,0 +1,36 @@ +/** @file Unit tests for components/ContinuousScrollToggle.tsx. */ +/// +/// + +import { useLocalizedStrings } from '@papi/frontend/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ContinuousScrollToggle from '../components/ContinuousScrollToggle'; + +describe('ContinuousScrollToggle', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([ + { + '%interlinearizer_continuousScrollToggle%': 'Continuous Scroll', + }, + false, + ]); + }); + + it('calls onCheckedChange when toggled', async () => { + const onCheckedChange = jest.fn(); + render(); + const checkbox = screen.getByRole('checkbox'); + + expect(checkbox).toBeChecked(); + await userEvent.click(checkbox); + + expect(onCheckedChange).toHaveBeenCalledWith(false); + }); + + it('renders disabled state when disabled is true', () => { + render(); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); +}); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index 593f2a1..5fbf86f 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -455,6 +455,31 @@ describe('InterlinearizerWebView', () => { expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); }); + it('switches rendering immediately using optimistic local state while setting is still pending', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + // Setting source remains true during the test (simulates delayed persistence confirmation). + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render(); + + // Initially in continuous mode. + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox')); + + // Before setting confirms, UI should already switch to token-chip mode. + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + it('renders ContinuousView when continuousScroll is true and book is loaded', () => { mockBookData({}); mockWritingSystem('en', true); diff --git a/src/components/ContinuousScrollToggle.tsx b/src/components/ContinuousScrollToggle.tsx index 985b46b..d51a6c1 100644 --- a/src/components/ContinuousScrollToggle.tsx +++ b/src/components/ContinuousScrollToggle.tsx @@ -1,23 +1,27 @@ -import { useLocalizedStrings, useProjectSetting } from '@papi/frontend/react'; +import { useLocalizedStrings } from '@papi/frontend/react'; import { Label, Switch } from 'platform-bible-react'; import { useId, useMemo } from 'react'; const STRING_KEYS = ['%interlinearizer_continuousScrollToggle%'] as const; /** - * Checkbox toggle that reads and writes the `interlinearizer.continuousScroll` project setting. + * Checkbox toggle UI for the `interlinearizer.continuousScroll` setting. * * @param props - Component props - * @param props.projectId - PAPI project ID whose setting to bind - * @returns A labeled checkbox bound to the continuous-scroll project setting + * @param props.checked - Current UI value for continuous scroll + * @param props.disabled - Whether the control is temporarily disabled + * @param props.onCheckedChange - Callback invoked when user toggles the switch + * @returns A labeled checkbox for continuous-scroll mode */ -export default function ContinuousScrollToggle({ projectId }: Readonly<{ projectId: string }>) { - const [continuousScroll, setContinuousScroll] = useProjectSetting( - projectId, - 'interlinearizer.continuousScroll', - true, - ); - +export default function ContinuousScrollToggle({ + checked, + disabled, + onCheckedChange, +}: Readonly<{ + checked: boolean; + disabled?: boolean; + onCheckedChange: (checked: boolean) => void; +}>) { const [localizedStrings] = useLocalizedStrings(useMemo(() => [...STRING_KEYS], [])); const switchId = useId(); @@ -25,8 +29,9 @@ export default function ContinuousScrollToggle({ projectId }: Readonly<{ project
setContinuousScroll?.(checked)} + checked={checked} + disabled={disabled} + onCheckedChange={onCheckedChange} />
{projectId ? ( - + ) : (

Open this WebView from a Paratext project to load its source book. From 2258983a514e357d4255f9826edc87ab706d31a6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 May 2026 10:01:10 -0400 Subject: [PATCH 08/27] Fix bugs --- src/__tests__/ContinuousView.test.tsx | 58 +++++++++++++++++++++++++++ src/components/ContinuousView.tsx | 39 ++++++++++++++---- src/interlinearizer.web-view.tsx | 13 ++++-- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/__tests__/ContinuousView.test.tsx b/src/__tests__/ContinuousView.test.tsx index 87d94a3..adcb4af 100644 --- a/src/__tests__/ContinuousView.test.tsx +++ b/src/__tests__/ContinuousView.test.tsx @@ -535,6 +535,38 @@ describe('ContinuousView onVerseChange propagation', () => { expect(handleVerseChange).not.toHaveBeenCalled(); }); + it('keeps clicked phrase focus when activeVerse updates to that same verse', async () => { + const onVerseChange = jest.fn(); + const { rerender } = render( + , + ); + + // Click the second phrase in GEN 1:2 ("God", index 3 in the full strip). + const godToken = screen.getByText('God'); + const godPhraseBox = godToken.closest('[data-phrase-box="true"]'); + if (!godPhraseBox) throw new Error('Expected phrase box wrapper for token'); + await userEvent.click(godPhraseBox); + + // Parent receives verse change and updates activeVerse to GEN 1:2. + expect(onVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + rerender( + , + ); + + // If focus was incorrectly reset to the first phrase of the verse ("beginning"), the right + // arrow would be enabled. Staying on "God" keeps us at strip end, so it remains disabled. + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + expect(godPhraseBox).toHaveAttribute('data-focus-state', 'focused'); + }); + it('calls onVerseChange with the chapter-2 verse when crossing the chapter boundary', async () => { const handleVerseChange = jest.fn(); render(); @@ -545,4 +577,30 @@ describe('ContinuousView onVerseChange propagation', () => { expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 2, verse: 1 }); }); + + it('does not call onVerseChange when book changes and focus resets to the first phrase', async () => { + const handleVerseChange = jest.fn(); + const { rerender } = render( + , + ); + + // Move focus away from index 0 so book-switch reset path is exercised. + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + handleVerseChange.mockClear(); + + const exoBook: Book = { + ...makeBook(), + id: 'EXO', + bookRef: 'EXO', + segments: makeBook().segments.map((seg) => ({ + ...seg, + startRef: { ...seg.startRef, book: 'EXO' }, + endRef: { ...seg.endRef, book: 'EXO' }, + })), + }; + + rerender(); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index f3b8556..ba3ba06 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -115,6 +115,21 @@ export default function ContinuousView({ // Jump to the first token of the matching segment when the active verse changes. useEffect(() => { if (!activeVerse) return; + + // Preserve current phrase focus when it is already inside the target verse. + const currentlyFocusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; + if (currentlyFocusedPhrase) { + const focusedSeg = tokenSegmentRef.current[currentlyFocusedPhrase.tokenIndex]; + if ( + focusedSeg && + focusedSeg.startRef.book === activeVerse.book && + focusedSeg.startRef.chapter === activeVerse.chapter && + focusedSeg.startRef.verse === activeVerse.verse + ) { + return; + } + } + const seg = book.segments.find( (s) => s.startRef.book === activeVerse.book && @@ -135,20 +150,29 @@ export default function ContinuousView({ // Fire onVerseChange when arrow navigation crosses into a new verse. // Initialise to the first segment so the initial render does not trigger the callback. - const lastReportedSegIdRef = useRef( - phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined, - ); + const firstVisibleSegId = + phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined; + const lastReportedSegIdRef = useRef(firstVisibleSegId); + + // Keep the reported-segment baseline in sync when switching to a different book. + useEffect(() => { + lastReportedSegIdRef.current = firstVisibleSegId; + }, [book.id, firstVisibleSegId]); + useEffect(() => { // Suppress echo-back when the change was driven by an incoming activeVerse prop. if (jumpTargetRef.current === focusPhraseIndex) { jumpTargetRef.current = undefined; return; } + jumpTargetRef.current = undefined; const focusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; if (!focusedPhrase) return; + const seg = tokenSegmentRef.current[focusedPhrase.tokenIndex]; if (!seg || seg.id === lastReportedSegIdRef.current) return; + lastReportedSegIdRef.current = seg.id; onVerseChange?.({ book: seg.startRef.book, @@ -167,12 +191,13 @@ export default function ContinuousView({ const atEnd = phraseEntries.length === 0 || focusPhraseIndex >= phraseEntries.length - 1; const goLeft = useCallback(() => { - if (!atStart) setFocusPhraseIndex((i) => i - 1); - }, [atStart]); + setFocusPhraseIndex((i) => (i > 0 ? i - 1 : i)); + }, []); const goRight = useCallback(() => { - if (!atEnd) setFocusPhraseIndex((i) => i + 1); - }, [atEnd]); + const max = phraseEntriesRef.current.length - 1; + setFocusPhraseIndex((i) => (i < max ? i + 1 : i)); + }, []); useEffect(() => { phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 38884af..ecc0234 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -6,7 +6,7 @@ import { useRecentScriptureRefs, } from '@papi/frontend/react'; import { isPlatformError } from 'platform-bible-utils'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { BOOK_CHAPTER_CONTROL_STRING_KEYS, BookChapterControl, @@ -96,6 +96,13 @@ function ProjectBookFetcher({ bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; } + const handleContinuousVerseChange = useCallback( + (v: { book: string; chapter: number; verse: number }) => { + setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); + }, + [setScrRef], + ); + return (

{bookError && ( @@ -124,9 +131,7 @@ function ProjectBookFetcher({ - setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }) - } + onVerseChange={handleContinuousVerseChange} /> )} From fbe9fb64648f9e3b00034b10adcd978120fd5098 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 5 May 2026 16:44:15 -0400 Subject: [PATCH 09/27] Refine continuous view behavior and stabilize web-view tests --- jest.setup.ts | 14 ++ .../ContinuousScrollToggle.test.tsx | 2 +- .../{ => components}/ContinuousView.test.tsx | 77 +++++++++- .../{ => components}/SegmentView.test.tsx | 2 +- src/components/ContinuousView.tsx | 132 ++++++++++++++---- src/interlinearizer.web-view.tsx | 97 ++++++++----- 6 files changed, 252 insertions(+), 72 deletions(-) rename src/__tests__/{ => components}/ContinuousScrollToggle.test.tsx (93%) rename src/__tests__/{ => components}/ContinuousView.test.tsx (86%) rename src/__tests__/{ => components}/SegmentView.test.tsx (98%) diff --git a/jest.setup.ts b/jest.setup.ts index cf32fbe..55971aa 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -3,3 +3,17 @@ * matchers for React component tests. */ import '@testing-library/jest-dom'; + +function ResizeObserverMock(): void {} + +ResizeObserverMock.prototype.observe = function observe(): void {}; +ResizeObserverMock.prototype.unobserve = function unobserve(): void {}; +ResizeObserverMock.prototype.disconnect = function disconnect(): void {}; + +if (!global.ResizeObserver) { + Object.defineProperty(global, 'ResizeObserver', { + configurable: true, + writable: true, + value: ResizeObserverMock, + }); +} diff --git a/src/__tests__/ContinuousScrollToggle.test.tsx b/src/__tests__/components/ContinuousScrollToggle.test.tsx similarity index 93% rename from src/__tests__/ContinuousScrollToggle.test.tsx rename to src/__tests__/components/ContinuousScrollToggle.test.tsx index 8f7be73..6d21597 100644 --- a/src/__tests__/ContinuousScrollToggle.test.tsx +++ b/src/__tests__/components/ContinuousScrollToggle.test.tsx @@ -5,7 +5,7 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import ContinuousScrollToggle from '../components/ContinuousScrollToggle'; +import ContinuousScrollToggle from '../../components/ContinuousScrollToggle'; describe('ContinuousScrollToggle', () => { beforeEach(() => { diff --git a/src/__tests__/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx similarity index 86% rename from src/__tests__/ContinuousView.test.tsx rename to src/__tests__/components/ContinuousView.test.tsx index adcb4af..5723ee3 100644 --- a/src/__tests__/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -2,10 +2,10 @@ /// /// -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Book } from 'interlinearizer'; -import ContinuousView from '../components/ContinuousView'; +import ContinuousView from '../../components/ContinuousView'; // --------------------------------------------------------------------------- // Test fixtures @@ -393,6 +393,14 @@ describe('ContinuousView smooth-scroll intent', () => { // --------------------------------------------------------------------------- describe('ContinuousView activeVerse verse-jump', () => { + // These tests rely on the 500 ms fade-out timer that delays the focus jump. + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('positions at focusIndex 0 when activeVerse matches the first segment', () => { render( , @@ -411,6 +419,10 @@ describe('ContinuousView activeVerse verse-jump', () => { rerender( , ); + // Advance past the fade-out delay so the pending focus jump fires. + act(() => { + jest.advanceTimersByTime(500); + }); // focusIndex is now 2 (first token of segment 2), so left arrow should be enabled expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); @@ -430,13 +442,18 @@ describe('ContinuousView activeVerse verse-jump', () => { activeVerse={{ book: 'GEN', chapter: 2, verse: 1 }} />, ); + act(() => { + jest.advanceTimersByTime(500); + }); // Chapter 2 starts at index 1 (the last token), so right arrow should be disabled expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); }); - it('calls scrollIntoView when activeVerse changes', () => { + it('calls scrollIntoView with instant behaviour when activeVerse changes', () => { + // External jumps use behavior:'auto' (not 'smooth') to avoid double-animation with the + // strip opacity fade that already plays during the jump. const { rerender } = render( , ); @@ -445,10 +462,23 @@ describe('ContinuousView activeVerse verse-jump', () => { rerender( , ); + act(() => { + jest.advanceTimersByTime(500); + }); - expect(scrollIntoViewMock).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'smooth' }), + expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); + }); + + it('initializes at the target verse position when activeVerse is provided at mount', () => { + // makeBook(): GEN 1:1 at index 0-1, GEN 1:2 at index 2-3. Mounting with verse 2 should + // start the strip focused at index 2 immediately (lazy useState initializer, no effect wait). + render( + , ); + + // Index 2 is not the start (left enabled) and not the end (right enabled). + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); }); it('does not jump when activeVerse is undefined', () => { @@ -514,6 +544,8 @@ describe('ContinuousView onVerseChange propagation', () => { }); it('does not call onVerseChange when activeVerse prop drives the jump', () => { + // Must advance timers so the jump actually completes and the echo-back guard is exercised. + jest.useFakeTimers(); const handleVerseChange = jest.fn(); const { rerender } = render( { onVerseChange={handleVerseChange} />, ); + act(() => { + jest.advanceTimersByTime(500); + }); + jest.useRealTimers(); expect(handleVerseChange).not.toHaveBeenCalled(); }); + it('does not jump back when arrow navigation crosses a verse and the parent echoes activeVerse', async () => { + // Regression: focusPhraseIndex was in the activeVerse effect's dep array, causing it to + // re-run on every arrow press. When crossing a verse, onVerseChange fired, the parent + // updated activeVerse, and the effect jumped back to the old verse — a loop. + const handleVerseChange = jest.fn(); + const { rerender } = render( + , + ); + + // Advance twice from index 0 to index 2 (first token of GEN 1:2). + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + + // Parent echoes activeVerse = GEN 1:2. The strip is already there — no jump should fire. + rerender( + , + ); + + // Focus stays at index 2: left arrow enabled, right arrow enabled (not at start or end). + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + // onVerseChange must not have been called a second time (no loop). + expect(handleVerseChange).toHaveBeenCalledTimes(1); + }); + it('keeps clicked phrase focus when activeVerse updates to that same verse', async () => { const onVerseChange = jest.fn(); const { rerender } = render( diff --git a/src/__tests__/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx similarity index 98% rename from src/__tests__/SegmentView.test.tsx rename to src/__tests__/components/SegmentView.test.tsx index 6756321..08ac9fa 100644 --- a/src/__tests__/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Segment } from 'interlinearizer'; -import SegmentView from '../components/SegmentView'; +import SegmentView from '../../components/SegmentView'; /** A word token segment. */ const WORD_SEGMENT: Segment = { diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index ba3ba06..e5fec08 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -41,6 +41,9 @@ export default function ContinuousView({ book: Book; onVerseChange?: (verse: VerseCoordinate) => void; }>) { + const STRIP_FADE_MS = 500; + const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; + const allTokens: Token[] = useMemo( () => book.segments.flatMap((seg) => seg.tokens), [book.segments], @@ -70,6 +73,8 @@ export default function ContinuousView({ const phraseEntriesRef = useRef(phraseEntries); phraseEntriesRef.current = phraseEntries; + const focusPhraseIndexRef = useRef(0); + /** Flat token index -> phrase index lookup for focused rendering. */ const phraseIndexByTokenIndex = useMemo( () => @@ -93,16 +98,41 @@ export default function ContinuousView({ const tokenSegmentRef = useRef(tokenSegment); tokenSegmentRef.current = tokenSegment; - const [focusPhraseIndex, setFocusPhraseIndex] = useState(0); + const getPhraseIndexForVerse = useCallback( + (verse: VerseCoordinate | undefined): number | undefined => { + if (!verse) return undefined; - // Reset strip position whenever the book identity changes. - const prevBookIdRef = useRef(book.id); - useEffect(() => { - if (prevBookIdRef.current !== book.id) { - prevBookIdRef.current = book.id; - setFocusPhraseIndex(0); - } - }, [book.id]); + const seg = book.segments.find( + (s) => + s.startRef.book === verse.book && + s.startRef.chapter === verse.chapter && + s.startRef.verse === verse.verse, + ); + if (!seg) return undefined; + + const tokenIndex = segmentStartIndex.get(seg.id); + if (tokenIndex === undefined) return undefined; + + return phraseIndexByTokenIndex.get(tokenIndex); + }, + [book.segments, segmentStartIndex, phraseIndexByTokenIndex], + ); + + // Lazy-initialize to the target verse so on first render the strip is already positioned + // correctly before the initial-load fade-in fires. + const [focusPhraseIndex, setFocusPhraseIndex] = useState(() => { + if (!activeVerse) return 0; + const seg = book.segments.find( + (s) => + s.startRef.book === activeVerse.book && + s.startRef.chapter === activeVerse.chapter && + s.startRef.verse === activeVerse.verse, + ); + if (!seg) return 0; + const tokenIdx = segmentStartIndex.get(seg.id); + if (tokenIdx === undefined) return 0; + return phraseIndexByTokenIndex.get(tokenIdx) ?? 0; + }); /** * When activeVerse triggers a programmatic jump we record the target index here. The verse-change @@ -110,19 +140,26 @@ export default function ContinuousView({ * target index (rather than a boolean) avoids a race where the flag gets consumed by an unrelated * tokenSegment reference change before the focusIndex state update arrives. */ + focusPhraseIndexRef.current = focusPhraseIndex; + const jumpTargetRef = useRef(undefined); + const [pendingExternalJumpPhraseIndex, setPendingExternalJumpPhraseIndex] = useState< + number | undefined + >(undefined); + const [stripOpacity, setStripOpacity] = useState<0 | 1>(0); + const isExternalJumpInProgressRef = useRef(false); + const isInitialLoadInProgressRef = useRef(true); // Jump to the first token of the matching segment when the active verse changes. useEffect(() => { if (!activeVerse) return; // Preserve current phrase focus when it is already inside the target verse. - const currentlyFocusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; + const currentlyFocusedPhrase = phraseEntriesRef.current[focusPhraseIndexRef.current]; if (currentlyFocusedPhrase) { const focusedSeg = tokenSegmentRef.current[currentlyFocusedPhrase.tokenIndex]; if ( - focusedSeg && - focusedSeg.startRef.book === activeVerse.book && + focusedSeg?.startRef.book === activeVerse.book && focusedSeg.startRef.chapter === activeVerse.chapter && focusedSeg.startRef.verse === activeVerse.verse ) { @@ -130,23 +167,30 @@ export default function ContinuousView({ } } - const seg = book.segments.find( - (s) => - s.startRef.book === activeVerse.book && - s.startRef.chapter === activeVerse.chapter && - s.startRef.verse === activeVerse.verse, - ); - if (!seg) return; - const idx = segmentStartIndex.get(seg.id); - if (idx !== undefined) { - const phraseIndex = phraseIndexByTokenIndex.get(idx); - if (phraseIndex === undefined) return; - - jumpTargetRef.current = phraseIndex; - setFocusPhraseIndex(phraseIndex); - } + const phraseIndex = getPhraseIndexForVerse(activeVerse); + if (phraseIndex === undefined) return; + + jumpTargetRef.current = phraseIndex; + isExternalJumpInProgressRef.current = true; + setStripOpacity(0); + setPendingExternalJumpPhraseIndex(phraseIndex); + // focusPhraseIndexRef is a ref so it is always current without being a dependency. + // Listing focusPhraseIndex here would re-run the effect on every arrow press, causing the + // strip to jump back to the old verse before activeVerse has been updated by the parent. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeVerse?.book, activeVerse?.chapter, activeVerse?.verse]); + }, [activeVerse?.book, activeVerse?.chapter, activeVerse?.verse, getPhraseIndexForVerse]); + + // Let the fade-out complete before triggering the focus jump scroll. + useEffect(() => { + if (pendingExternalJumpPhraseIndex === undefined) return undefined; + + const timeout = setTimeout(() => { + setFocusPhraseIndex(pendingExternalJumpPhraseIndex); + setPendingExternalJumpPhraseIndex(undefined); + }, STRIP_FADE_MS); + + return () => clearTimeout(timeout); + }, [pendingExternalJumpPhraseIndex, STRIP_FADE_MS]); // Fire onVerseChange when arrow navigation crosses into a new verse. // Initialise to the first segment so the initial render does not trigger the callback. @@ -189,6 +233,7 @@ export default function ContinuousView({ const atStart = phraseEntries.length === 0 || focusPhraseIndex === 0; const atEnd = phraseEntries.length === 0 || focusPhraseIndex >= phraseEntries.length - 1; + const stripOpacityClass = stripOpacity === 1 ? 'tw-opacity-100' : 'tw-opacity-0'; const goLeft = useCallback(() => { setFocusPhraseIndex((i) => (i > 0 ? i - 1 : i)); @@ -200,11 +245,32 @@ export default function ContinuousView({ }, []); useEffect(() => { + const isExternalJump = isExternalJumpInProgressRef.current; + const isInitialLoad = isInitialLoadInProgressRef.current; + const shouldJumpInstantly = isExternalJump || isInitialLoad; phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ - behavior: 'smooth', + behavior: shouldJumpInstantly ? 'auto' : 'smooth', block: 'nearest', inline: 'center', }); + + if (!isExternalJump && !isInitialLoad) return undefined; + + // Clear the flags now — scrollIntoView has already been called above. Clearing here + // (rather than inside the RAF callback) keeps subsequent scroll behavior deterministic + // regardless of whether the RAF fires before the next focusPhraseIndex change. + if (isExternalJump) isExternalJumpInProgressRef.current = false; + if (isInitialLoad) isInitialLoadInProgressRef.current = false; + + // Defer the fade-in until after the browser applies the instant scroll position. + const rafId = requestAnimationFrame(() => setStripOpacity(1)); + + return () => { + cancelAnimationFrame(rafId); + // If the RAF was cancelled (another focus change fired before the first frame), + // still reveal the strip so it is not left invisible. + setStripOpacity(1); + }; }, [focusPhraseIndex]); return ( @@ -239,7 +305,13 @@ export default function ContinuousView({ )} {/* Inner flex row */} -
+
{allTokens.map((token, tokenIndex) => { if (token.type !== 'word') return ; diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index ecc0234..a187ada 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -6,7 +6,7 @@ import { useRecentScriptureRefs, } from '@papi/frontend/react'; import { isPlatformError } from 'platform-bible-utils'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BOOK_CHAPTER_CONTROL_STRING_KEYS, BookChapterControl, @@ -39,11 +39,13 @@ function ProjectBookFetcher({ scrRef, setScrRef, continuousScroll, + stickyTopOffset, }: Readonly<{ projectId: string; scrRef: ReturnType[0]; setScrRef: ReturnType[1]; continuousScroll: boolean; + stickyTopOffset: number; }>) { const bookScrRef = useMemo( () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), @@ -127,12 +129,17 @@ function ProjectBookFetcher({

Loading…

)} - {!bookError && !tokenizeError && !isLoading && book && continuousScroll === true && ( - + {!bookError && !tokenizeError && !isLoading && book && continuousScroll && ( +
+ +
)} {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( @@ -148,7 +155,7 @@ function ProjectBookFetcher({ key={seg.id} segment={seg} isActive={seg.startRef.verse === scrRef.verseNum} - displayMode={continuousScroll === true ? 'baseline-text' : 'token-chip'} + displayMode={continuousScroll ? 'baseline-text' : 'token-chip'} onClick={() => setScrRef({ book: seg.startRef.book, @@ -180,6 +187,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ useWebViewScrollGroupScrRef, }: WebViewProps) { const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + const toolbarRef = useRef(undefined); const [continuousScrollSetting, setContinuousScrollSetting] = useProjectSetting( projectId ?? '', 'interlinearizer.continuousScroll', @@ -190,6 +198,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ const [pendingContinuousScroll, setPendingContinuousScroll] = useState( undefined, ); + const [stickyTopOffset, setStickyTopOffset] = useState(0); // Drive UI from optimistic local state and clear pending once the setting confirms. useEffect(() => { @@ -203,6 +212,18 @@ globalThis.webViewComponent = function InterlinearizerWebView({ } }, [settingValue, pendingContinuousScroll]); + useEffect(() => { + const element = toolbarRef.current; + if (!element) return undefined; + + const updateOffset = () => setStickyTopOffset(element.getBoundingClientRect().height); + updateOffset(); + + const observer = new ResizeObserver(updateOffset); + observer.observe(element); + return () => observer.disconnect(); + }, []); + const [localizedStrings] = useLocalizedStrings( useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), ); @@ -211,42 +232,47 @@ globalThis.webViewComponent = function InterlinearizerWebView({ return (
-
+
{ + toolbarRef.current = element ?? undefined; + }} + className="tw-sticky tw-top-0 tw-z-10 tw-bg-background" + > +
+ + +
} endAreaChildren={ - + projectId && ( + { + if (pendingContinuousScroll !== undefined) return; + setContinuousScroll(checked); + setPendingContinuousScroll(checked); + setContinuousScrollSetting?.(checked); + }} + /> + ) } onSelectProjectMenuItem={() => {}} onSelectViewInfoMenuItem={() => {}} /> - {projectId && ( -
- { - if (pendingContinuousScroll !== undefined) return; - setContinuousScroll(checked); - setPendingContinuousScroll(checked); - setContinuousScrollSetting?.(checked); - }} - /> -
- )}
@@ -256,6 +282,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ projectId={projectId} scrRef={scrRef} setScrRef={setScrRef} + stickyTopOffset={stickyTopOffset} /> ) : (

From 04e852bf7dffa9217f05cba866c2ad67a0c8d261 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 5 May 2026 16:52:44 -0400 Subject: [PATCH 10/27] Extract book data into hook and restructure sticky layout - Create useInterlinearizerBookData hook to consolidate USJ fetch, tokenization, and state - Remove ProjectBookFetcher component entirely - Simplify root rendering to directly use hook output - Move ContinuousView into toolbar sticky container to prevent overlap - Remove ResizeObserver and stickyTopOffset complexity - Clean up jest.setup.ts mock (no longer needed) --- jest.setup.ts | 14 -- src/hooks/useInterlinearizerBookData.ts | 86 ++++++++ src/interlinearizer.web-view.tsx | 265 ++++++++---------------- 3 files changed, 168 insertions(+), 197 deletions(-) create mode 100644 src/hooks/useInterlinearizerBookData.ts diff --git a/jest.setup.ts b/jest.setup.ts index 55971aa..cf32fbe 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -3,17 +3,3 @@ * matchers for React component tests. */ import '@testing-library/jest-dom'; - -function ResizeObserverMock(): void {} - -ResizeObserverMock.prototype.observe = function observe(): void {}; -ResizeObserverMock.prototype.unobserve = function unobserve(): void {}; -ResizeObserverMock.prototype.disconnect = function disconnect(): void {}; - -if (!global.ResizeObserver) { - Object.defineProperty(global, 'ResizeObserver', { - configurable: true, - writable: true, - value: ResizeObserverMock, - }); -} diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts new file mode 100644 index 0000000..302db10 --- /dev/null +++ b/src/hooks/useInterlinearizerBookData.ts @@ -0,0 +1,86 @@ +import type { WebViewProps } from '@papi/core'; +import { logger } from '@papi/frontend'; +import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import type { Book } from 'interlinearizer'; +import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { isPlatformError } from 'platform-bible-utils'; +import { useEffect, useMemo } from 'react'; + +type ScrRef = ReturnType[0]; + +interface UseInterlinearizerBookDataArgs { + projectId?: string; + scrRef: ScrRef; +} + +interface UseInterlinearizerBookDataResult { + book: Book | undefined; + chapterSegments: Book['segments']; + isLoading: boolean; + bookError: string | undefined; + tokenizeError: { message: string; raw: unknown } | undefined; +} + +export default function useInterlinearizerBookData({ + projectId, + scrRef, +}: Readonly): UseInterlinearizerBookDataResult { + const hasProject = Boolean(projectId); + const resolvedProjectId = projectId ?? ''; + + const bookScrRef = useMemo( + () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), + [scrRef.book], + ); + + const [bookResult, , isLoadingRaw] = useProjectData( + 'platformScripture.USJ_Book', + resolvedProjectId, + ).BookUSJ(bookScrRef, undefined); + const [writingSystem] = useProjectSetting(resolvedProjectId, 'platform.languageTag', ''); + + const [book, tokenizeError] = useMemo((): [ + Book | undefined, + { message: string; raw: unknown } | undefined, + ] => { + if (!bookResult || isPlatformError(bookResult)) return [undefined, undefined]; + try { + const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; + return [tokenizeBook(extractBookFromUsj(bookResult, ws)), undefined]; + } catch (err) { + return [undefined, { message: err instanceof Error ? err.message : String(err), raw: err }]; + } + }, [bookResult, writingSystem]); + + useEffect(() => { + if (!hasProject || !tokenizeError) return; + + const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; + logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { + message: tokenizeError.message, + writingSystem: ws, + projectId: resolvedProjectId, + book: scrRef.book, + }); + }, [hasProject, tokenizeError, writingSystem, resolvedProjectId, scrRef.book]); + + const chapterSegments = useMemo( + () => + book?.segments.filter( + (seg) => seg.startRef.book === scrRef.book && seg.startRef.chapter === scrRef.chapterNum, + ) ?? [], + [book, scrRef.book, scrRef.chapterNum], + ); + + const isLoading = hasProject ? isLoadingRaw : false; + + let bookError: string | undefined; + if (hasProject && isPlatformError(bookResult)) { + bookError = bookResult.message; + } else if (hasProject && !isLoading && bookResult === undefined) { + bookError = `No USJ book available for ${scrRef.book} in project ${resolvedProjectId}`; + } + + return { book, chapterSegments, isLoading, bookError, tokenizeError }; +} diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index a187ada..4b017d5 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -1,179 +1,26 @@ import type { WebViewProps } from '@papi/core'; import { useLocalizedStrings, - useProjectData, useProjectSetting, useRecentScriptureRefs, } from '@papi/frontend/react'; -import { isPlatformError } from 'platform-bible-utils'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { BOOK_CHAPTER_CONTROL_STRING_KEYS, BookChapterControl, ScrollGroupSelector, TabToolbar, } from 'platform-bible-react'; -import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; -import { tokenizeBook } from 'parsers/papi/bookTokenizer'; -import type { Book } from 'interlinearizer'; -import { logger } from '@papi/frontend'; import ContinuousScrollToggle from './components/ContinuousScrollToggle'; import ContinuousView from './components/ContinuousView'; import SegmentView from './components/SegmentView'; +import useInterlinearizerBookData from './hooks/useInterlinearizerBookData'; const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; -/** - * Fetches the USJ book for the given project, tokenizes it, and renders all segments in the current - * chapter. Shows loading / error states while data is in flight or unavailable. - * - * @param props - Component props - * @param props.projectId - PAPI project ID whose USJ book to fetch and tokenize - * @param props.scrRef - Current scripture reference shared via the scroll group - * @param props.setScrRef - Setter to update the scroll-group scripture reference when a segment is - * clicked - * @returns A column of {@link SegmentView} chips, or an appropriate loading / error message - */ -function ProjectBookFetcher({ - projectId, - scrRef, - setScrRef, - continuousScroll, - stickyTopOffset, -}: Readonly<{ - projectId: string; - scrRef: ReturnType[0]; - setScrRef: ReturnType[1]; - continuousScroll: boolean; - stickyTopOffset: number; -}>) { - const bookScrRef = useMemo( - () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), - [scrRef.book], - ); - const [bookResult, , isLoading] = useProjectData('platformScripture.USJ_Book', projectId).BookUSJ( - bookScrRef, - undefined, - ); - - const [writingSystem] = useProjectSetting(projectId, 'platform.languageTag', ''); - - const [book, tokenizeError] = useMemo((): [ - Book | undefined, - { message: string; raw: unknown } | undefined, - ] => { - if (!bookResult || isPlatformError(bookResult)) return [undefined, undefined]; - try { - const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; - return [tokenizeBook(extractBookFromUsj(bookResult, ws)), undefined]; - } catch (err) { - return [undefined, { message: err instanceof Error ? err.message : String(err), raw: err }]; - } - }, [bookResult, writingSystem]); - - useEffect(() => { - if (tokenizeError) { - const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; - logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { - message: tokenizeError.message, - writingSystem: ws, - projectId, - book: scrRef.book, - }); - } - }, [tokenizeError, writingSystem, projectId, scrRef.book]); - - const chapterSegments = useMemo( - () => - book?.segments.filter( - (seg) => seg.startRef.book === scrRef.book && seg.startRef.chapter === scrRef.chapterNum, - ) ?? [], - [book, scrRef.book, scrRef.chapterNum], - ); - - let bookError: string | undefined; - if (isPlatformError(bookResult)) { - bookError = bookResult.message; - } else if (!isLoading && bookResult === undefined) { - bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; - } - - const handleContinuousVerseChange = useCallback( - (v: { book: string; chapter: number; verse: number }) => { - setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); - }, - [setScrRef], - ); - - return ( -

- {bookError && ( -
-

Error loading book

-
-            {bookError}
-          
-
- )} - - {tokenizeError && ( -
-

Error processing book

-
-            {tokenizeError.message}
-          
-
- )} - - {!bookError && !tokenizeError && isLoading && ( -

Loading…

- )} - - {!bookError && !tokenizeError && !isLoading && book && continuousScroll && ( -
- -
- )} - - {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( -

- No verse data for {scrRef.book} {scrRef.chapterNum}. -

- )} - - {!bookError && !tokenizeError && !isLoading && chapterSegments.length > 0 && ( -
- {chapterSegments.map((seg) => ( - - setScrRef({ - book: seg.startRef.book, - chapterNum: seg.startRef.chapter, - verseNum: seg.startRef.verse, - }) - } - /> - ))} -
- )} -
- ); -} - /** * Root WebView component for the Interlinearizer. Renders a sticky reference picker at the top and - * delegates book fetching to {@link ProjectBookFetcher} in the scrollable content area below. + * uses hook-backed book state to render continuous and segmented views. * * @param props - WebView props injected by the PAPI host * @param props.projectId - PAPI project ID passed from the host; undefined when the WebView is @@ -187,7 +34,6 @@ globalThis.webViewComponent = function InterlinearizerWebView({ useWebViewScrollGroupScrRef, }: WebViewProps) { const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); - const toolbarRef = useRef(undefined); const [continuousScrollSetting, setContinuousScrollSetting] = useProjectSetting( projectId ?? '', 'interlinearizer.continuousScroll', @@ -198,7 +44,12 @@ globalThis.webViewComponent = function InterlinearizerWebView({ const [pendingContinuousScroll, setPendingContinuousScroll] = useState( undefined, ); - const [stickyTopOffset, setStickyTopOffset] = useState(0); + const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( + { + projectId, + scrRef, + }, + ); // Drive UI from optimistic local state and clear pending once the setting confirms. useEffect(() => { @@ -212,32 +63,21 @@ globalThis.webViewComponent = function InterlinearizerWebView({ } }, [settingValue, pendingContinuousScroll]); - useEffect(() => { - const element = toolbarRef.current; - if (!element) return undefined; - - const updateOffset = () => setStickyTopOffset(element.getBoundingClientRect().height); - updateOffset(); - - const observer = new ResizeObserver(updateOffset); - observer.observe(element); - return () => observer.disconnect(); - }, []); - const [localizedStrings] = useLocalizedStrings( useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), ); const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = useRecentScriptureRefs(); + const handleContinuousVerseChange = useCallback( + (v: { book: string; chapter: number; verse: number }) => { + setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); + }, + [setScrRef], + ); return (
-
{ - toolbarRef.current = element ?? undefined; - }} - className="tw-sticky tw-top-0 tw-z-10 tw-bg-background" - > +
{}} onSelectViewInfoMenuItem={() => {}} /> + {projectId && !bookError && !tokenizeError && !isLoading && book && continuousScroll && ( +
+ +
+ )}
{projectId ? ( - +
+ {bookError && ( +
+

+ Error loading book +

+
+                  {bookError}
+                
+
+ )} + + {tokenizeError && ( +
+

+ Error processing book +

+
+                  {tokenizeError.message}
+                
+
+ )} + + {!bookError && !tokenizeError && isLoading && ( +

Loading…

+ )} + + {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( +

+ No verse data for {scrRef.book} {scrRef.chapterNum}. +

+ )} + + {!bookError && !tokenizeError && !isLoading && chapterSegments.length > 0 && ( +
+ {chapterSegments.map((seg) => ( + + setScrRef({ + book: seg.startRef.book, + chapterNum: seg.startRef.chapter, + verseNum: seg.startRef.verse, + }) + } + /> + ))} +
+ )} +
) : (

Open this WebView from a Paratext project to load its source book. From 4fcdb784f8f95fda236e3e0f7191c045a9c78268 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 5 May 2026 17:34:17 -0400 Subject: [PATCH 11/27] Add comprehensive web view, hook, and component tests --- src/__tests__/components/PhraseBox.test.tsx | 134 +++++++ .../hooks/useInterlinearizerBookData.test.ts | 350 ++++++++++++++++++ .../interlinearizer.web-view.test.tsx | 53 ++- 3 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/components/PhraseBox.test.tsx create mode 100644 src/__tests__/hooks/useInterlinearizerBookData.test.ts diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx new file mode 100644 index 0000000..6fc3aec --- /dev/null +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -0,0 +1,134 @@ +/** @file Unit tests for PhraseBox component. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Token } from 'interlinearizer'; +import PhraseBox from '../../components/PhraseBox'; + +jest.mock('../../components/TokenChip', () => ({ + __esModule: true, + default: ({ token }: { token: Token }) => ( + {token.surfaceText} + ), +})); + +/** Pre-built test token */ +const TEST_TOKEN: Token = { + id: 'token-1', + surfaceText: 'Hello', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, +}; + +/** Second test token */ +const TEST_TOKEN_2: Token = { + id: 'token-2', + surfaceText: 'World', + writingSystem: 'en', + type: 'word', + charStart: 6, + charEnd: 11, +}; + +describe('PhraseBox', () => { + it('renders as a span when no onClick handler is provided', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox?.tagName).toBe('SPAN'); + }); + + it('renders as a button when onClick handler is provided', () => { + const mockOnClick = jest.fn(); + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox?.tagName).toBe('BUTTON'); + expect(phraseBox).toHaveAttribute('type', 'button'); + }); + + it('renders tokens using TokenChip components', () => { + render(); + + expect(screen.getByTestId('token-token-1')).toBeInTheDocument(); + expect(screen.getByTestId('token-token-2')).toBeInTheDocument(); + }); + + it('calls onClick when button is clicked', async () => { + const mockOnClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('applies focused styling when isFocused is true', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveAttribute('data-focus-state', 'focused'); + expect(phraseBox).toHaveClass('tw-border-2'); + }); + + it('applies default styling when isFocused is false', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveAttribute('data-focus-state', 'default'); + expect(phraseBox).not.toHaveClass('tw-border-2'); + }); + + it('applies default styling when isFocused is not provided', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveAttribute('data-focus-state', 'default'); + }); + + it('button has correct focused styling and cursor', () => { + const mockOnClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('data-focus-state', 'focused'); + expect(button).toHaveClass('tw-cursor-pointer'); + expect(button).toHaveClass('tw-text-left'); + }); + + it('button has hover styling', () => { + const mockOnClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('hover:tw-bg-muted/30'); + }); + + it('renders multiple tokens in order', () => { + render(); + + const tokens = document.querySelectorAll('[data-testid^="token-"]'); + expect(tokens[0]).toHaveAttribute('data-testid', 'token-token-1'); + expect(tokens[1]).toHaveAttribute('data-testid', 'token-token-2'); + }); + + it('applies base spacing classes to both button and span', () => { + const { rerender } = render(); + + const span = document.querySelector('[data-phrase-box="true"]'); + expect(span).toHaveClass('tw-px-1'); + expect(span).toHaveClass('tw-py-0.5'); + + const mockOnClick = jest.fn(); + rerender(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('tw-px-1'); + expect(button).toHaveClass('tw-py-0.5'); + }); +}); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts new file mode 100644 index 0000000..b2ab77d --- /dev/null +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -0,0 +1,350 @@ +/** @file Unit tests for useInterlinearizerBookData hook. */ +/// + +import { logger } from '@papi/frontend'; +import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { renderHook } from '@testing-library/react'; +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { extractBookFromUsj, type RawBook } from 'parsers/papi/usjBookExtractor'; +import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; + +jest.mock('@papi/frontend'); +jest.mock('@papi/frontend/react'); +jest.mock('parsers/papi/bookTokenizer'); +jest.mock('parsers/papi/usjBookExtractor'); + +/** Mock PlatformError shape */ +type PlatformError = { message: string; platformErrorVersion: number }; + +/** Pre-built RawBook for mocking extractBookFromUsj return value */ +const TEST_RAW_BOOK: RawBook = { + bookCode: 'GEN', + writingSystem: 'en', + contentHash: 'test-hash', + verses: [ + { sid: 'GEN 1:1', text: 'In the beginning.' }, + { sid: 'GEN 1:2', text: 'And the earth.' }, + { sid: 'GEN 2:1', text: 'The second day.' }, + ], +}; + +/** Pre-built Book for mocking tokenizeBook return value */ +const TEST_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + id: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + { + id: 'GEN 2:1', + startRef: { book: 'GEN', chapter: 2, verse: 1 }, + endRef: { book: 'GEN', chapter: 2, verse: 1 }, + baselineText: 'The second day.', + tokens: [ + { + id: 'GEN 2:1:0', + surfaceText: 'The', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], +}; + +const GEN_1_1_SRC_REF = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +describe('useInterlinearizerBookData', () => { + /** Default mock setup for useProjectData */ + const setupDefaultProjectDataMock = () => { + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [{ USJ: 'mock-usj' }, jest.fn(), false], + }); + }; + + /** Default mock setup for useProjectSetting */ + const setupDefaultProjectSettingMock = () => { + jest.mocked(useProjectSetting).mockReturnValue(['en', jest.fn(), jest.fn(), false]); + }; + + beforeAll(() => { + setupDefaultProjectDataMock(); + setupDefaultProjectSettingMock(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(logger.error).mockImplementation(() => {}); + setupDefaultProjectDataMock(); + setupDefaultProjectSettingMock(); + }); + + it('returns idle state when no projectId is provided', () => { + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: undefined, scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + expect(result.current.bookError).toBeUndefined(); + expect(result.current.tokenizeError).toBeUndefined(); + }); + + it('returns book data when project is set and data loads successfully', () => { + jest.mocked(useProjectData).mockReturnValue({ BookUSJ: () => [undefined, jest.fn(), true] }); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + expect(result.current.bookError).toBeUndefined(); + }); + + it('returns error when USJ book data is a PlatformError', () => { + const platformError: PlatformError = { message: 'Project not found', platformErrorVersion: 1 }; + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [platformError, jest.fn(), false], + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.bookError).toBe('Project not found'); + expect(result.current.tokenizeError).toBeUndefined(); + }); + + it('returns error when USJ book is unavailable', () => { + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [undefined, jest.fn(), false], + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.bookError).toContain('No USJ book available'); + }); + + it('returns tokenization error when extractBookFromUsj throws', () => { + const error = new Error('Invalid USJ format'); + jest.mocked(extractBookFromUsj).mockImplementation(() => { + throw error; + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.tokenizeError?.message).toBe('Invalid USJ format'); + expect(result.current.tokenizeError?.raw).toBe(error); + }); + + it('returns tokenization error when tokenizeBook throws non-Error', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const nonErrorValue = 'some string error'; + jest.mocked(tokenizeBook).mockImplementation(() => { + throw nonErrorValue; + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.tokenizeError?.message).toBe('some string error'); + expect(result.current.tokenizeError?.raw).toBe(nonErrorValue); + }); + + it('filters segments to current chapter', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.chapterSegments).toHaveLength(2); // Only GEN 1:1 and GEN 1:2 + expect(result.current.chapterSegments[0].id).toBe('GEN 1:1'); + expect(result.current.chapterSegments[1].id).toBe('GEN 1:2'); + }); + + it('filters segments for different chapters correctly', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ + projectId: 'test-project', + scrRef: { book: 'GEN', chapterNum: 2, verseNum: 1 }, + }), + ); + + expect(result.current.chapterSegments).toHaveLength(1); // Only GEN 2:1 + expect(result.current.chapterSegments[0].id).toBe('GEN 2:1'); + }); + + it('falls back to "und" writing system when useProjectSetting returns PlatformError', () => { + const platformError: PlatformError = { + message: 'Setting unavailable', + platformErrorVersion: 1, + }; + jest.mocked(useProjectSetting).mockReturnValue([platformError, jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBe(TEST_BOOK); + expect(jest.mocked(extractBookFromUsj)).toHaveBeenCalledWith({ USJ: 'mock-usj' }, 'und'); + }); + + it('falls back to "und" writing system when useProjectSetting returns empty string', () => { + jest.mocked(useProjectSetting).mockReturnValue(['', jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBe(TEST_BOOK); + expect(jest.mocked(extractBookFromUsj)).toHaveBeenCalledWith({ USJ: 'mock-usj' }, 'und'); + }); + + it('logs tokenization error when hook has projectId and tokenizeError occurs', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const error = new Error('Tokenization failed'); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw error; + }); + + renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(jest.mocked(logger.error)).toHaveBeenCalledWith( + 'Failed to parse/tokenize USJ book', + error, + { + book: 'GEN', + message: 'Tokenization failed', + projectId: 'test-project', + writingSystem: 'en', + }, + ); + }); + + it('does not log error when hook has no projectId', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const error = new Error('Tokenization failed'); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw error; + }); + + renderHook(() => + useInterlinearizerBookData({ projectId: undefined, scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(jest.mocked(logger.error)).not.toHaveBeenCalled(); + }); + + it('logs tokenization error with PlatformError writing system', () => { + const platformError: PlatformError = { + message: 'Setting unavailable', + platformErrorVersion: 1, + }; + jest.mocked(useProjectSetting).mockReturnValue([platformError, jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const error = new Error('Tokenization failed'); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw error; + }); + + renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(jest.mocked(logger.error)).toHaveBeenCalledWith( + 'Failed to parse/tokenize USJ book', + error, + { + book: 'GEN', + message: 'Tokenization failed', + projectId: 'test-project', + writingSystem: 'und', + }, + ); + }); + + it('logs tokenization error with empty string writing system', () => { + jest.mocked(useProjectSetting).mockReturnValue(['', jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const error = new Error('Tokenization failed'); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw error; + }); + + renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(jest.mocked(logger.error)).toHaveBeenCalledWith( + 'Failed to parse/tokenize USJ book', + error, + { + book: 'GEN', + message: 'Tokenization failed', + projectId: 'test-project', + writingSystem: 'und', + }, + ); + }); +}); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index 5fbf86f..a49ab37 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -18,9 +18,16 @@ import { tokenizeBook } from 'parsers/papi/bookTokenizer'; jest.mock('parsers/papi/bookTokenizer'); jest.mock('parsers/papi/usjBookExtractor'); + +// Store captured props so tests can simulate callbacks +let capturedContinuousViewProps: Record = {}; + jest.mock('../components/ContinuousView', () => ({ __esModule: true, - default: () =>

, + default: (props: Record) => { + capturedContinuousViewProps = props; + return
; + }, })); /** @@ -525,4 +532,48 @@ describe('InterlinearizerWebView', () => { ); expect(allElements[0]).toBe(continuousView); }); + + it('toggles continuous scroll setting back to true after being false', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [false, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + + render(); + + // Initially in token-chip mode + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + + // Click toggle to turn on continuous mode + await userEvent.click(screen.getByRole('checkbox')); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(true); + }); + + it('calls setScrRef when ContinuousView emits onVerseChange', async () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); + mockWritingSystem('en', true); + + const mockSetScrRef = jest.fn(); + render(); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + + // Simulate ContinuousView calling onVerseChange + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion + const onVerseChange = capturedContinuousViewProps.onVerseChange as any; + expect(onVerseChange).toBeDefined(); + + onVerseChange({ book: 'GEN', chapter: 2, verse: 3 }); + + expect(mockSetScrRef).toHaveBeenCalledWith({ + book: 'GEN', + chapterNum: 2, + verseNum: 3, + }); + }); }); From 189af0a6a6201b1e7fa644c85ace4c0adba458af Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 09:11:57 -0400 Subject: [PATCH 12/27] Add border below ContinuousView --- src/interlinearizer.web-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 4b017d5..6102051 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -114,7 +114,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ onSelectViewInfoMenuItem={() => {}} /> {projectId && !bookError && !tokenizeError && !isLoading && book && continuousScroll && ( -
+
Date: Thu, 7 May 2026 09:17:52 -0400 Subject: [PATCH 13/27] Memoize segment onClick --- src/components/SegmentView.tsx | 8 +++++--- src/interlinearizer.web-view.tsx | 17 ++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index 9cb7332..3118e4f 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -23,9 +23,11 @@ export default function SegmentView({ }: Readonly<{ displayMode?: SegmentDisplayMode; isActive?: boolean; - onClick?: () => void; + onClick?: (ref: { book: string; chapter: number; verse: number }) => void; segment: Segment; }>) { + const { book, chapter, verse } = segment.startRef; + return ( ); } + +const MemoizedSegmentView = memo(SegmentView); +export default MemoizedSegmentView; diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 2207b50..ccc929a 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -1,4 +1,5 @@ import type { Token } from 'interlinearizer'; +import { memo } from 'react'; /** * Renders a single token as an inline chip. Word tokens get a bordered box; non-word tokens (e.g. @@ -8,7 +9,7 @@ import type { Token } from 'interlinearizer'; * @param props.token - The token to render * @returns A styled inline span */ -export default function TokenChip({ token }: Readonly<{ token: Token }>) { +export function TokenChip({ token }: Readonly<{ token: Token }>) { return token.type === 'word' ? ( {token.surfaceText} @@ -19,3 +20,6 @@ export default function TokenChip({ token }: Readonly<{ token: Token }>) { ); } + +const MemoizedTokenChip = memo(TokenChip); +export default MemoizedTokenChip; diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index d9ad7af..78e8830 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -13,7 +13,7 @@ import { } from 'platform-bible-react'; import ContinuousScrollToggle from './components/ContinuousScrollToggle'; import ContinuousView from './components/ContinuousView'; -import SegmentView from './components/SegmentView'; +import MemoizedSegmentView from './components/SegmentView'; import useInterlinearizerBookData from './hooks/useInterlinearizerBookData'; const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; @@ -175,7 +175,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ {!bookError && !tokenizeError && !isLoading && chapterSegments.length > 0 && (
{chapterSegments.map((seg) => ( - Date: Thu, 7 May 2026 10:01:45 -0400 Subject: [PATCH 15/27] Avoid first-load extra fire; Tidy code --- src/components/ContinuousView.tsx | 46 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 50edbfc..38b1b59 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -99,8 +99,8 @@ export default function ContinuousView({ tokenSegmentRef.current = tokenSegment; const getPhraseIndexForVerse = useCallback( - (verse: VerseCoordinate | undefined): number | undefined => { - if (!verse) return undefined; + (verse?: VerseCoordinate): number | undefined => { + if (!verse) return; const seg = book.segments.find( (s) => @@ -108,10 +108,10 @@ export default function ContinuousView({ s.startRef.chapter === verse.chapter && s.startRef.verse === verse.verse, ); - if (!seg) return undefined; + if (!seg) return; const tokenIndex = segmentStartIndex.get(seg.id); - if (tokenIndex === undefined) return undefined; + if (tokenIndex === undefined) return; return phraseIndexByTokenIndex.get(tokenIndex); }, @@ -122,6 +122,7 @@ export default function ContinuousView({ // correctly before the initial-load fade-in fires. const [focusPhraseIndex, setFocusPhraseIndex] = useState(() => { if (!activeVerse) return 0; + const seg = book.segments.find( (s) => s.startRef.book === activeVerse.book && @@ -129,8 +130,10 @@ export default function ContinuousView({ s.startRef.verse === activeVerse.verse, ); if (!seg) return 0; + const tokenIdx = segmentStartIndex.get(seg.id); if (tokenIdx === undefined) return 0; + return phraseIndexByTokenIndex.get(tokenIdx) ?? 0; }); @@ -142,11 +145,12 @@ export default function ContinuousView({ */ focusPhraseIndexRef.current = focusPhraseIndex; - const jumpTargetRef = useRef(undefined); + const jumpTargetRef = useRef(); const [pendingExternalJumpPhraseIndex, setPendingExternalJumpPhraseIndex] = useState< number | undefined - >(undefined); - const [stripOpacity, setStripOpacity] = useState<0 | 1>(0); + >(); + const [isVisible, setIsVisible] = useState(false); + const isExternalJumpInProgressRef = useRef(false); const isInitialLoadInProgressRef = useRef(true); @@ -157,11 +161,11 @@ export default function ContinuousView({ // Preserve current phrase focus when it is already inside the target verse. const currentlyFocusedPhrase = phraseEntriesRef.current[focusPhraseIndexRef.current]; if (currentlyFocusedPhrase) { - const focusedSeg = tokenSegmentRef.current[currentlyFocusedPhrase.tokenIndex]; + const ref = tokenSegmentRef.current[currentlyFocusedPhrase.tokenIndex]?.startRef; if ( - focusedSeg?.startRef.book === activeVerse.book && - focusedSeg.startRef.chapter === activeVerse.chapter && - focusedSeg.startRef.verse === activeVerse.verse + ref?.book === activeVerse.book && + ref.chapter === activeVerse.chapter && + ref.verse === activeVerse.verse ) { return; } @@ -172,7 +176,7 @@ export default function ContinuousView({ jumpTargetRef.current = phraseIndex; isExternalJumpInProgressRef.current = true; - setStripOpacity(0); + setIsVisible(false); setPendingExternalJumpPhraseIndex(phraseIndex); // focusPhraseIndexRef is a ref so it is always current without being a dependency. // Listing focusPhraseIndex here would re-run the effect on every arrow press, causing the @@ -193,10 +197,14 @@ export default function ContinuousView({ }, [pendingExternalJumpPhraseIndex, STRIP_FADE_MS]); // Fire onVerseChange when arrow navigation crosses into a new verse. - // Initialise to the first segment so the initial render does not trigger the callback. + // Initialise to the segment that owns the initial focusPhraseIndex so the initial render does not trigger the callback. const firstVisibleSegId = phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined; - const lastReportedSegIdRef = useRef(firstVisibleSegId); + const initialFocusedPhrase = phraseEntries[focusPhraseIndex]; + const initialSegId = initialFocusedPhrase + ? tokenSegment[initialFocusedPhrase.tokenIndex]?.id + : firstVisibleSegId; + const lastReportedSegIdRef = useRef(initialSegId); // Keep the reported-segment baseline in sync when switching to a different book. useEffect(() => { @@ -231,9 +239,9 @@ export default function ContinuousView({ // One ref slot per phrase so we can call scrollIntoView on the focused one. const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); - const atStart = phraseEntries.length === 0 || focusPhraseIndex === 0; - const atEnd = phraseEntries.length === 0 || focusPhraseIndex >= phraseEntries.length - 1; - const stripOpacityClass = stripOpacity === 1 ? 'tw-opacity-100' : 'tw-opacity-0'; + const atStart = !phraseEntries.length || !focusPhraseIndex; + const atEnd = !phraseEntries.length || focusPhraseIndex >= phraseEntries.length - 1; + const stripOpacityClass = isVisible ? 'tw-opacity-100' : 'tw-opacity-0'; const goLeft = useCallback(() => { setFocusPhraseIndex((i) => (i > 0 ? i - 1 : i)); @@ -272,13 +280,13 @@ export default function ContinuousView({ if (isInitialLoad) isInitialLoadInProgressRef.current = false; // Defer the fade-in until after the browser applies the instant scroll position. - const rafId = requestAnimationFrame(() => setStripOpacity(1)); + const rafId = requestAnimationFrame(() => setIsVisible(true)); return () => { cancelAnimationFrame(rafId); // If the RAF was cancelled (another focus change fired before the first frame), // still reveal the strip so it is not left invisible. - setStripOpacity(1); + setIsVisible(true); }; }, [focusPhraseIndex]); From 4a024ee68853235d2d4e5d663355b959d7ed7368 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 11:19:50 -0400 Subject: [PATCH 16/27] Add timeout if setting-save fails --- src/interlinearizer.web-view.tsx | 50 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 78e8830..c0afe55 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -4,7 +4,7 @@ import { useProjectSetting, useRecentScriptureRefs, } from '@papi/frontend/react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BOOK_CHAPTER_CONTROL_STRING_KEYS, BookChapterControl, @@ -34,16 +34,18 @@ globalThis.webViewComponent = function InterlinearizerWebView({ useWebViewScrollGroupScrRef, }: WebViewProps) { const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + const [continuousScrollSetting, setContinuousScrollSetting] = useProjectSetting( projectId ?? '', 'interlinearizer.continuousScroll', true, ); const settingValue = continuousScrollSetting === true; + const [continuousScroll, setContinuousScroll] = useState(settingValue); - const [pendingContinuousScroll, setPendingContinuousScroll] = useState( - undefined, - ); + const [pendingContinuousScroll, setPendingContinuousScroll] = useState(); + const skipSettingRevertRef = useRef(false); + const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( { projectId, @@ -54,14 +56,15 @@ globalThis.webViewComponent = function InterlinearizerWebView({ // Drive UI from optimistic local state and clear pending once the setting confirms. useEffect(() => { if (pendingContinuousScroll === undefined) { - setContinuousScroll(settingValue); - return; - } - if (settingValue === pendingContinuousScroll) { + if (!skipSettingRevertRef.current) { + setContinuousScroll(settingValue); + } + skipSettingRevertRef.current = false; + } else if (settingValue === pendingContinuousScroll) { setPendingContinuousScroll(undefined); setContinuousScroll(settingValue); } - }, [settingValue, pendingContinuousScroll]); + }, [pendingContinuousScroll, settingValue]); const [localizedStrings] = useLocalizedStrings( useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), @@ -70,6 +73,28 @@ globalThis.webViewComponent = function InterlinearizerWebView({ const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = useRecentScriptureRefs(); + const handleContinuousScrollChange = useCallback( + (checked: boolean) => { + if (pendingContinuousScroll !== undefined) return; + + setContinuousScroll(checked); + setPendingContinuousScroll(checked); + setContinuousScrollSetting?.(checked); + }, + [pendingContinuousScroll, setContinuousScrollSetting], + ); + + // Clear the pending flag after 15 s if the setting never confirms (e.g. network failure). + useEffect(() => { + if (pendingContinuousScroll === undefined) return undefined; + + const timeout = setTimeout(() => { + skipSettingRevertRef.current = true; + setPendingContinuousScroll(undefined); + }, 15_000); + return () => clearTimeout(timeout); + }, [pendingContinuousScroll]); + const handleContinuousVerseChange = useCallback( (v: { book: string; chapter: number; verse: number }) => { setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); @@ -110,12 +135,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({ { - if (pendingContinuousScroll !== undefined) return; - setContinuousScroll(checked); - setPendingContinuousScroll(checked); - setContinuousScrollSetting?.(checked); - }} + onCheckedChange={handleContinuousScrollChange} /> ) } From 0b6ac12df9b94011bddedd3e0d66b29118aa5001 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 14:02:02 -0400 Subject: [PATCH 17/27] Extract settings hook and add testing --- .../ContinuousScrollToggle.test.tsx | 18 +- .../hooks/useInterlinearizerBookData.test.ts | 2 - .../hooks/useOptimisticBooleanSetting.test.ts | 176 ++++++++++++++++++ .../interlinearizer.web-view.test.tsx | 5 +- src/components/ContinuousScrollToggle.tsx | 10 +- src/hooks/useOptimisticBooleanSetting.ts | 65 +++++++ src/interlinearizer.web-view.tsx | 57 +----- 7 files changed, 255 insertions(+), 78 deletions(-) create mode 100644 src/__tests__/hooks/useOptimisticBooleanSetting.test.ts create mode 100644 src/hooks/useOptimisticBooleanSetting.ts diff --git a/src/__tests__/components/ContinuousScrollToggle.test.tsx b/src/__tests__/components/ContinuousScrollToggle.test.tsx index 6d21597..34a4558 100644 --- a/src/__tests__/components/ContinuousScrollToggle.test.tsx +++ b/src/__tests__/components/ContinuousScrollToggle.test.tsx @@ -9,12 +9,12 @@ import ContinuousScrollToggle from '../../components/ContinuousScrollToggle'; describe('ContinuousScrollToggle', () => { beforeEach(() => { - jest.mocked(useLocalizedStrings).mockReturnValue([ - { - '%interlinearizer_continuousScrollToggle%': 'Continuous Scroll', - }, - false, - ]); + jest + .mocked(useLocalizedStrings) + .mockReturnValue([ + { '%interlinearizer_continuousScrollToggle%': 'Continuous Scroll' }, + false, + ]); }); it('calls onCheckedChange when toggled', async () => { @@ -27,10 +27,4 @@ describe('ContinuousScrollToggle', () => { expect(onCheckedChange).toHaveBeenCalledWith(false); }); - - it('renders disabled state when disabled is true', () => { - render(); - - expect(screen.getByRole('checkbox')).toBeDisabled(); - }); }); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index b2ab77d..67cf19a 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -9,8 +9,6 @@ import { tokenizeBook } from 'parsers/papi/bookTokenizer'; import { extractBookFromUsj, type RawBook } from 'parsers/papi/usjBookExtractor'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; -jest.mock('@papi/frontend'); -jest.mock('@papi/frontend/react'); jest.mock('parsers/papi/bookTokenizer'); jest.mock('parsers/papi/usjBookExtractor'); diff --git a/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts new file mode 100644 index 0000000..aab30a2 --- /dev/null +++ b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts @@ -0,0 +1,176 @@ +/** @file Unit tests for useOptimisticBooleanSetting hook. */ +/// + +import { useProjectSetting } from '@papi/frontend/react'; +import { act, renderHook } from '@testing-library/react'; +import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; + +const mockUseProjectSetting = jest.mocked(useProjectSetting); + +const SETTING_KEY = 'interlinearizer.continuousScroll' as const; +const TIMEOUT_MS = 15_000; + +describe('useOptimisticBooleanSetting', () => { + let mockSetSetting: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + mockSetSetting = jest.fn(); + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the persisted setting value as the initial display value', () => { + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + expect(result.current.value).toBe(true); + }); + + it('falls back to defaultValue when the persisted setting is not yet a boolean', () => { + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + expect(result.current.value).toBe(true); + }); + + it('updates the display value immediately on change (optimistic update)', () => { + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + expect(result.current.value).toBe(true); + }); + + it('calls setSetting with the new value on change', () => { + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + expect(mockSetSetting).toHaveBeenCalledWith(true); + }); + + it('ignores incoming setting updates while the timeout is active', () => { + // Setting not yet loaded so `setting` starts as non-boolean. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + + act(() => { + result.current.onChange(false); + }); + expect(result.current.value).toBe(false); + + // Simulate the store returning a conflicting value during the lock period. + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + + // Value should remain at the optimistically set value. + expect(result.current.value).toBe(false); + }); + + it('accepts incoming setting updates after the timeout elapses (covers lines 57-58)', () => { + // Setting not yet loaded so `setting` starts as non-boolean. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + + // Make an optimistic change and let the store return a conflicting value. + act(() => { + result.current.onChange(false); + }); + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(false); // Locked to optimistic value. + + // Fire the timeout — executes lines 57-58 (timeoutRef.current = undefined; ignoreRef.current = false). + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS); + }); + + // The store value (true) should now be accepted when setting changes. + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(false); + }); + + it('clears the first timeout when onChange is called a second time', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + + act(() => { + result.current.onChange(true); + }); + act(() => { + result.current.onChange(false); // second call should clear the first timer + }); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('does not accept setting updates until the reset timeout elapses after back-to-back onChange calls', () => { + // Start with a non-boolean setting so the initial useEffect bails early. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + + act(() => { + result.current.onChange(true); // timer A starts at t=0 + }); + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS - 1_000); // t=14000 + }); + act(() => { + result.current.onChange(true); // timer A cleared, timer B starts at t=14000 + }); + + // At t=15000, timer A would have fired but was cleared — lock still active. + act(() => { + jest.advanceTimersByTime(1_000); + }); + // Arriving setting is ignored while locked. + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(true); // locked; update blocked + + // Timer B fires at t=29000 — lock released. + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS - 1_000); + }); + // New setting value (different from the false that arrived during the lock) triggers the effect. + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(true); // lock released; setting accepted + }); + + it('clears the pending timeout on unmount', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const { result, unmount } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('passes empty string to useProjectSetting when projectId is undefined', () => { + renderHook(() => useOptimisticBooleanSetting(undefined, SETTING_KEY, false)); + expect(mockUseProjectSetting).toHaveBeenCalledWith('', SETTING_KEY, false); + }); +}); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index a49ab37..ce9c312 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -462,7 +462,7 @@ describe('InterlinearizerWebView', () => { expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); }); - it('switches rendering immediately using optimistic local state while setting is still pending', async () => { + it('switches rendering immediately using optimistic local state while setting saves', async () => { mockBookData({}); const mockSetContinuousScroll = jest.fn(); // Setting source remains true during the test (simulates delayed persistence confirmation). @@ -480,11 +480,10 @@ describe('InterlinearizerWebView', () => { await userEvent.click(screen.getByRole('checkbox')); - // Before setting confirms, UI should already switch to token-chip mode. + // Before setting saves, UI should already switch to token-chip mode. expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); expect(screen.getByText('In')).toBeInTheDocument(); expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); - expect(screen.getByRole('checkbox')).toBeDisabled(); }); it('renders ContinuousView when continuousScroll is true and book is loaded', () => { diff --git a/src/components/ContinuousScrollToggle.tsx b/src/components/ContinuousScrollToggle.tsx index d51a6c1..607801e 100644 --- a/src/components/ContinuousScrollToggle.tsx +++ b/src/components/ContinuousScrollToggle.tsx @@ -9,17 +9,14 @@ const STRING_KEYS = ['%interlinearizer_continuousScrollToggle%'] as const; * * @param props - Component props * @param props.checked - Current UI value for continuous scroll - * @param props.disabled - Whether the control is temporarily disabled * @param props.onCheckedChange - Callback invoked when user toggles the switch * @returns A labeled checkbox for continuous-scroll mode */ export default function ContinuousScrollToggle({ checked, - disabled, onCheckedChange, }: Readonly<{ checked: boolean; - disabled?: boolean; onCheckedChange: (checked: boolean) => void; }>) { const [localizedStrings] = useLocalizedStrings(useMemo(() => [...STRING_KEYS], [])); @@ -27,12 +24,7 @@ export default function ContinuousScrollToggle({ return (
- + diff --git a/src/hooks/useOptimisticBooleanSetting.ts b/src/hooks/useOptimisticBooleanSetting.ts new file mode 100644 index 0000000..8a2e701 --- /dev/null +++ b/src/hooks/useOptimisticBooleanSetting.ts @@ -0,0 +1,65 @@ +import { useProjectSetting } from '@papi/frontend/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** A timeout duration longer than the 5-10 seconds it usually takes for a setting to save. */ +const TIMEOUT_MS = 15_000; + +/** + * Manages a boolean project setting with optimistic UI updates. + * + * The local value is updated immediately on change and stays locked until timeout elapses, to allow + * the stored setting to finish updating. + * + * @param projectId - PAPI project ID; pass `undefined` when outside a project context + * @param settingKey - A valid key for a boolean setting + * @param defaultValue - Default value used when the setting has not been persisted yet + * @returns `value` — the current display value; `onChange` — stable change handler + */ +export default function useOptimisticBooleanSetting( + projectId: string | undefined, + settingKey: 'interlinearizer.continuousScroll', + defaultValue: boolean, +): { + value: boolean; + onChange: (newValue: boolean) => void; +} { + const [setting, setSetting] = useProjectSetting(projectId ?? '', settingKey, defaultValue); + + const [value, setValue] = useState(typeof setting === 'boolean' ? setting : defaultValue); + + const timeoutRef = useRef | undefined>(); + const ignoreRef = useRef(false); + + // Drive UI from optimistic local state and clear pending once the setting confirms. + useEffect(() => { + // Ignore platform errors or settings that arrive during the timeout period. + if (ignoreRef.current || typeof setting !== 'boolean') return; + + setValue(setting); + }, [setting]); + + // Clean up on unmount. + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const onChange = useCallback( + (newValue: boolean) => { + setValue(newValue); + ignoreRef.current = true; + setSetting?.(newValue); + // Reset the timeout on every call so back-to-back onChange calls don't let an earlier + // timeout clear the pending value set by a later call. + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = undefined; + ignoreRef.current = false; + }, TIMEOUT_MS); + }, + [setSetting], + ); + + return { value, onChange }; +} diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index c0afe55..65e8863 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -1,10 +1,6 @@ import type { WebViewProps } from '@papi/core'; -import { - useLocalizedStrings, - useProjectSetting, - useRecentScriptureRefs, -} from '@papi/frontend/react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; +import { useCallback, useMemo } from 'react'; import { BOOK_CHAPTER_CONTROL_STRING_KEYS, BookChapterControl, @@ -12,6 +8,7 @@ import { TabToolbar, } from 'platform-bible-react'; import ContinuousScrollToggle from './components/ContinuousScrollToggle'; +import useOptimisticBooleanSetting from './hooks/useOptimisticBooleanSetting'; import ContinuousView from './components/ContinuousView'; import MemoizedSegmentView from './components/SegmentView'; import useInterlinearizerBookData from './hooks/useInterlinearizerBookData'; @@ -35,16 +32,8 @@ globalThis.webViewComponent = function InterlinearizerWebView({ }: WebViewProps) { const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); - const [continuousScrollSetting, setContinuousScrollSetting] = useProjectSetting( - projectId ?? '', - 'interlinearizer.continuousScroll', - true, - ); - const settingValue = continuousScrollSetting === true; - - const [continuousScroll, setContinuousScroll] = useState(settingValue); - const [pendingContinuousScroll, setPendingContinuousScroll] = useState(); - const skipSettingRevertRef = useRef(false); + const { value: continuousScroll, onChange: handleContinuousScrollChange } = + useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( { @@ -53,19 +42,6 @@ globalThis.webViewComponent = function InterlinearizerWebView({ }, ); - // Drive UI from optimistic local state and clear pending once the setting confirms. - useEffect(() => { - if (pendingContinuousScroll === undefined) { - if (!skipSettingRevertRef.current) { - setContinuousScroll(settingValue); - } - skipSettingRevertRef.current = false; - } else if (settingValue === pendingContinuousScroll) { - setPendingContinuousScroll(undefined); - setContinuousScroll(settingValue); - } - }, [pendingContinuousScroll, settingValue]); - const [localizedStrings] = useLocalizedStrings( useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), ); @@ -73,28 +49,6 @@ globalThis.webViewComponent = function InterlinearizerWebView({ const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = useRecentScriptureRefs(); - const handleContinuousScrollChange = useCallback( - (checked: boolean) => { - if (pendingContinuousScroll !== undefined) return; - - setContinuousScroll(checked); - setPendingContinuousScroll(checked); - setContinuousScrollSetting?.(checked); - }, - [pendingContinuousScroll, setContinuousScrollSetting], - ); - - // Clear the pending flag after 15 s if the setting never confirms (e.g. network failure). - useEffect(() => { - if (pendingContinuousScroll === undefined) return undefined; - - const timeout = setTimeout(() => { - skipSettingRevertRef.current = true; - setPendingContinuousScroll(undefined); - }, 15_000); - return () => clearTimeout(timeout); - }, [pendingContinuousScroll]); - const handleContinuousVerseChange = useCallback( (v: { book: string; chapter: number; verse: number }) => { setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); @@ -134,7 +88,6 @@ globalThis.webViewComponent = function InterlinearizerWebView({ projectId && ( ) From ce23c1db2917c62117fa8e7a39a91244967ca274 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 14:12:52 -0400 Subject: [PATCH 18/27] Complete Web View coverage --- src/interlinearizer.web-view.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 65e8863..d011f85 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -92,7 +92,9 @@ globalThis.webViewComponent = function InterlinearizerWebView({ /> ) } + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ onSelectProjectMenuItem={() => {}} + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ onSelectViewInfoMenuItem={() => {}} /> {projectId && !bookError && !tokenizeError && !isLoading && book && continuousScroll && ( From f6b7002e5ebedfca4adf565a93219e415e17d2c7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 14:32:57 -0400 Subject: [PATCH 19/27] Complete ContinuousView test coverage --- .../components/ContinuousView.test.tsx | 121 +++++++++++++++++- src/components/ContinuousView.tsx | 7 + 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 5723ee3..1593e23 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -141,8 +141,80 @@ function makeSingleTokenBook(): Book { }; } -// --------------------------------------------------------------------------- -// scrollIntoView mock +/** + * A book whose GEN 1:1 segment has word tokens and whose GEN 1:2 segment has only a punctuation + * token (no word tokens). Used to exercise code paths that run when a segment exists in the book + * but contributes nothing to phraseEntries / segmentStartIndex. + */ +function makeMixedBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the', + tokens: [ + { + id: 'mix-tok-0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: '.', + tokens: [ + { + id: 'mix-punct-0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], + }; +} + +/** A book where every token is non-word, so phraseEntries is empty. */ +function makeWordFreeBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: '...', + tokens: [ + { + id: 'wf-punct-0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], + }; +} + // --------------------------------------------------------------------------- const scrollIntoViewMock = jest.fn(); @@ -671,3 +743,48 @@ describe('ContinuousView onVerseChange propagation', () => { expect(handleVerseChange).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Non-word token rendering, word-free books, and word-free segment jumps +// --------------------------------------------------------------------------- + +describe('ContinuousView non-word tokens and word-free paths', () => { + it('renders a non-word token via TokenChip within the strip', () => { + // makeMixedBook: GEN 1:1 has a word token; GEN 1:2 has a punctuation token + render(); + + // Both the word chip ("In") and the punctuation chip (".") must appear + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('renders without crashing when book has no word tokens (empty phraseEntries)', () => { + render(); + + // No phrase boxes rendered; both arrow buttons disabled (atStart && atEnd when length === 0) + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + // The punctuation token itself is rendered + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('does not jump when activeVerse targets a segment that has no word tokens', () => { + // Start focused at GEN 1:1 (word token), then move activeVerse to GEN 1:2 (punctuation only). + // getPhraseIndexForVerse should return undefined → no pending jump. + jest.useFakeTimers(); + const { rerender } = render( + , + ); + + rerender( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + jest.useRealTimers(); + + // No jump occurred; focus stays at GEN 1:1 (index 0), so left arrow remains disabled. + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 38b1b59..cca1ce0 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -100,6 +100,7 @@ export default function ContinuousView({ const getPhraseIndexForVerse = useCallback( (verse?: VerseCoordinate): number | undefined => { + /* v8 ignore next -- verse is always defined at the one call site */ if (!verse) return; const seg = book.segments.find( @@ -129,11 +130,14 @@ export default function ContinuousView({ s.startRef.chapter === activeVerse.chapter && s.startRef.verse === activeVerse.verse, ); + /* v8 ignore next -- V8 does not track branches inside useState lazy initializer */ if (!seg) return 0; const tokenIdx = segmentStartIndex.get(seg.id); + /* v8 ignore next -- V8 does not track branches inside useState lazy initializer */ if (tokenIdx === undefined) return 0; + /* v8 ignore next -- phraseIndexByTokenIndex always has an entry for a valid tokenIdx */ return phraseIndexByTokenIndex.get(tokenIdx) ?? 0; }); @@ -220,6 +224,7 @@ export default function ContinuousView({ jumpTargetRef.current = undefined; const focusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; + /* v8 ignore next -- focusPhraseIndex is always within phraseEntries bounds when state changes */ if (!focusedPhrase) return; const seg = tokenSegmentRef.current[focusedPhrase.tokenIndex]; @@ -244,11 +249,13 @@ export default function ContinuousView({ const stripOpacityClass = isVisible ? 'tw-opacity-100' : 'tw-opacity-0'; const goLeft = useCallback(() => { + /* v8 ignore next -- false branch (i === 0) is guarded by the disabled button */ setFocusPhraseIndex((i) => (i > 0 ? i - 1 : i)); }, []); const goRight = useCallback(() => { const max = phraseEntriesRef.current.length - 1; + /* v8 ignore next -- false branch (i >= max) is guarded by the disabled button */ setFocusPhraseIndex((i) => (i < max ? i + 1 : i)); }, []); From 4afb14acba0f8842fe9fa97fd4840e7aa7ea138f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 14:45:35 -0400 Subject: [PATCH 20/27] Replace left/right with prev/next for RTL support --- .../components/ContinuousView.test.tsx | 78 +++++++++---------- src/components/ContinuousView.tsx | 54 ++++++++----- 2 files changed, 72 insertions(+), 60 deletions(-) diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 1593e23..41dae1f 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -286,13 +286,13 @@ describe('ContinuousView rendering', () => { // --------------------------------------------------------------------------- describe('ContinuousView arrow disabled states', () => { - it('disables the left arrow on initial render (book start)', () => { + it('disables the prev arrow on initial render (book start)', () => { render(); expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); }); - it('enables the right arrow on initial render when there are multiple tokens', () => { + it('enables the next arrow on initial render when there are multiple tokens', () => { render(); expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); @@ -305,7 +305,7 @@ describe('ContinuousView arrow disabled states', () => { expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); }); - it('enables the left arrow after clicking right once', async () => { + it('enables the prev arrow after clicking next once', async () => { render(); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -313,7 +313,7 @@ describe('ContinuousView arrow disabled states', () => { expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); }); - it('disables the right arrow when advanced to the last token', async () => { + it('disables the next arrow when advanced to the last token', async () => { render(); const nextBtn = screen.getByRole('button', { name: 'Next token' }); @@ -325,7 +325,7 @@ describe('ContinuousView arrow disabled states', () => { expect(nextBtn).toBeDisabled(); }); - it('re-enables the right arrow after going left from the last token', async () => { + it('re-enables the next arrow after going prev from the last token', async () => { render(); const nextBtn = screen.getByRole('button', { name: 'Next token' }); @@ -346,40 +346,40 @@ describe('ContinuousView arrow disabled states', () => { // --------------------------------------------------------------------------- describe('ContinuousView fade overlays', () => { - it('does not render left fade at book start', () => { + it('does not render prev fade at book start', () => { const { container } = render(); - // Left fade gradient is tw-from-background (left-to-right gradient) + // Prev fade gradient is tw-from-background (prev-to-next gradient) const gradients = container.querySelectorAll('[aria-hidden="true"]'); - const leftFades = Array.from(gradients).filter((el) => - el.className.includes('tw-bg-gradient-to-r'), + const prevFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-e'), ); - expect(leftFades).toHaveLength(0); + expect(prevFades).toHaveLength(0); }); - it('renders right fade at book start (right side is enabled)', () => { + it('renders next fade at book start (next side is enabled)', () => { const { container } = render(); const gradients = container.querySelectorAll('[aria-hidden="true"]'); - const rightFades = Array.from(gradients).filter((el) => - el.className.includes('tw-bg-gradient-to-l'), + const nextFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-s'), ); - expect(rightFades).toHaveLength(1); + expect(nextFades).toHaveLength(1); }); - it('renders left fade after moving away from book start', async () => { + it('renders prev fade after moving away from book start', async () => { const { container } = render(); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); const gradients = container.querySelectorAll('[aria-hidden="true"]'); - const leftFades = Array.from(gradients).filter((el) => - el.className.includes('tw-bg-gradient-to-r'), + const prevFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-e'), ); - expect(leftFades).toHaveLength(1); + expect(prevFades).toHaveLength(1); }); - it('does not render right fade at book end', async () => { + it('does not render next fade at book end', async () => { const { container } = render(); const nextBtn = screen.getByRole('button', { name: 'Next token' }); @@ -388,10 +388,10 @@ describe('ContinuousView fade overlays', () => { await userEvent.click(nextBtn); const gradients = container.querySelectorAll('[aria-hidden="true"]'); - const rightFades = Array.from(gradients).filter((el) => - el.className.includes('tw-bg-gradient-to-l'), + const nextFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-s'), ); - expect(rightFades).toHaveLength(0); + expect(nextFades).toHaveLength(0); }); }); @@ -408,15 +408,15 @@ describe('ContinuousView cross-chapter traversal', () => { expect(screen.getByText('Beta')).toBeInTheDocument(); }); - it('can navigate across a chapter boundary with the right arrow', async () => { + it('can navigate across a chapter boundary with the next arrow', async () => { render(); - // Only one token per chapter, so clicking right once reaches chapter 2's token (index 1 = last) + // Only one token per chapter, so clicking next once reaches chapter 2's token (index 1 = last) await userEvent.click(screen.getByRole('button', { name: 'Next token' })); - // Right arrow should now be disabled (at end = last token = chapter 2 token) + // Next arrow should now be disabled (at end = last token = chapter 2 token) expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); - // Left arrow should be enabled + // Prev arrow should be enabled expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); }); }); @@ -426,7 +426,7 @@ describe('ContinuousView cross-chapter traversal', () => { // --------------------------------------------------------------------------- describe('ContinuousView smooth-scroll intent', () => { - it('calls scrollIntoView with smooth behaviour when right arrow is clicked', async () => { + it('calls scrollIntoView with smooth behaviour when next arrow is clicked', async () => { render(); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -436,7 +436,7 @@ describe('ContinuousView smooth-scroll intent', () => { ); }); - it('calls scrollIntoView with smooth behaviour when left arrow is clicked', async () => { + it('calls scrollIntoView with smooth behaviour when prev arrow is clicked', async () => { render(); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -453,7 +453,7 @@ describe('ContinuousView smooth-scroll intent', () => { render(); scrollIntoViewMock.mockClear(); - // Left arrow is disabled at start — clicking it should be a no-op + // Prev arrow is disabled at start — clicking it should be a no-op await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); expect(scrollIntoViewMock).not.toHaveBeenCalled(); @@ -478,7 +478,7 @@ describe('ContinuousView activeVerse verse-jump', () => { , ); - // At index 0 the left arrow should be disabled + // At index 0 the prev arrow should be disabled expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); }); @@ -496,7 +496,7 @@ describe('ContinuousView activeVerse verse-jump', () => { jest.advanceTimersByTime(500); }); - // focusIndex is now 2 (first token of segment 2), so left arrow should be enabled + // focusIndex is now 2 (first token of segment 2), so prev arrow should be enabled expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); }); @@ -518,7 +518,7 @@ describe('ContinuousView activeVerse verse-jump', () => { jest.advanceTimersByTime(500); }); - // Chapter 2 starts at index 1 (the last token), so right arrow should be disabled + // Chapter 2 starts at index 1 (the last token), so next arrow should be disabled expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); }); @@ -548,7 +548,7 @@ describe('ContinuousView activeVerse verse-jump', () => { , ); - // Index 2 is not the start (left enabled) and not the end (right enabled). + // Index 2 is not the start (prev enabled) and not the end (next enabled). expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); }); @@ -575,7 +575,7 @@ describe('ContinuousView activeVerse verse-jump', () => { // --------------------------------------------------------------------------- describe('ContinuousView onVerseChange propagation', () => { - it('calls onVerseChange when the right arrow crosses into a new verse', async () => { + it('calls onVerseChange when the next arrow crosses into a new verse', async () => { // makeBook(): segment GEN 1:1 has tokens at index 0,1; GEN 1:2 starts at index 2 const handleVerseChange = jest.fn(); render(); @@ -587,7 +587,7 @@ describe('ContinuousView onVerseChange propagation', () => { expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); }); - it('calls onVerseChange when the left arrow crosses back into a prior verse', async () => { + it('calls onVerseChange when the prev arrow crosses back into a prior verse', async () => { const handleVerseChange = jest.fn(); render( { ); handleVerseChange.mockClear(); - // focusIndex is at 2 (first token of GEN 1:2); go left to cross back to GEN 1:1 + // focusIndex is at 2 (first token of GEN 1:2); go prev to cross back to GEN 1:1 await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }); @@ -667,7 +667,7 @@ describe('ContinuousView onVerseChange propagation', () => { />, ); - // Focus stays at index 2: left arrow enabled, right arrow enabled (not at start or end). + // Focus stays at index 2: prev arrow enabled, next arrow enabled (not at start or end). expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); // onVerseChange must not have been called a second time (no loop). @@ -700,7 +700,7 @@ describe('ContinuousView onVerseChange propagation', () => { />, ); - // If focus was incorrectly reset to the first phrase of the verse ("beginning"), the right + // If focus was incorrectly reset to the first phrase of the verse ("beginning"), the next // arrow would be enabled. Staying on "God" keeps us at strip end, so it remains disabled. expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); expect(godPhraseBox).toHaveAttribute('data-focus-state', 'focused'); @@ -784,7 +784,7 @@ describe('ContinuousView non-word tokens and word-free paths', () => { }); jest.useRealTimers(); - // No jump occurred; focus stays at GEN 1:1 (index 0), so left arrow remains disabled. + // No jump occurred; focus stays at GEN 1:1 (index 0), so prev arrow remains disabled. expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); }); }); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index cca1ce0..478542d 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -19,8 +19,8 @@ export interface VerseCoordinate { * * Edge behaviour: * - * - Left arrow is disabled (and left fade suppressed) when the first token is focused. - * - Right arrow is disabled (and right fade suppressed) when the last token is focused. + * - Previous arrow is disabled (and previous fade suppressed) when the first token is focused. + * - Next arrow is disabled (and next fade suppressed) when the last token is focused. * * When `activeVerse` changes the strip jumps to the first token of the matching segment. When arrow * navigation crosses a verse boundary `onVerseChange` is called with the new verse coordinate. @@ -30,7 +30,7 @@ export interface VerseCoordinate { * first token of the matching segment * @param props.book - The full tokenized book whose tokens should be streamed * @param props.onVerseChange - Called when arrow navigation moves the focus into a new verse - * @returns A horizontal token strip with left/right navigation arrows and edge-fade overlays + * @returns A horizontal token strip with previous/next navigation arrows and edge-fade overlays */ export default function ContinuousView({ activeVerse, @@ -248,16 +248,18 @@ export default function ContinuousView({ const atEnd = !phraseEntries.length || focusPhraseIndex >= phraseEntries.length - 1; const stripOpacityClass = isVisible ? 'tw-opacity-100' : 'tw-opacity-0'; - const goLeft = useCallback(() => { - /* v8 ignore next -- false branch (i === 0) is guarded by the disabled button */ - setFocusPhraseIndex((i) => (i > 0 ? i - 1 : i)); + const step = useCallback((delta: number) => { + setFocusPhraseIndex((i) => { + const nextIndex = i + delta; + if (nextIndex < 0) return 0; + if (nextIndex >= phraseEntriesRef.current.length) return phraseEntriesRef.current.length - 1; + return nextIndex; + }); }, []); - const goRight = useCallback(() => { - const max = phraseEntriesRef.current.length - 1; - /* v8 ignore next -- false branch (i >= max) is guarded by the disabled button */ - setFocusPhraseIndex((i) => (i < max ? i + 1 : i)); - }, []); + const stepPrev = useCallback(() => step(-1), [step]); + + const stepNext = useCallback(() => step(1), [step]); const handlePhraseSelect = useCallback( (index?: number) => { @@ -299,32 +301,37 @@ export default function ContinuousView({ return (
- {/* Left navigation arrow */} + {/* Previous navigation arrow */} {/* Scrollable token strip */}
- {/* Left fade overlay — only rendered when the left arrow is enabled */} + {/* Previous fade overlay — only rendered when the previous arrow is enabled */} {!atStart && ( - {/* Right navigation arrow */} + {/* Next navigation arrow */}
); From 8d43ce31eb582f177ddfae1f1d85b819dcde9896 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 15:36:42 -0400 Subject: [PATCH 21/27] Fix arrows and RTL support --- src/components/ContinuousView.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 478542d..819f909 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -43,6 +43,7 @@ export default function ContinuousView({ }>) { const STRIP_FADE_MS = 500; const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; + const isRtl = document.documentElement.dir === 'rtl'; const allTokens: Token[] = useMemo( () => book.segments.flatMap((seg) => seg.tokens), @@ -309,12 +310,7 @@ export default function ContinuousView({ onClick={stepPrev} type="button" > - - + {/* Scrollable token strip */} @@ -375,12 +371,7 @@ export default function ContinuousView({ onClick={stepNext} type="button" > - - +
); From 3f387b55c61d994bc13501e2553d8c71c3711e78 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 15:36:57 -0400 Subject: [PATCH 22/27] Show loading while waiting for setting --- src/hooks/useOptimisticBooleanSetting.ts | 14 ++++++++++---- src/interlinearizer.web-view.tsx | 21 ++++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/hooks/useOptimisticBooleanSetting.ts b/src/hooks/useOptimisticBooleanSetting.ts index 8a2e701..0e8dddd 100644 --- a/src/hooks/useOptimisticBooleanSetting.ts +++ b/src/hooks/useOptimisticBooleanSetting.ts @@ -13,17 +13,23 @@ const TIMEOUT_MS = 15_000; * @param projectId - PAPI project ID; pass `undefined` when outside a project context * @param settingKey - A valid key for a boolean setting * @param defaultValue - Default value used when the setting has not been persisted yet - * @returns `value` — the current display value; `onChange` — stable change handler + * @returns `isLoading` — whether the setting value is still loading from the platform; `onChange` — + * stable change handler; `value` — the current display value */ export default function useOptimisticBooleanSetting( projectId: string | undefined, settingKey: 'interlinearizer.continuousScroll', defaultValue: boolean, ): { - value: boolean; + isLoading: boolean; onChange: (newValue: boolean) => void; + value: boolean; } { - const [setting, setSetting] = useProjectSetting(projectId ?? '', settingKey, defaultValue); + const [setting, setSetting, , isLoading] = useProjectSetting( + projectId ?? '', + settingKey, + defaultValue, + ); const [value, setValue] = useState(typeof setting === 'boolean' ? setting : defaultValue); @@ -61,5 +67,5 @@ export default function useOptimisticBooleanSetting( [setSetting], ); - return { value, onChange }; + return { isLoading, onChange, value }; } diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index d011f85..d487699 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -32,8 +32,11 @@ globalThis.webViewComponent = function InterlinearizerWebView({ }: WebViewProps) { const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); - const { value: continuousScroll, onChange: handleContinuousScrollChange } = - useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); + const { + isLoading: isSettingLoading, + onChange: handleContinuousScrollChange, + value: continuousScroll, + } = useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( { @@ -63,6 +66,9 @@ globalThis.webViewComponent = function InterlinearizerWebView({ [setScrRef], ); + const hasError = !!bookError || !!tokenizeError; + const showLoading = isLoading || isSettingLoading; + return (
@@ -85,7 +91,8 @@ globalThis.webViewComponent = function InterlinearizerWebView({
} endAreaChildren={ - projectId && ( + projectId && + !isSettingLoading && ( {}} /> - {projectId && !bookError && !tokenizeError && !isLoading && book && continuousScroll && ( + {projectId && !hasError && !showLoading && book && continuousScroll && (
)} - {!bookError && !tokenizeError && isLoading && ( + {!hasError && showLoading && (

Loading…

)} - {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( + {!hasError && !showLoading && chapterSegments.length === 0 && (

No verse data for {scrRef.book} {scrRef.chapterNum}.

)} - {!bookError && !tokenizeError && !isLoading && chapterSegments.length > 0 && ( + {!hasError && !showLoading && chapterSegments.length > 0 && (
{chapterSegments.map((seg) => ( Date: Thu, 7 May 2026 15:40:53 -0400 Subject: [PATCH 23/27] Use existing type --- src/components/ContinuousView.tsx | 15 ++++----------- src/hooks/useInterlinearizerBookData.ts | 6 ++---- src/interlinearizer.web-view.tsx | 2 +- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 819f909..a446258 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -1,16 +1,9 @@ /** @file Continuous horizontal token-strip viewer for a full book. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { Book, Token } from 'interlinearizer'; +import type { Book, ScriptureRef, Token } from 'interlinearizer'; import MemoizedPhraseBox from './PhraseBox'; import MemoizedTokenChip from './TokenChip'; -/** A verse coordinate used to drive the strip's scroll position. */ -export interface VerseCoordinate { - book: string; - chapter: number; - verse: number; -} - /** * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable * strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling @@ -37,9 +30,9 @@ export default function ContinuousView({ book, onVerseChange, }: Readonly<{ - activeVerse?: VerseCoordinate; + activeVerse?: ScriptureRef; book: Book; - onVerseChange?: (verse: VerseCoordinate) => void; + onVerseChange?: (verse: ScriptureRef) => void; }>) { const STRIP_FADE_MS = 500; const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; @@ -100,7 +93,7 @@ export default function ContinuousView({ tokenSegmentRef.current = tokenSegment; const getPhraseIndexForVerse = useCallback( - (verse?: VerseCoordinate): number | undefined => { + (verse?: ScriptureRef): number | undefined => { /* v8 ignore next -- verse is always defined at the one call site */ if (!verse) return; diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index 302db10..8bc9ecd 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -1,17 +1,15 @@ -import type { WebViewProps } from '@papi/core'; import { logger } from '@papi/frontend'; import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book } from 'interlinearizer'; import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; import { tokenizeBook } from 'parsers/papi/bookTokenizer'; import { isPlatformError } from 'platform-bible-utils'; import { useEffect, useMemo } from 'react'; -type ScrRef = ReturnType[0]; - interface UseInterlinearizerBookDataArgs { projectId?: string; - scrRef: ScrRef; + scrRef: SerializedVerseRef; } interface UseInterlinearizerBookDataResult { diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index d487699..41f85f0 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -107,12 +107,12 @@ globalThis.webViewComponent = function InterlinearizerWebView({ {projectId && !hasError && !showLoading && book && continuousScroll && (
From 610129446f3e7dcbcb04b5a439ca34bf8767cb07 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 16:59:59 -0400 Subject: [PATCH 24/27] Split up main Web View into pieces --- .../components/Interlinearizer.test.tsx | 251 +++++++++ .../components/InterlinearizerLoader.test.tsx | 369 ++++++++++++ .../components/ScriptureNavControls.test.tsx | 56 ++ .../hooks/useInterlinearizerBookData.test.ts | 26 - .../hooks/useOptimisticBooleanSetting.test.ts | 5 - .../interlinearizer.web-view.test.tsx | 532 +----------------- src/components/Interlinearizer.tsx | 85 +++ src/components/InterlinearizerLoader.tsx | 110 ++++ src/components/ScriptureNavControls.tsx | 44 ++ src/components/SegmentView.tsx | 4 +- src/hooks/useInterlinearizerBookData.ts | 30 +- src/hooks/useOptimisticBooleanSetting.ts | 12 +- src/interlinearizer.web-view.tsx | 169 +----- 13 files changed, 954 insertions(+), 739 deletions(-) create mode 100644 src/__tests__/components/Interlinearizer.test.tsx create mode 100644 src/__tests__/components/InterlinearizerLoader.test.tsx create mode 100644 src/__tests__/components/ScriptureNavControls.test.tsx create mode 100644 src/components/Interlinearizer.tsx create mode 100644 src/components/InterlinearizerLoader.tsx create mode 100644 src/components/ScriptureNavControls.tsx diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx new file mode 100644 index 0000000..cd30bc5 --- /dev/null +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -0,0 +1,251 @@ +/** @file Unit tests for components/Interlinearizer.tsx. */ +/// +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Book } from 'interlinearizer'; +import Interlinearizer from '../../components/Interlinearizer'; + +// Store captured props so tests can simulate callbacks +let capturedContinuousViewProps: Record = {}; + +jest.mock('../../components/ContinuousView', () => ({ + __esModule: true, + default: (props: Record) => { + capturedContinuousViewProps = props; + return
; + }, +})); + +const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +/** Pre-built Book with one GEN 1:1 segment. */ +const GEN_1_1_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + ], +}; + +/** Pre-built Book with no segments — used by the no-verse-data test. */ +const GEN_EMPTY_BOOK: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments: [] }; + +/** Book with two segments in GEN 1 — used by chapter-display tests. */ +const GEN_1_MULTI_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + id: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], +}; + +/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */ +const GEN_1_1_PUNCTUATION_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: '.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], +}; + +function renderInterlinearizer({ + book = GEN_1_1_BOOK, + bookSegments = GEN_1_1_BOOK.segments, + continuousScroll = false, + scrRef = defaultScrRef, + setScrRef = () => {}, +}: { + book?: Book; + bookSegments?: Book['segments']; + continuousScroll?: boolean; + scrRef?: SerializedVerseRef; + setScrRef?: (r: SerializedVerseRef) => void; +} = {}) { + return render( + } + />, + ); +} + +describe('Interlinearizer', () => { + beforeEach(() => { + capturedContinuousViewProps = {}; + }); + + it('renders token chips when the tokenized book has a segment for the current reference', () => { + renderInterlinearizer(); + + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('shows a no-verse message when the tokenized book has no segments at all', () => { + renderInterlinearizer({ bookSegments: GEN_EMPTY_BOOK.segments }); + + expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); + }); + + it('renders all segments in the current chapter', () => { + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('And')).toBeInTheDocument(); + }); + + it('highlights only the segment matching the current verse', () => { + const { container } = renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + + // defaultScrRef is GEN 1:1, so verse 1 is active + const activeSegments = container.querySelectorAll('button[aria-current="true"]'); + expect(activeSegments).toHaveLength(1); + }); + + it('shows all chapter segments when navigating to a title reference (verse 0)', () => { + const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 }; + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef }); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('And')).toBeInTheDocument(); + }); + + it('renders non-word tokens as muted chips', () => { + renderInterlinearizer({ bookSegments: GEN_1_1_PUNCTUATION_BOOK.segments }); + + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('calls setScrRef with the segment ref when a verse box is clicked', async () => { + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef }); + + await userEvent.click(screen.getByText('And')); + + expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + }); + + it('renders segments in baseline-text mode when continuousScroll is true', () => { + renderInterlinearizer({ continuousScroll: true }); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + }); + + it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => { + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true }); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.getByText('And the earth.')).toBeInTheDocument(); + }); + + it('renders ContinuousView when continuousScroll is true', () => { + renderInterlinearizer({ continuousScroll: true }); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + }); + + it('does not render ContinuousView when continuousScroll is false', () => { + renderInterlinearizer({ continuousScroll: false }); + + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + }); + + it('renders ContinuousView above the chapter segment rows when both are present', () => { + const { container } = renderInterlinearizer({ + bookSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + }); + + const continuousView = screen.getByTestId('continuous-view'); + const allElements = Array.from( + container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'), + ); + expect(allElements[0]).toBe(continuousView); + }); + + it('calls setScrRef when ContinuousView emits onVerseChange', () => { + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ continuousScroll: true, setScrRef: mockSetScrRef }); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion + const onVerseChange = capturedContinuousViewProps.onVerseChange as any; + expect(onVerseChange).toBeDefined(); + + onVerseChange({ book: 'GEN', chapter: 2, verse: 3 }); + + expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 2, verseNum: 3 }); + }); +}); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx new file mode 100644 index 0000000..669fadf --- /dev/null +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -0,0 +1,369 @@ +/** @file Unit tests for components/InterlinearizerLoader.tsx. */ +/// +/// + +import { + useLocalizedStrings, + useProjectData, + useProjectSetting, + useRecentScriptureRefs, +} from '@papi/frontend/react'; +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; +import InterlinearizerLoader from '../../components/InterlinearizerLoader'; + +jest.mock('parsers/papi/bookTokenizer'); +jest.mock('parsers/papi/usjBookExtractor'); + +jest.mock('../../components/ContinuousView', () => ({ + __esModule: true, + default: () =>
, +})); + +/** + * Matches the PlatformError shape from platform-bible-utils (discriminated by + * platformErrorVersion). + */ +type PlatformError = { platformErrorVersion: number; message: string }; + +const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; +const testProjectId = 'test-project-id'; + +/** Pre-built Book with one GEN 1:1 segment. */ +const GEN_1_1_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + ], +}; + +/** + * Returns a `useWebViewScrollGroupScrRef` hook stub bound to the given reference and setter. + * + * @param scrRef - Scripture reference to expose; defaults to GEN 1:1 + * @param setScrRef - Setter callback; defaults to a no-op + */ +function makeScrollGroupHook( + scrRef: SerializedVerseRef = defaultScrRef, + setScrRef: (r: SerializedVerseRef) => void = () => {}, +) { + return (): [ + SerializedVerseRef, + (r: SerializedVerseRef) => void, + number | undefined, + (id: number | undefined) => void, + ] => [scrRef, setScrRef, undefined, () => {}]; +} + +/** Configures useProjectData to return the given BookUSJ value and loading state this render. */ +function mockBookData(value: unknown, isLoading = false): void { + jest.mocked(useProjectData).mockImplementation(() => ({ + BookUSJ: () => [value, jest.fn(), isLoading], + })); +} + +/** + * Configures useProjectSetting for the languageTag and continuousScroll keys. All other keys + * receive their defaultState. + * + * @param tag - Writing system tag returned for `platform.languageTag` + * @param continuousScroll - Value returned for `interlinearizer.continuousScroll`; defaults to + * `false` so existing token-chip rendering tests are unaffected + */ +function mockWritingSystem(tag: string | PlatformError = 'en', continuousScroll = false): void { + jest.mocked(useProjectSetting).mockImplementation((_projectId, key, defaultState) => { + if (key === 'platform.languageTag') return [tag, jest.fn(), jest.fn(), false]; + if (key === 'interlinearizer.continuousScroll') + return [continuousScroll, jest.fn(), jest.fn(), false]; + return [defaultState, jest.fn(), jest.fn(), false]; + }); +} + +describe('InterlinearizerLoader', () => { + beforeEach(() => { + mockBookData(undefined); + mockWritingSystem(); + jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: jest.fn(), + }); + jest.mocked(extractBookFromUsj).mockReturnValue({ + bookCode: 'GEN', + writingSystem: 'en', + contentHash: 'abc', + verses: [], + }); + jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_BOOK); + }); + + it('shows the book chapter control and renders a segment when book data is available', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('shows Loading when book data has not arrived', () => { + mockBookData(undefined, true); + render( + , + ); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + + it('shows an error when no USJ book is available for the project', () => { + mockBookData(undefined, false); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument(); + }); + + it('shows an error heading and message when book data is a PlatformError', () => { + mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/project not found/i)).toBeInTheDocument(); + }); + + it('falls back to "und" writing system when useProjectSetting returns a PlatformError', () => { + mockBookData({}); + mockWritingSystem({ platformErrorVersion: 1, message: 'Setting unavailable' }); + render( + , + ); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); + }); + + it('falls back to "und" writing system when useProjectSetting returns an empty string', () => { + mockBookData({}); + mockWritingSystem(''); + render( + , + ); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); + }); + + it('shows an error heading and message when tokenization throws an Error', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw new Error('parse failure'); + }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); + expect(screen.getByText('parse failure')).toBeInTheDocument(); + }); + + it('shows an error message when tokenization throws a non-Error value', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockImplementation(() => { + // eslint-disable-next-line no-throw-literal + throw 'unexpected string error'; + }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); + expect(screen.getByText('unexpected string error')).toBeInTheDocument(); + }); + + it('passes a book-stable ref to BookUSJ so chapter and verse changes do not re-fetch the book', () => { + const mockBookUSJ = jest.fn().mockReturnValue([{}, jest.fn(), false]); + jest.mocked(useProjectData).mockImplementation(() => ({ BookUSJ: mockBookUSJ })); + const { rerender } = render( + , + ); + rerender( + , + ); + + const refsPassed = mockBookUSJ.mock.calls.map((c) => c[0]); + refsPassed.forEach((ref) => expect(ref).toEqual({ book: 'GEN', chapterNum: 1, verseNum: 1 })); + expect(mockBookUSJ.mock.calls.length).toBeGreaterThanOrEqual(2); + refsPassed.slice(1).forEach((ref) => expect(ref).toBe(refsPassed[0])); + }); + + it('renders the continuous scroll toggle', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('continuous scroll toggle is checked when the setting is true', () => { + mockBookData({}); + mockWritingSystem('en', true); + render( + , + ); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('continuous scroll toggle is unchecked when the setting is false', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('clicking the continuous scroll toggle calls setContinuousScroll with the new value', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + await userEvent.click(screen.getByRole('checkbox')); + + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + }); + + it('switches rendering immediately using optimistic local state while setting saves', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + // Setting source remains true during the test (simulates delayed persistence confirmation). + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + // Initially in continuous mode. + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox')); + + // Before setting saves, UI should already switch to token-chip mode. + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + }); + + it('toggles continuous scroll setting back to true after being false', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [false, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + // Initially in token-chip mode + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + + // Click toggle to turn on continuous mode + await userEvent.click(screen.getByRole('checkbox')); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/__tests__/components/ScriptureNavControls.test.tsx b/src/__tests__/components/ScriptureNavControls.test.tsx new file mode 100644 index 0000000..a7c2bd7 --- /dev/null +++ b/src/__tests__/components/ScriptureNavControls.test.tsx @@ -0,0 +1,56 @@ +/** @file Unit tests for components/ScriptureNavControls.tsx. */ +/// +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; +import ScriptureNavControls from '../../components/ScriptureNavControls'; + +const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +describe('ScriptureNavControls', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: jest.fn(), + }); + }); + + it('shows the book chapter control', () => { + render( + {}} + scrollGroupId={undefined} + onChangeScrollGroupId={() => {}} + />, + ); + + expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); + }); + + it('calls handleSubmit and addRecentScriptureRef when the verse picker submits', async () => { + const mockHandleSubmit = jest.fn(); + const mockAddRecentRef = jest.fn(); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: mockAddRecentRef, + }); + render( + {}} + />, + ); + + await userEvent.click(screen.getByRole('button', { name: /submit reference/i })); + + expect(mockHandleSubmit).toHaveBeenCalledWith(defaultScrRef); + expect(mockAddRecentRef).toHaveBeenCalledWith(defaultScrRef); + }); +}); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index 67cf19a..51d00e0 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -111,17 +111,6 @@ describe('useInterlinearizerBookData', () => { setupDefaultProjectSettingMock(); }); - it('returns idle state when no projectId is provided', () => { - const { result } = renderHook(() => - useInterlinearizerBookData({ projectId: undefined, scrRef: { ...GEN_1_1_SRC_REF } }), - ); - - expect(result.current.book).toBeUndefined(); - expect(result.current.isLoading).toBe(false); - expect(result.current.bookError).toBeUndefined(); - expect(result.current.tokenizeError).toBeUndefined(); - }); - it('returns book data when project is set and data loads successfully', () => { jest.mocked(useProjectData).mockReturnValue({ BookUSJ: () => [undefined, jest.fn(), true] }); jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); @@ -277,21 +266,6 @@ describe('useInterlinearizerBookData', () => { ); }); - it('does not log error when hook has no projectId', () => { - jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); - - const error = new Error('Tokenization failed'); - jest.mocked(tokenizeBook).mockImplementation(() => { - throw error; - }); - - renderHook(() => - useInterlinearizerBookData({ projectId: undefined, scrRef: { ...GEN_1_1_SRC_REF } }), - ); - - expect(jest.mocked(logger.error)).not.toHaveBeenCalled(); - }); - it('logs tokenization error with PlatformError writing system', () => { const platformError: PlatformError = { message: 'Setting unavailable', diff --git a/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts index aab30a2..ca2fe70 100644 --- a/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts +++ b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts @@ -168,9 +168,4 @@ describe('useOptimisticBooleanSetting', () => { unmount(); expect(clearTimeoutSpy).toHaveBeenCalled(); }); - - it('passes empty string to useProjectSetting when projectId is undefined', () => { - renderHook(() => useOptimisticBooleanSetting(undefined, SETTING_KEY, false)); - expect(mockUseProjectSetting).toHaveBeenCalledWith('', SETTING_KEY, false); - }); }); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index ce9c312..daac793 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -5,37 +5,14 @@ import type { WebViewProps } from '@papi/core'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { - useLocalizedStrings, - useProjectData, - useProjectSetting, - useRecentScriptureRefs, -} from '@papi/frontend/react'; -import type { Book } from 'interlinearizer'; -import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; -import { tokenizeBook } from 'parsers/papi/bookTokenizer'; -jest.mock('parsers/papi/bookTokenizer'); -jest.mock('parsers/papi/usjBookExtractor'); - -// Store captured props so tests can simulate callbacks -let capturedContinuousViewProps: Record = {}; - -jest.mock('../components/ContinuousView', () => ({ +jest.mock('../components/InterlinearizerLoader', () => ({ __esModule: true, - default: (props: Record) => { - capturedContinuousViewProps = props; - return
; - }, + default: ({ projectId }: { projectId: string }) => ( +
Loader for {projectId}
+ ), })); -/** - * Matches the PlatformError shape from platform-bible-utils (discriminated by - * platformErrorVersion). - */ -type PlatformError = { platformErrorVersion: number; message: string }; - /** * Load the WebView module; it assigns the component to globalThis.webViewComponent. This pattern is * required by the Platform.Bible WebView framework: the WebView entry is built with a ?inline query @@ -51,108 +28,8 @@ if (!InterlinearizerWebView) throw new Error('webViewComponent not loaded'); /** Minimal SerializedVerseRef for hook mock return. */ const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; -const testProjectId = 'test-project-id'; - -/** Pre-built Book with one GEN 1:1 segment — used by tests that need the strip to render. */ -const GEN_1_1_BOOK: Book = { - id: 'GEN', - bookRef: 'GEN', - textVersion: 'v1', - segments: [ - { - id: 'GEN 1:1', - startRef: { book: 'GEN', chapter: 1, verse: 1 }, - endRef: { book: 'GEN', chapter: 1, verse: 1 }, - baselineText: 'In the beginning.', - tokens: [ - { - id: 'GEN 1:1:0', - surfaceText: 'In', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 2, - }, - ], - }, - ], -}; - -/** Pre-built Book with no segments — used by the no-verse-data test. */ -const GEN_EMPTY_BOOK: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments: [] }; - -/** Book with two segments in GEN 1 — used by chapter-display tests. */ -const GEN_1_MULTI_BOOK: Book = { - id: 'GEN', - bookRef: 'GEN', - textVersion: 'v1', - segments: [ - { - id: 'GEN 1:1', - startRef: { book: 'GEN', chapter: 1, verse: 1 }, - endRef: { book: 'GEN', chapter: 1, verse: 1 }, - baselineText: 'In the beginning.', - tokens: [ - { - id: 'GEN 1:1:0', - surfaceText: 'In', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 2, - }, - ], - }, - { - id: 'GEN 1:2', - startRef: { book: 'GEN', chapter: 1, verse: 2 }, - endRef: { book: 'GEN', chapter: 1, verse: 2 }, - baselineText: 'And the earth.', - tokens: [ - { - id: 'GEN 1:2:0', - surfaceText: 'And', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 3, - }, - ], - }, - ], -}; - -/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */ -const GEN_1_1_PUNCTUATION_BOOK: Book = { - id: 'GEN', - bookRef: 'GEN', - textVersion: 'v1', - segments: [ - { - id: 'GEN 1:1', - startRef: { book: 'GEN', chapter: 1, verse: 1 }, - endRef: { book: 'GEN', chapter: 1, verse: 1 }, - baselineText: '.', - tokens: [ - { - id: 'GEN 1:1:0', - surfaceText: '.', - writingSystem: 'en', - type: 'punctuation', - charStart: 0, - charEnd: 1, - }, - ], - }, - ], -}; - /** Builds a minimal WebViewProps for tests. */ -function makeProps( - projectId?: string, - scrRef: SerializedVerseRef = defaultScrRef, - setScrRef: (r: SerializedVerseRef) => void = () => {}, -): WebViewProps { +function makeProps(projectId?: string, scrRef: SerializedVerseRef = defaultScrRef): WebViewProps { return { id: 'test-id', webViewType: 'interlinearizer.mainWebView', @@ -167,412 +44,21 @@ function makeProps( (r: SerializedVerseRef) => void, number | undefined, (id: number | undefined) => void, - ] => [scrRef, setScrRef, undefined, () => {}], + ] => [scrRef, () => {}, undefined, () => {}], updateWebViewDefinition: () => true, }; } -/** Configures useProjectData to return the given BookUSJ value and loading state this render. */ -function mockBookData(value: unknown, isLoading = false): void { - jest.mocked(useProjectData).mockImplementation(() => ({ - BookUSJ: () => [value, jest.fn(), isLoading], - })); -} - -/** - * Configures useProjectSetting for the languageTag and continuousScroll keys. All other keys - * receive their defaultState. - * - * @param tag - Writing system tag returned for `platform.languageTag` - * @param continuousScroll - Value returned for `interlinearizer.continuousScroll`; defaults to - * `false` so existing token-chip rendering tests are unaffected - */ -function mockWritingSystem(tag: string | PlatformError = 'en', continuousScroll = false): void { - jest.mocked(useProjectSetting).mockImplementation((_projectId, key, defaultState) => { - if (key === 'platform.languageTag') return [tag, jest.fn(), jest.fn(), false]; - if (key === 'interlinearizer.continuousScroll') - return [continuousScroll, jest.fn(), jest.fn(), false]; - return [defaultState, jest.fn(), jest.fn(), false]; - }); -} - describe('InterlinearizerWebView', () => { - beforeEach(() => { - mockBookData(undefined); - mockWritingSystem(); - jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); - jest.mocked(useRecentScriptureRefs).mockReturnValue({ - recentScriptureRefs: [], - addRecentScriptureRef: jest.fn(), - }); - jest.mocked(extractBookFromUsj).mockReturnValue({ - bookCode: 'GEN', - writingSystem: 'en', - contentHash: 'abc', - verses: [], - }); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_BOOK); - }); - - it('shows the book chapter control regardless of whether a project is linked', () => { - render(); - - expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); - }); - it('shows a prompt to open from a project when no projectId is provided', () => { render(); expect(screen.getByText(/open this webview from a paratext project/i)).toBeInTheDocument(); }); - it('shows the book chapter control and renders a segment when a project is linked', () => { - mockBookData({}); - render(); - - expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); - expect(screen.getByText('In')).toBeInTheDocument(); - }); - - it('shows Loading when projectId is set but book data has not arrived', () => { - mockBookData(undefined, true); - render(); - - expect(screen.getByText('Loading…')).toBeInTheDocument(); - }); - - it('shows an error when no USJ book is available for the project', () => { - mockBookData(undefined, false); - render(); - - expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); - expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument(); - }); - - it('renders token chips when the tokenized book has a segment for the current reference', () => { - mockBookData({}); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - }); - - it('shows a no-verse message when the tokenized book has no segments at all', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_EMPTY_BOOK); - render(); - - expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); - }); - - it('renders all segments in the current chapter', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(screen.getByText('And')).toBeInTheDocument(); - }); - - it('highlights only the segment matching the current verse', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - // defaultScrRef is GEN 1:1, so verse 1 is active - const { container } = render(); - - const activeSegments = container.querySelectorAll('button[aria-current="true"]'); - expect(activeSegments).toHaveLength(1); - }); - - it('shows all chapter segments when navigating to a title reference (verse 0)', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 }; - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(screen.getByText('And')).toBeInTheDocument(); - }); - - it('shows an error heading and message when book data is a PlatformError', () => { - mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); - render(); - - expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); - expect(screen.getByText(/project not found/i)).toBeInTheDocument(); - }); - - it('falls back to "und" writing system when useProjectSetting returns a PlatformError', () => { - mockBookData({}); - mockWritingSystem({ platformErrorVersion: 1, message: 'Setting unavailable' }); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); - }); - - it('falls back to "und" writing system when useProjectSetting returns an empty string', () => { - mockBookData({}); - mockWritingSystem(''); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); - }); - - it('shows an error heading and message when tokenization throws an Error', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockImplementation(() => { - throw new Error('parse failure'); - }); - render(); - - expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); - expect(screen.getByText('parse failure')).toBeInTheDocument(); - }); - - it('shows an error message when tokenization throws a non-Error value', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockImplementation(() => { - // eslint-disable-next-line no-throw-literal - throw 'unexpected string error'; - }); - render(); - - expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); - expect(screen.getByText('unexpected string error')).toBeInTheDocument(); - }); - - it('renders non-word tokens as muted chips', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_PUNCTUATION_BOOK); - render(); - - expect(screen.getByText('.')).toBeInTheDocument(); - }); - - it('calls setScrRef and addRecentScriptureRef when the verse picker submits', async () => { - mockBookData({}); - const mockSetScrRef = jest.fn(); - const mockAddRecentRef = jest.fn(); - jest.mocked(useRecentScriptureRefs).mockReturnValue({ - recentScriptureRefs: [], - addRecentScriptureRef: mockAddRecentRef, - }); - render(); - - await userEvent.click(screen.getByRole('button', { name: /submit reference/i })); - - expect(mockSetScrRef).toHaveBeenCalledWith(defaultScrRef); - expect(mockAddRecentRef).toHaveBeenCalledWith(defaultScrRef); - }); - - it('calls setScrRef with the segment ref when a verse box is clicked', async () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - const mockSetScrRef = jest.fn(); - // Start at verse 1; click verse 2's token to select it - render(); - - await userEvent.click(screen.getByText('And')); - - expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); - }); - - it('passes a book-stable ref to BookUSJ so chapter and verse changes do not re-fetch the book', () => { - const mockBookUSJ = jest.fn().mockReturnValue([{}, jest.fn(), false]); - jest.mocked(useProjectData).mockImplementation(() => ({ BookUSJ: mockBookUSJ })); - const { rerender } = render(); - rerender( - , - ); - - const refsPassed = mockBookUSJ.mock.calls.map((c) => c[0]); - refsPassed.forEach((ref) => expect(ref).toEqual({ book: 'GEN', chapterNum: 1, verseNum: 1 })); - expect(mockBookUSJ.mock.calls.length).toBeGreaterThanOrEqual(2); - refsPassed.slice(1).forEach((ref) => expect(ref).toBe(refsPassed[0])); - }); - - it('renders the continuous scroll toggle when a project is linked', () => { - mockBookData({}); - render(); - - expect(screen.getByRole('checkbox')).toBeInTheDocument(); - }); - - it('does not render the continuous scroll toggle when no project is linked', () => { - render(); - - expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); - }); - - it('continuous scroll toggle is checked when the setting is true', () => { - mockBookData({}); - mockWritingSystem('en', true); - render(); - - expect(screen.getByRole('checkbox')).toBeChecked(); - }); - - it('continuous scroll toggle is unchecked when the setting is false', () => { - mockBookData({}); - jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { - if (key === 'interlinearizer.continuousScroll') return [false, jest.fn(), jest.fn(), false]; - if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; - return [d, jest.fn(), jest.fn(), false]; - }); - render(); - - expect(screen.getByRole('checkbox')).not.toBeChecked(); - }); - - it('renders segments in baseline-text mode when continuousScroll is true', () => { - mockBookData({}); - mockWritingSystem('en', true); - render(); - - expect(screen.getByText('In the beginning.')).toBeInTheDocument(); - expect(screen.queryByText('In')).not.toBeInTheDocument(); - }); - - it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - mockWritingSystem('en', true); - render(); - - expect(screen.getByText('In the beginning.')).toBeInTheDocument(); - expect(screen.getByText('And the earth.')).toBeInTheDocument(); - }); - - it('clicking the continuous scroll toggle calls setContinuousScroll with the new value', async () => { - mockBookData({}); - const mockSetContinuousScroll = jest.fn(); - jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { - if (key === 'interlinearizer.continuousScroll') - return [true, mockSetContinuousScroll, jest.fn(), false]; - if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; - return [d, jest.fn(), jest.fn(), false]; - }); - render(); - - await userEvent.click(screen.getByRole('checkbox')); - - expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); - }); - - it('switches rendering immediately using optimistic local state while setting saves', async () => { - mockBookData({}); - const mockSetContinuousScroll = jest.fn(); - // Setting source remains true during the test (simulates delayed persistence confirmation). - jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { - if (key === 'interlinearizer.continuousScroll') - return [true, mockSetContinuousScroll, jest.fn(), false]; - if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; - return [d, jest.fn(), jest.fn(), false]; - }); - render(); - - // Initially in continuous mode. - expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); - expect(screen.queryByText('In')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole('checkbox')); - - // Before setting saves, UI should already switch to token-chip mode. - expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); - expect(screen.getByText('In')).toBeInTheDocument(); - expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); - }); - - it('renders ContinuousView when continuousScroll is true and book is loaded', () => { - mockBookData({}); - mockWritingSystem('en', true); - render(); - - expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); - }); - - it('does not render ContinuousView when continuousScroll is false', () => { - mockBookData({}); - mockWritingSystem('en', false); - render(); - - expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); - }); - - it('does not render ContinuousView when continuousScroll defaults to true but book is still loading', () => { - mockBookData(undefined, true); - mockWritingSystem('en', true); - render(); - - expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); - }); - - it('does not render ContinuousView when there is a book error', () => { - mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); - mockWritingSystem('en', true); - render(); - - expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); - }); - - it('renders ContinuousView above the chapter segment rows when both are present', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - mockWritingSystem('en', true); - const { container } = render(); - - const continuousView = screen.getByTestId('continuous-view'); - // All interactive elements in DOM order; ContinuousView's div must precede the segment buttons - const allElements = Array.from( - container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'), - ); - expect(allElements[0]).toBe(continuousView); - }); - - it('toggles continuous scroll setting back to true after being false', async () => { - mockBookData({}); - const mockSetContinuousScroll = jest.fn(); - jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { - if (key === 'interlinearizer.continuousScroll') - return [false, mockSetContinuousScroll, jest.fn(), false]; - if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; - return [d, jest.fn(), jest.fn(), false]; - }); - - render(); - - // Initially in token-chip mode - expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); - - // Click toggle to turn on continuous mode - await userEvent.click(screen.getByRole('checkbox')); - expect(mockSetContinuousScroll).toHaveBeenCalledWith(true); - }); - - it('calls setScrRef when ContinuousView emits onVerseChange', async () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - mockWritingSystem('en', true); - - const mockSetScrRef = jest.fn(); - render(); - - expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); - - // Simulate ContinuousView calling onVerseChange - // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion - const onVerseChange = capturedContinuousViewProps.onVerseChange as any; - expect(onVerseChange).toBeDefined(); - - onVerseChange({ book: 'GEN', chapter: 2, verse: 3 }); + it('renders InterlinearizerLoader when a projectId is provided', () => { + render(); - expect(mockSetScrRef).toHaveBeenCalledWith({ - book: 'GEN', - chapterNum: 2, - verseNum: 3, - }); + expect(screen.getByTestId('interlinearizer-loader')).toBeInTheDocument(); }); }); diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx new file mode 100644 index 0000000..34242aa --- /dev/null +++ b/src/components/Interlinearizer.tsx @@ -0,0 +1,85 @@ +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, ScriptureRef, Segment } from 'interlinearizer'; +import { type ReactNode, useCallback } from 'react'; +import ContinuousView from './ContinuousView'; +import MemoizedSegmentView from './SegmentView'; + +/** + * Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top, + * followed by segmented views. + * + * @param props - Component props + * @param props.book - Book data used by the continuous view + * @param props.bookSegments - 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.toolbar - Toolbar content rendered at the top of the sticky header + */ +export default function Interlinearizer({ + book, + bookSegments, + continuousScroll, + scrRef, + setScrRef, + toolbar, +}: { + book: Book; + bookSegments: Segment[]; + continuousScroll: boolean; + scrRef: SerializedVerseRef; + setScrRef: (newScrRef: SerializedVerseRef) => void; + toolbar: ReactNode; +}) { + const handleVerseChange = useCallback( + (v: ScriptureRef) => { + setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); + }, + [setScrRef], + ); + + return ( +
+
+ {toolbar} + {continuousScroll && ( +
+ +
+ )} +
+ +
+
+ {bookSegments.length === 0 && ( +

+ No verse data for {scrRef.book} {scrRef.chapterNum}. +

+ )} + + {bookSegments.length > 0 && ( +
+ {bookSegments.map((seg) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx new file mode 100644 index 0000000..688954b --- /dev/null +++ b/src/components/InterlinearizerLoader.tsx @@ -0,0 +1,110 @@ +import type { UseWebViewScrollGroupScrRefHook } from '@papi/core'; +import { TabToolbar } from 'platform-bible-react'; +import ContinuousScrollToggle from './ContinuousScrollToggle'; +import ScriptureNavControls from './ScriptureNavControls'; +import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; +import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; +import Interlinearizer from './Interlinearizer'; + +/** + * Root component for loading the Interlinearizer. Loads book data and settings, then renders error + * and loading states or delegates to {@link Interlinearizer} when data is ready. + * + * @param props - Component props + * @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 + */ +export default function InterlinearizerLoader({ + projectId, + useWebViewScrollGroupScrRef, +}: { + projectId: string; + useWebViewScrollGroupScrRef: UseWebViewScrollGroupScrRefHook; +}) { + const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + + const { + isLoading: isSettingLoading, + onChange: handleContinuousScrollChange, + value: continuousScroll, + } = useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); + + const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( + { + projectId, + scrRef, + }, + ); + + const hasError = !!bookError || !!tokenizeError; + const showLoading = isLoading || isSettingLoading; + + const toolbar = ( + + } + endAreaChildren={ + !isSettingLoading && ( + + ) + } + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ + onSelectProjectMenuItem={() => {}} + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ + onSelectViewInfoMenuItem={() => {}} + /> + ); + + return hasError || showLoading || !book ? ( +
+ {toolbar} +
+
+ {bookError && ( +
+

Error loading book

+
+                {bookError}
+              
+
+ )} + + {tokenizeError && ( +
+

+ Error processing book +

+
+                {tokenizeError.message}
+              
+
+ )} + + {!hasError && showLoading && ( +

Loading…

+ )} +
+
+
+ ) : ( + + ); +} diff --git a/src/components/ScriptureNavControls.tsx b/src/components/ScriptureNavControls.tsx new file mode 100644 index 0000000..481ee73 --- /dev/null +++ b/src/components/ScriptureNavControls.tsx @@ -0,0 +1,44 @@ +import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; +import { useMemo } from 'react'; +import { + BOOK_CHAPTER_CONTROL_STRING_KEYS, + BookChapterControl, + BookChapterControlProps, + ScrollGroupSelector, + ScrollGroupSelectorProps, +} from 'platform-bible-react'; + +const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; + +type ScriptureNavControlsProps = Pick & + Pick; + +export default function ScriptureNavControls({ + scrRef, + handleSubmit, + scrollGroupId, + onChangeScrollGroupId, +}: ScriptureNavControlsProps) { + const [localizedStrings] = useLocalizedStrings( + useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), + ); + const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = + useRecentScriptureRefs(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index 6d14c15..ca7dabb 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -1,4 +1,4 @@ -import type { Segment } from 'interlinearizer'; +import type { ScriptureRef, Segment } from 'interlinearizer'; import { memo } from 'react'; import MemoizedPhraseBox from './PhraseBox'; import MemoizedTokenChip from './TokenChip'; @@ -24,7 +24,7 @@ export function SegmentView({ }: Readonly<{ displayMode?: SegmentDisplayMode; isActive?: boolean; - onClick?: (ref: { book: string; chapter: number; verse: number }) => void; + onClick?: (ref: ScriptureRef) => void; segment: Segment; }>) { const { book, chapter, verse } = segment.startRef; diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index 8bc9ecd..09b2858 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -8,7 +8,7 @@ import { isPlatformError } from 'platform-bible-utils'; import { useEffect, useMemo } from 'react'; interface UseInterlinearizerBookDataArgs { - projectId?: string; + projectId: string; scrRef: SerializedVerseRef; } @@ -24,25 +24,23 @@ export default function useInterlinearizerBookData({ projectId, scrRef, }: Readonly): UseInterlinearizerBookDataResult { - const hasProject = Boolean(projectId); - const resolvedProjectId = projectId ?? ''; - const bookScrRef = useMemo( () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), [scrRef.book], ); - const [bookResult, , isLoadingRaw] = useProjectData( - 'platformScripture.USJ_Book', - resolvedProjectId, - ).BookUSJ(bookScrRef, undefined); - const [writingSystem] = useProjectSetting(resolvedProjectId, 'platform.languageTag', ''); + const [bookResult, , isLoading] = useProjectData('platformScripture.USJ_Book', projectId).BookUSJ( + bookScrRef, + undefined, + ); + const [writingSystem] = useProjectSetting(projectId, 'platform.languageTag', ''); const [book, tokenizeError] = useMemo((): [ Book | undefined, { message: string; raw: unknown } | undefined, ] => { if (!bookResult || isPlatformError(bookResult)) return [undefined, undefined]; + try { const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; return [tokenizeBook(extractBookFromUsj(bookResult, ws)), undefined]; @@ -52,16 +50,16 @@ export default function useInterlinearizerBookData({ }, [bookResult, writingSystem]); useEffect(() => { - if (!hasProject || !tokenizeError) return; + if (!tokenizeError) return; const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { message: tokenizeError.message, writingSystem: ws, - projectId: resolvedProjectId, + projectId, book: scrRef.book, }); - }, [hasProject, tokenizeError, writingSystem, resolvedProjectId, scrRef.book]); + }, [tokenizeError, writingSystem, projectId, scrRef.book]); const chapterSegments = useMemo( () => @@ -71,13 +69,11 @@ export default function useInterlinearizerBookData({ [book, scrRef.book, scrRef.chapterNum], ); - const isLoading = hasProject ? isLoadingRaw : false; - let bookError: string | undefined; - if (hasProject && isPlatformError(bookResult)) { + if (isPlatformError(bookResult)) { bookError = bookResult.message; - } else if (hasProject && !isLoading && bookResult === undefined) { - bookError = `No USJ book available for ${scrRef.book} in project ${resolvedProjectId}`; + } else if (!isLoading && !bookResult) { + bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; } return { book, chapterSegments, isLoading, bookError, tokenizeError }; diff --git a/src/hooks/useOptimisticBooleanSetting.ts b/src/hooks/useOptimisticBooleanSetting.ts index 0e8dddd..bf9ba32 100644 --- a/src/hooks/useOptimisticBooleanSetting.ts +++ b/src/hooks/useOptimisticBooleanSetting.ts @@ -10,14 +10,14 @@ const TIMEOUT_MS = 15_000; * The local value is updated immediately on change and stays locked until timeout elapses, to allow * the stored setting to finish updating. * - * @param projectId - PAPI project ID; pass `undefined` when outside a project context - * @param settingKey - A valid key for a boolean setting * @param defaultValue - Default value used when the setting has not been persisted yet + * @param projectId - PAPI project ID + * @param settingKey - A valid key for a boolean setting * @returns `isLoading` — whether the setting value is still loading from the platform; `onChange` — * stable change handler; `value` — the current display value */ export default function useOptimisticBooleanSetting( - projectId: string | undefined, + projectId: string, settingKey: 'interlinearizer.continuousScroll', defaultValue: boolean, ): { @@ -25,11 +25,7 @@ export default function useOptimisticBooleanSetting( onChange: (newValue: boolean) => void; value: boolean; } { - const [setting, setSetting, , isLoading] = useProjectSetting( - projectId ?? '', - settingKey, - defaultValue, - ); + const [setting, setSetting, , isLoading] = useProjectSetting(projectId, settingKey, defaultValue); const [value, setValue] = useState(typeof setting === 'boolean' ? setting : defaultValue); diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 41f85f0..1b650d9 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -1,23 +1,8 @@ import type { WebViewProps } from '@papi/core'; -import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; -import { useCallback, useMemo } from 'react'; -import { - BOOK_CHAPTER_CONTROL_STRING_KEYS, - BookChapterControl, - ScrollGroupSelector, - TabToolbar, -} from 'platform-bible-react'; -import ContinuousScrollToggle from './components/ContinuousScrollToggle'; -import useOptimisticBooleanSetting from './hooks/useOptimisticBooleanSetting'; -import ContinuousView from './components/ContinuousView'; -import MemoizedSegmentView from './components/SegmentView'; -import useInterlinearizerBookData from './hooks/useInterlinearizerBookData'; - -const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; +import InterlinearizerLoader from './components/InterlinearizerLoader'; /** - * Root WebView component for the Interlinearizer. Renders a sticky reference picker at the top and - * uses hook-backed book state to render continuous and segmented views. + * Root WebView component for the Interlinearizer. * * @param props - WebView props injected by the PAPI host * @param props.projectId - PAPI project ID passed from the host; undefined when the WebView is @@ -30,150 +15,18 @@ globalThis.webViewComponent = function InterlinearizerWebView({ projectId, useWebViewScrollGroupScrRef, }: WebViewProps) { - const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); - - const { - isLoading: isSettingLoading, - onChange: handleContinuousScrollChange, - value: continuousScroll, - } = useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); - - const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( - { - projectId, - scrRef, - }, - ); - - const [localizedStrings] = useLocalizedStrings( - useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), - ); - - const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = - useRecentScriptureRefs(); - - const handleContinuousVerseChange = useCallback( - (v: { book: string; chapter: number; verse: number }) => { - setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); - }, - [setScrRef], - ); - - const handleSegmentSelect = useCallback( - (ref: { book: string; chapter: number; verse: number }) => { - setScrRef({ book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }); - }, - [setScrRef], - ); - - const hasError = !!bookError || !!tokenizeError; - const showLoading = isLoading || isSettingLoading; - return (
-
- - - -
- } - endAreaChildren={ - projectId && - !isSettingLoading && ( - - ) - } - /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ - onSelectProjectMenuItem={() => {}} - /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ - onSelectViewInfoMenuItem={() => {}} + {projectId ? ( + - {projectId && !hasError && !showLoading && book && continuousScroll && ( -
- -
- )} -
- -
- {projectId ? ( -
- {bookError && ( -
-

- Error loading book -

-
-                  {bookError}
-                
-
- )} - - {tokenizeError && ( -
-

- Error processing book -

-
-                  {tokenizeError.message}
-                
-
- )} - - {!hasError && showLoading && ( -

Loading…

- )} - - {!hasError && !showLoading && chapterSegments.length === 0 && ( -

- No verse data for {scrRef.book} {scrRef.chapterNum}. -

- )} - - {!hasError && !showLoading && chapterSegments.length > 0 && ( -
- {chapterSegments.map((seg) => ( - - ))} -
- )} -
- ) : ( -

- Open this WebView from a Paratext project to load its source book. -

- )} -
+ ) : ( +

+ Open this WebView from a Paratext project to load its source book. +

+ )}
); }; From e119d681e02854467f2151808a7a2050a645d695 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 17:05:33 -0400 Subject: [PATCH 25/27] Complete ContinuousView coverage --- .../components/ContinuousView.test.tsx | 28 +++++++++++++++++++ src/components/ContinuousView.tsx | 2 ++ 2 files changed, 30 insertions(+) diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 41dae1f..eb19b1a 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -788,3 +788,31 @@ describe('ContinuousView non-word tokens and word-free paths', () => { expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); }); }); + +// --------------------------------------------------------------------------- +// RTL layout +// --------------------------------------------------------------------------- + +describe('ContinuousView RTL layout', () => { + beforeEach(() => { + document.documentElement.dir = 'rtl'; + }); + + afterEach(() => { + document.documentElement.dir = 'ltr'; + }); + + it('shows right-arrow (→) on the previous button in RTL mode', () => { + render(); + + const prevBtn = screen.getByRole('button', { name: 'Previous token' }); + expect(prevBtn.querySelector('[aria-hidden="true"]')).toHaveTextContent('\u2192'); + }); + + it('shows left-arrow (←) on the next button in RTL mode', () => { + render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + expect(nextBtn.querySelector('[aria-hidden="true"]')).toHaveTextContent('\u2190'); + }); +}); diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index a446258..d7f6120 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -245,7 +245,9 @@ export default function ContinuousView({ const step = useCallback((delta: number) => { setFocusPhraseIndex((i) => { const nextIndex = i + delta; + /* v8 ignore next -- disabled buttons prevent underflow */ if (nextIndex < 0) return 0; + /* v8 ignore next -- disabled buttons prevent overflow */ if (nextIndex >= phraseEntriesRef.current.length) return phraseEntriesRef.current.length - 1; return nextIndex; }); From 6fc0c7f92fef90daa5e23ea94964040480bc9798 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 17:22:25 -0400 Subject: [PATCH 26/27] Add setting type --- contributions/projectSettings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json index 5f9160b..2c3b6bb 100644 --- a/contributions/projectSettings.json +++ b/contributions/projectSettings.json @@ -5,7 +5,8 @@ "interlinearizer.continuousScroll": { "label": "%interlinearizer_projectSettings_continuousScroll%", "description": "%interlinearizer_projectSettings_continuousScrollDescription%", - "default": true + "default": true, + "type": "boolean" } } } From 1af220ee510fbe5ea584f582c881afc1dbd9f45f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 May 2026 17:23:24 -0400 Subject: [PATCH 27/27] Smooth-scroll for internal clicks --- src/components/ContinuousView.tsx | 46 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index d7f6120..354fd2c 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -1,9 +1,11 @@ -/** @file Continuous horizontal token-strip viewer for a full book. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Book, ScriptureRef, Token } from 'interlinearizer'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import MemoizedPhraseBox from './PhraseBox'; import MemoizedTokenChip from './TokenChip'; +const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; +const STRIP_FADE_MS = 500; + /** * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable * strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling @@ -34,8 +36,6 @@ export default function ContinuousView({ book: Book; onVerseChange?: (verse: ScriptureRef) => void; }>) { - const STRIP_FADE_MS = 500; - const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; const isRtl = document.documentElement.dir === 'rtl'; const allTokens: Token[] = useMemo( @@ -103,6 +103,7 @@ export default function ContinuousView({ s.startRef.chapter === verse.chapter && s.startRef.verse === verse.verse, ); + /* v8 ignore next -- only reachable when an external activeVerse references an unrecognized segment */ if (!seg) return; const tokenIndex = segmentStartIndex.get(seg.id); @@ -152,21 +153,28 @@ export default function ContinuousView({ const isExternalJumpInProgressRef = useRef(false); const isInitialLoadInProgressRef = useRef(true); + /** + * Records the verse most recently reported via `onVerseChange`. When the parent echoes that verse + * back as `activeVerse` we skip the jump — the change originated here, not externally. + * Initialized to `activeVerse` so the initial mount position (set by the lazy `useState` + * initializer) is treated as already handled, preventing a spurious jump on first render. + */ + const lastInternalVerseRef = useRef(activeVerse); + // Jump to the first token of the matching segment when the active verse changes. useEffect(() => { if (!activeVerse) return; - // Preserve current phrase focus when it is already inside the target verse. - const currentlyFocusedPhrase = phraseEntriesRef.current[focusPhraseIndexRef.current]; - if (currentlyFocusedPhrase) { - const ref = tokenSegmentRef.current[currentlyFocusedPhrase.tokenIndex]?.startRef; - if ( - ref?.book === activeVerse.book && - ref.chapter === activeVerse.chapter && - ref.verse === activeVerse.verse - ) { - return; - } + // Skip if this activeVerse update is an echo-back of a verse change we reported ourselves. + const lastInternal = lastInternalVerseRef.current; + if ( + lastInternal && + lastInternal.book === activeVerse.book && + lastInternal.chapter === activeVerse.chapter && + lastInternal.verse === activeVerse.verse + ) { + lastInternalVerseRef.current = undefined; + return; } const phraseIndex = getPhraseIndexForVerse(activeVerse); @@ -192,7 +200,7 @@ export default function ContinuousView({ }, STRIP_FADE_MS); return () => clearTimeout(timeout); - }, [pendingExternalJumpPhraseIndex, STRIP_FADE_MS]); + }, [pendingExternalJumpPhraseIndex]); // Fire onVerseChange when arrow navigation crosses into a new verse. // Initialise to the segment that owns the initial focusPhraseIndex so the initial render does not trigger the callback. @@ -225,11 +233,13 @@ export default function ContinuousView({ if (!seg || seg.id === lastReportedSegIdRef.current) return; lastReportedSegIdRef.current = seg.id; - onVerseChange?.({ + const verse = { book: seg.startRef.book, chapter: seg.startRef.chapter, verse: seg.startRef.verse, - }); + }; + lastInternalVerseRef.current = verse; + onVerseChange?.(verse); // onVerseChange and tokenSegmentRef are intentionally excluded — callers must stabilize the // reference (useCallback) and tokenSegmentRef is a ref so changes are always current. // eslint-disable-next-line react-hooks/exhaustive-deps