Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f65496c
Allow user to define their own segment boundaries
alex-rawlings-yyc Jun 22, 2026
bade6e9
"Prevent" verse zero from being included (currently broken, pushing s…
alex-rawlings-yyc Jun 24, 2026
ba196ee
Draw hard boundary at verse-0 segments
alex-rawlings-yyc Jun 24, 2026
a4f41f8
Update user questions document
alex-rawlings-yyc Jun 24, 2026
439807f
Fix formatting issue
alex-rawlings-yyc Jul 2, 2026
009da57
Remove the verse-0 hard-wall guards from the segmentation model
alex-rawlings-yyc Jul 2, 2026
f2f2bb3
Fix boundary edits not re-rendering the view
alex-rawlings-yyc Jul 2, 2026
b3355d8
Label segments with a per-chapter number and contained verse range
alex-rawlings-yyc Jul 2, 2026
964564b
Add straddled-phrase detection and an event-time phrase-link getter
alex-rawlings-yyc Jul 2, 2026
adf8888
Replace the boundary-edit mode with always-available controls
alex-rawlings-yyc Jul 2, 2026
8d79504
Keep focus and scroll stable across boundary edits
alex-rawlings-yyc Jul 2, 2026
d60ed58
Update user questions for the reworked boundary editing
alex-rawlings-yyc Jul 2, 2026
a05ca88
Fix code-review findings across segmentation persistence and boundary
alex-rawlings-yyc Jul 6, 2026
7b4be0d
Letter split-verse labels and resolve navigation by verse containment
alex-rawlings-yyc Jul 6, 2026
2fd26d4
Use SegmentLabel type in Interlinearizer test
alex-rawlings-yyc Jul 7, 2026
c90b481
Renamed adjacentSegment to boundarySegment for clarity
alex-rawlings-yyc Jul 7, 2026
3505710
Fix lint error
alex-rawlings-yyc Jul 7, 2026
520f565
Normalize empty segmentation delta to default in autosave
alex-rawlings-yyc Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions __mocks__/lucide-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,33 @@ export function Settings(props: Readonly<{ size?: number; className?: string }>)
export function Plus(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="plus-icon" {...props} />;
}
/**
* Stub for the FoldVertical icon used by the row-gap merge control (join two stacked rows).
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a vertical-merge icon stub in tests.
*/
export function FoldVertical(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="fold-vertical-icon" {...props} />;
}

/**
* Stub for the FoldHorizontal icon used by the continuous-strip merge control (join two adjacent
* segments).
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a horizontal-merge icon stub in tests.
*/
export function FoldHorizontal(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="fold-horizontal-icon" {...props} />;
}

/**
* Stub for the Scissors icon used by the split boundary control.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a split icon stub in tests.
*/
export function Scissors(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="scissors-icon" {...props} />;
}
5 changes: 4 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Only a free edge token of an adjacent segment can be linked across a boundary.",
"%interlinearizer_boundaryControl_merge%": "Merge segments",
"%interlinearizer_boundaryControl_split%": "Split segment here",
"%interlinearizer_boundaryControl_formerBoundary%": "Former verse boundary",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
"%interlinearizer_modal_create_name_label%": "Name (optional)",
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"recentering",
"recenters",
"relayout",
"resegment",
"resnap",
"sandboxed",
"scriptio",
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/components/AnalysisStore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useMorphemeDeleteDispatch,
useMorphemeGlossDispatch,
useMorphemes,
usePhraseLinkByIdGetter,
usePhraseLinkByIdMap,
usePhraseLinkForToken,
usePhraseLinkMap,
Expand Down Expand Up @@ -684,6 +685,45 @@ describe('usePhraseLinkByIdMap', () => {
});
});

/**
* Renders a component that reads the phrase-link map through `usePhraseLinkByIdGetter` at render
* time and displays its size, used to assert the getter resolves current store state.
*
* @returns JSX element suitable for passing to `render`.
*/
function PhraseLinkByIdGetterReader() {
const getPhraseLinkById = usePhraseLinkByIdGetter();
return <span data-testid="getter-map-size">{getPhraseLinkById().size}</span>;
}

/**
* Renders a component that calls `usePhraseLinkByIdGetter` without a provider, to assert it throws.
*
* @returns Nothing — only mounted to trigger the throw.
*/
function PhraseLinkByIdGetterUser() {
usePhraseLinkByIdGetter();
return undefined;
}

