Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ead5035
Allow user to define their own segment boundaries
alex-rawlings-yyc Jul 13, 2026
babb726
"Prevent" verse zero from being included (currently broken, pushing s…
alex-rawlings-yyc Jun 24, 2026
3a2594e
Draw hard boundary at verse-0 segments
alex-rawlings-yyc Jun 24, 2026
a6388d0
Update user questions document
alex-rawlings-yyc Jun 24, 2026
e72ecdb
Fix formatting issue
alex-rawlings-yyc Jul 2, 2026
c589ff6
Remove the verse-0 hard-wall guards from the segmentation model
alex-rawlings-yyc Jul 2, 2026
0e8f5e8
Fix boundary edits not re-rendering the view
alex-rawlings-yyc Jul 2, 2026
2d5db97
Label segments with a per-chapter number and contained verse range
alex-rawlings-yyc Jul 2, 2026
e8e808e
Add straddled-phrase detection and an event-time phrase-link getter
alex-rawlings-yyc Jul 2, 2026
9be6499
Replace the boundary-edit mode with always-available controls
alex-rawlings-yyc Jul 2, 2026
2874ecb
Keep focus and scroll stable across boundary edits
alex-rawlings-yyc Jul 2, 2026
01f89f3
Update user questions for the reworked boundary editing
alex-rawlings-yyc Jul 2, 2026
0f5ef91
Fix code-review findings across segmentation persistence and boundary
alex-rawlings-yyc Jul 6, 2026
9103f47
Letter split-verse labels and resolve navigation by verse containment
alex-rawlings-yyc Jul 6, 2026
e9d76b6
Use SegmentLabel type in Interlinearizer test
alex-rawlings-yyc Jul 7, 2026
97a9b3e
Renamed adjacentSegment to boundarySegment for clarity
alex-rawlings-yyc Jul 7, 2026
94412e2
Fix lint error
alex-rawlings-yyc Jul 7, 2026
234b0fd
Normalize empty segmentation delta to default in autosave
alex-rawlings-yyc Jul 7, 2026
e29291a
Fix segment-boundary review findings
alex-rawlings-yyc Jul 15, 2026
78d1f05
Fix non-first subverse segment click deferring to first segment
alex-rawlings-yyc Jul 9, 2026
d8a16f9
Replace segment labels with inline verse superscripts and a chapter
alex-rawlings-yyc Jul 9, 2026
c53e49f
Replace Scissors split button with Alt+click gap gesture
alex-rawlings-yyc Jul 9, 2026
221f763
Move verse numbers into the phrase slot and rework boundary affordances
alex-rawlings-yyc Jul 9, 2026
e186946
Rework segment-boundary affordances and slot column layout
alex-rawlings-yyc Jul 15, 2026
d83a0ac
Fix segment-boundary review findings across resegmentation and verse
alex-rawlings-yyc Jul 10, 2026
b6a4484
Fix formatting issue
alex-rawlings-yyc Jul 10, 2026
f4f0244
Add sourceProjectId to scroll-group hook test stubs
alex-rawlings-yyc Jul 13, 2026
59786f3
Make join buttons always visible and dedupe split-verse superscripts
alex-rawlings-yyc Jul 13, 2026
6c63602
Add verse-range gutter column to the segment view
alex-rawlings-yyc Jul 13, 2026
0106e9b
Cancel pending pinned-chapter rAF on cleanup; dedupe verse-label regex
alex-rawlings-yyc Jul 13, 2026
1957639
Fix segment-boundary review findings across merge, wipe, and verse
alex-rawlings-yyc Jul 14, 2026
4133eaf
Test useResolvedTokenAnalysis disabled short-circuit
alex-rawlings-yyc Jul 14, 2026
a7fde79
Make the verse-range gutter a view toggle vs inline superscripts
alex-rawlings-yyc Jul 14, 2026
5fd2fa3
Keep the focused phrase centered until the continuous strip settles
alex-rawlings-yyc Jul 14, 2026
327c262
Fix phrase arcs measured against still-settling gloss chip widths
alex-rawlings-yyc Jul 14, 2026
e26a72b
Align intra-phrase gap controls with inter-phrase slots
alex-rawlings-yyc Jul 14, 2026
f880420
Keep contview hold observer alive past the quiet window
alex-rawlings-yyc Jul 14, 2026
f880f60
Trim verbose and history-narrating comments across the branch
alex-rawlings-yyc Jul 15, 2026
c309630
Clean up user-questions.md
alex-rawlings-yyc Jul 15, 2026
454e627
Disable cross-segment token linking at boundaries
alex-rawlings-yyc Jul 15, 2026
58f0ca9
Include split-button geometry in arc apply signature
alex-rawlings-yyc Jul 15, 2026
4703ce0
Keep segment window framed across boundary edits
alex-rawlings-yyc Jul 15, 2026
1137846
Fix boundary-edit test fixture and correct split-control docs
alex-rawlings-yyc Jul 15, 2026
e5538cd
Hoist boundary-control strings to strip context
alex-rawlings-yyc Jul 15, 2026
5b2b797
Group boundary-control tests under nested describes
alex-rawlings-yyc Jul 15, 2026
04a41d5
Use platform-bible-react Tooltip for boundary controls
alex-rawlings-yyc Jul 15, 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
35 changes: 34 additions & 1 deletion __mocks__/lucide-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function Link2(props: Readonly<{ size?: number; className?: string }>): R
}

/**
* Stub for the Link2Off (unlink) icon used by the between-token unlink and arc-split buttons.
* Stub for the Link2Off (unlink) icon used by the arc-split button.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as an unlink icon stub in tests.
Expand All @@ -65,6 +65,17 @@ export function Link2Off(props: Readonly<{ size?: number; className?: string }>)
return <svg data-testid="link2off-icon" {...props} />;
}

/**
* Stub for the Unlink2 icon used by the between-token unlink button; a broken-chain glyph whose
* chain sits at the same vertical position as Link2 so their button centers line up.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as an unlink icon stub in tests.
*/
export function Unlink2(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="unlink2-icon" {...props} />;
}

/**
* Stub for the Settings gear icon used by the view-options dropdown button.
*
Expand All @@ -84,3 +95,25 @@ 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 Merge icon used by both merge controls (the row-gap merge in the segment list and the
* cross-segment merge in the continuous strip). A single Y-join glyph, deliberately a different
* shape from the split marker's arrows-apart glyph.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a merge icon stub in tests.
*/
export function Merge(props: Readonly<{ className?: string }>): ReactElement {
return <svg data-testid="merge-icon" {...props} />;
}

/**
* Stub for the Split icon used by the Alt-gated split markers (token-chip and baseline-text). One
* stroke diverging into two — the mirror of the `Merge` glyph's join.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a split-marker icon stub in tests.
*/
export function Split(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="split-icon" {...props} />;
}
71 changes: 70 additions & 1 deletion __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* without extra transform configuration. This stub provides the subset used by the extension.
*/

import { forwardRef, useEffect, useRef } from 'react';
import { Children, cloneElement, forwardRef, isValidElement, useEffect, useRef } from 'react';
import type { MouseEventHandler, ReactElement, ReactNode } from 'react';

export interface MenuItemContainingCommand {
Expand Down Expand Up @@ -523,3 +523,72 @@ export function Label({
</label>
);
}

/**
* Marker component identifying the tooltip's hover text within a {@link Tooltip}. The real component
* renders a portaled popover on hover; this stub carries no markup of its own — {@link Tooltip}
* reads its text children and projects them onto the trigger (see there) so the tooltip text is
* assertable on the trigger element without simulating hover.
*
* @param props - Component props.
* @param props.children - The tooltip text.
* @returns `null`; the text is surfaced by {@link Tooltip}, not rendered here.
*/
export function TooltipContent({ children: _children }: Readonly<{ children?: ReactNode }>): null {
return null;
}

/**
* Stub tooltip trigger. With `asChild` (the only mode the extension uses) the real component merges
* its trigger onto the single child element rather than rendering a wrapper; this stub renders the
* child unchanged and lets {@link Tooltip} clone it to attach the tooltip text. `asChild` is assumed
* throughout, so no non-`asChild` fallback is modeled.
*
* @param props - Component props.
* @param props.children - The element the tooltip is anchored to.
* @returns The child unchanged.
*/
export function TooltipTrigger({
children,
}: Readonly<{ children?: ReactNode; asChild?: boolean }>): ReactElement {
return <>{children}</>;
}

/**
* Stub tooltip root. The real component shows {@link TooltipContent} in a portaled popover on hover;
* because native and Radix tooltips are both invisible in jsdom, this stub instead reads the
* `TooltipContent` text from its children and clones the `TooltipTrigger`'s child element with that
* text applied as a `title` attribute. This keeps the tooltip text assertable on the trigger
* element (mirroring where hover text lived before the migration) without simulating hover, while
* the real component supplies the modifier-key-immune tooltip in production.
*
* @param props - Component props.
* @param props.children - A {@link TooltipTrigger} and a {@link TooltipContent}, in either order.
* @returns The trigger's child element cloned with the tooltip text as its `title`.
*/
export function Tooltip({ children }: Readonly<{ children?: ReactNode }>): ReactNode {
let tooltipText: ReactNode;
let triggerChild: ReactNode;
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
if (child.type === TooltipContent) tooltipText = child.props.children;
if (child.type === TooltipTrigger) triggerChild = child.props.children;
});
if (!isValidElement(triggerChild)) return <>{children}</>;
const title = typeof tooltipText === 'string' ? tooltipText : undefined;
return cloneElement(triggerChild, { title });
}

/**
* Stub tooltip provider that shares hover-delay config across nested tooltips. The stub renders its
* children unchanged; the delay has no effect in tests.
*
* @param props - Component props.
* @param props.children - The subtree whose tooltips share this provider.
* @returns The children unchanged.
*/
export function TooltipProvider({
children,
}: Readonly<{ children?: ReactNode; delayDuration?: number }>): ReactElement {
return <>{children}</>;
}
11 changes: 7 additions & 4 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@
"%interlinearizer_viewOption_continuousScroll%": "Continuous Scroll",
"%interlinearizer_viewOption_hideInactiveLinkButtons%": "Hide out-of-segment link buttons",
"%interlinearizer_viewOption_simplifyPhrases%": "Show phrase controls on focus only",
"%interlinearizer_viewOption_chapterLabelInVerse%": "Show chapter in verse label",
"%interlinearizer_projectSettings_hideInactiveLinkButtons%": "Hide Out-of-Segment Link Buttons",
"%interlinearizer_projectSettings_hideInactiveLinkButtonsDescription%": "Hide link buttons between phrases in segments that are not currently active",
"%interlinearizer_projectSettings_simplifyPhrases%": "Show Phrase Controls on Focus Only",
"%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover",
"%interlinearizer_projectSettings_chapterLabelInVerse%": "Show Chapter in Verse Label",
"%interlinearizer_projectSettings_chapterLabelInVerseDescription%": "Mark chapter boundaries by labeling the first verse of each chapter as chapter:verse instead of showing an inline chapter header above it",
"%interlinearizer_viewOption_showMorphology%": "Show morphology",
"%interlinearizer_projectSettings_showMorphology%": "Show Morphology",
"%interlinearizer_projectSettings_showMorphologyDescription%": "Display morpheme breakdown and per-morpheme glosses beneath each word token",
"%interlinearizer_viewOption_showFreeTranslation%": "Show free translation",
"%interlinearizer_projectSettings_showFreeTranslation%": "Show Free Translation",
"%interlinearizer_projectSettings_showFreeTranslationDescription%": "Display a free-translation input beneath each segment's tokens or baseline text",
"%interlinearizer_viewOption_showVerseGutter%": "Show verse gutter",
"%interlinearizer_projectSettings_showVerseGutter%": "Show Verse Gutter",
"%interlinearizer_projectSettings_showVerseGutterDescription%": "Show each segment's verse range in a left gutter column instead of inline verse superscripts",
"%interlinearizer_viewOption_showSuggestions%": "Show suggestions",
"%interlinearizer_glossInput_placeholder%": "gloss",
"%interlinearizer_freeTranslationInput_placeholder%": "Free translation",
Expand All @@ -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%": "Tokens in different segments can't be linked. Join the segments first to link across this boundary.",
"%interlinearizer_boundaryControl_merge%": "Join these two segments",
"%interlinearizer_boundaryControl_mergeAltHint%": "Join these two segments. Hold Alt (Option on Mac) and click between words to split.",
"%interlinearizer_boundaryControl_split%": "Split segment here",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
"%interlinearizer_modal_create_name_label%": "Name (optional)",
Expand Down
10 changes: 5 additions & 5 deletions contributions/projectSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@
"description": "%interlinearizer_projectSettings_simplifyPhrasesDescription%",
"default": false
},
"interlinearizer.chapterLabelInVerse": {
"label": "%interlinearizer_projectSettings_chapterLabelInVerse%",
"description": "%interlinearizer_projectSettings_chapterLabelInVerseDescription%",
"default": false
},
"interlinearizer.showMorphology": {
"label": "%interlinearizer_projectSettings_showMorphology%",
"description": "%interlinearizer_projectSettings_showMorphologyDescription%",
Expand All @@ -31,6 +26,11 @@
"label": "%interlinearizer_projectSettings_showFreeTranslation%",
"description": "%interlinearizer_projectSettings_showFreeTranslationDescription%",
"default": false
},
"interlinearizer.showVerseGutter": {
"label": "%interlinearizer_projectSettings_showVerseGutter%",
"description": "%interlinearizer_projectSettings_showVerseGutterDescription%",
"default": false
}
}
}
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"recentering",
"recenters",
"relayout",
"resegment",
"resnap",
"sandboxed",
"scriptio",
Expand Down
32 changes: 32 additions & 0 deletions src/__tests__/components/AltHeldContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** @file Unit tests for components/AltHeldContext.tsx. */
/// <reference types="jest" />
/// <reference types="@testing-library/jest-dom" />

import { render, screen } from '@testing-library/react';
import { AltHeldProvider, useAltHeldValue } from '../../components/AltHeldContext';

/**
* Renders the current Alt-held value from context as a testable string.
*
* @returns A span containing the current Alt-held value stringified.
*/
function AltHeldProbe() {
const altHeld = useAltHeldValue();
return <span data-testid="probe">{String(altHeld)}</span>;
}

describe('AltHeldContext', () => {
it('delivers the provided value to consumers', () => {
render(
<AltHeldProvider value>
<AltHeldProbe />
</AltHeldProvider>,
);
expect(screen.getByTestId('probe')).toHaveTextContent('true');
});

it('defaults to false for a consumer rendered without a provider', () => {
render(<AltHeldProbe />);
expect(screen.getByTestId('probe')).toHaveTextContent('false');
});
});
68 changes: 61 additions & 7 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 @@ -417,8 +418,7 @@ describe('useGlossDispatch', () => {

await userEvent.click(screen.getByRole('button', { name: 'write' }));

// tok-1 reads the new 'b'; tok-2 keeps the original 'a' because the reducer forked the
// shared payload before editing.
// tok-1 reads the new 'b'; tok-2 keeps 'a' because editing forked the shared payload.
expect(screen.getByTestId('gloss')).toHaveTextContent('b');
const saved: TextAnalysis = onSave.mock.calls[0][0];
expect(saved.tokenAnalyses).toHaveLength(2);
Expand Down Expand Up @@ -684,6 +684,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 Expand Up @@ -755,8 +794,7 @@ describe('usePhraseDispatch', () => {

await userEvent.click(screen.getByRole('button', { name: 'merge' }));

// A single dispatch means a single save — the intermediate state where tokens belonged to two
// phrases is never observed.
// A single dispatch means a single save — the intermediate two-phrase state is never observed.
expect(onSave).toHaveBeenCalledTimes(1);
const saved: TextAnalysis = onSave.mock.calls[0][0];
expect(saved.phraseAnalysisLinks[0].tokens.map((t) => t.tokenRef)).toStrictEqual([
Expand Down Expand Up @@ -1566,20 +1604,23 @@ describe('useReportGlossEditing', () => {
*
* @param props.tokenRef - Token ref to resolve.
* @param props.surfaceText - The token's surface text.
* @param props.enabled - Whether the hook should derive; `false` short-circuits to `undefined`.
* @param props.sink - Array each render appends its resolved value to (for render-count
* assertions).
* @returns JSX element suitable for passing to `render`.
*/
function ResolvedReader({
tokenRef,
surfaceText,
enabled = true,
sink,
}: Readonly<{
tokenRef: string;
surfaceText: string;
enabled?: boolean;
sink?: (ResolvedTokenAnalysis | undefined)[];
}>) {
const resolved = useResolvedTokenAnalysis(tokenRef, surfaceText);
const resolved = useResolvedTokenAnalysis(tokenRef, surfaceText, enabled);
sink?.push(resolved);
return <span data-testid="resolved">{resolved?.status ?? 'none'}</span>;
}
Expand Down Expand Up @@ -1676,6 +1717,19 @@ describe('useResolvedTokenAnalysis', () => {
expect(screen.getByTestId('resolved')).toHaveTextContent('none');
});

it('short-circuits to undefined without deriving when disabled', () => {
// With enabled=false the hook must not resolve even a token that would otherwise be approved.
render(
<AnalysisStoreProvider
initialAnalysis={makeAnalysisWithGloss('tok-1', 'w', 'logos')}
analysisLanguage="und"
>
<ResolvedReader tokenRef="tok-1" surfaceText="logos" enabled={false} />
</AnalysisStoreProvider>,
);
expect(screen.getByTestId('resolved')).toHaveTextContent('none');
});

it('does not re-render a suggested token when an unrelated token is glossed', async () => {
const sink: (ResolvedTokenAnalysis | undefined)[] = [];
render(
Expand All @@ -1689,8 +1743,8 @@ describe('useResolvedTokenAnalysis', () => {
);
const before = sink.length;

// Glossing 'cat' rebuilds the pool but leaves the 'logos' suggestion untouched; the custom
// equalityFn keeps the selected value referentially stable so the reader never re-renders.
// Glossing 'cat' rebuilds the pool but leaves the 'logos' suggestion referentially stable (via
// the custom equalityFn), so the reader never re-renders.
await userEvent.click(screen.getByRole('button', { name: 'write' }));

expect(sink.length).toBe(before);
Expand Down
Loading
Loading