describe('usePhraseLinkByIdGetter', () => {
it('returns a getter resolving the current phrase-link-by-id map', () => {
render(
<AnalysisStoreProvider initialAnalysis={PHRASE_ANALYSIS} analysisLanguage="und">
<PhraseLinkByIdGetterReader />
</AnalysisStoreProvider>,
);
expect(screen.getByTestId('getter-map-size')).toHaveTextContent('1');
});

it('throws when called outside an AnalysisStoreProvider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<PhraseLinkByIdGetterUser />)).toThrow(
'usePhraseLinkByIdGetter must be used inside an AnalysisStoreProvider',
);
});
});

describe('usePhraseDispatch', () => {
it('createPhrase adds a new phrase and calls onSave', async () => {
const onSave = jest.fn();
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event';
import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer';
import { useState, type ReactNode } from 'react';
import { resegmentBook } from 'parsers/papi/resegmentBook';
import type { PhraseDispatch } from '../../components/AnalysisStore';
import ContinuousView from '../../components/ContinuousView';
import { isWordToken } from '../../types/type-guards';
Expand Down Expand Up @@ -863,6 +864,54 @@ describe('ContinuousView scroll behavior', () => {
expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' }));
});

it('holds the group centered on animation frames after an external jump within the active segment', () => {
// An external jump landing in the already-active segment never flips committedActiveSegmentId,
// so the layout-effect re-center loop does not run for it; the scroll effect's own hold loop
// must keep the group pinned while late layout (arc padding settling on the slid window)
// shifts the strip after the instant snap.
const book = makeBook();
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);

act(() => {
jest.useFakeTimers();
});
try {
// tok-1 shares GEN 1:1 with tok-0, so the active segment is unchanged by this jump.
rerender(<ContinuousView {...{ ...props, focusedTokenRef: 'tok-1' }} />);
// Complete the fade-out (RECENTER_FADE_MS) so the displayed focus updates and the instant
// snap fires.
scrollIntoViewMock.mockClear();
act(() => {
jest.advanceTimersByTime(510);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);

// Frames within the hold window keep re-centering after the snap.
scrollIntoViewMock.mockClear();
act(() => {
jest.advanceTimersByTime(50);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);

// Past the deadline the loop stops scheduling further frames.
act(() => {
jest.advanceTimersByTime(500);
});
scrollIntoViewMock.mockClear();
act(() => {
jest.advanceTimersByTime(500);
});
expect(scrollIntoViewMock).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});

it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => {
const book = makeBook();
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
Expand Down Expand Up @@ -1157,6 +1206,49 @@ describe('ContinuousView scroll behavior', () => {
});
});

// ---------------------------------------------------------------------------
// Segmentation edits
// ---------------------------------------------------------------------------

describe('ContinuousView segmentation edits', () => {
/**
* Reads the inline opacity of the link-slot wrapper between `prevRef` and `nextRef`, the style
* `PhraseSlot` uses to suppress link buttons outside the active segment.
*
* @param container - The render container to query.
* @param prevRef - Token ref on the start side of the slot.
* @param nextRef - Token ref on the end side of the slot.
* @returns The wrapper's inline `opacity` value.
*/
function slotOpacity(container: HTMLElement, prevRef: string, nextRef: string): string {
const icon = container.querySelector(
`[data-prev-ref="${prevRef}"][data-next-ref="${nextRef}"]`,
);
const wrapper = icon?.parentElement;
if (!(wrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span');
return wrapper.style.opacity;
}

it('keeps the focused segment link buttons active when a merge changes the focused token segment id', () => {
const book = makeBook();
const props = requiredProps(book, { focusedTokenRef: 'tok-2' });
props.viewOptions = { ...allFalseViewOptions, hideInactiveLinkButtons: true };
const { container, rerender } = render(<ContinuousView {...props} />, withAnalysisStore);

// Focus sits in GEN 1:2, so the slot between its two tokens is active and visible.
expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1');

// Merge GEN 1:2 into GEN 1:1 — the exact transform the loader applies on a boundary edit.
// Token refs survive, so focus does not move, but the focused token's segment id changes.
const merged = resegmentBook(book, { removedVerseStarts: ['tok-2'], addedStarts: [] });
rerender(<ContinuousView {...{ ...props, book: merged, ...buildLookups(merged) }} />);

// The committed active segment must follow the merge; a stale id names a segment that no
// longer exists, which would suppress every link button until the next navigation.
expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1');
});
});

// ---------------------------------------------------------------------------
// RTL layout
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading