+
);
}
diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx
index a1b78792..6d45577e 100644
--- a/src/components/InterlinearizerLoader.tsx
+++ b/src/components/InterlinearizerLoader.tsx
@@ -10,9 +10,18 @@ import type { SelectMenuItemHandler } from 'platform-bible-react';
import type { ScriptureRef } from 'interlinearizer';
import { isPlatformError } from 'platform-bible-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { resegmentBook } from 'parsers/papi/resegmentBook';
import useDraftProject from '../hooks/useDraftProject';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting';
+import {
+ isDefaultSegmentation,
+ mergeSegments,
+ moveBoundary,
+ splitSegmentBefore,
+} from '../utils/segmentation';
+import { isWordToken } from '../types/type-guards';
+import type { SegmentationDispatch } from './SegmentationStore';
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
import Interlinearizer from './Interlinearizer';
import ViewOptionsDropdown from './controls/ViewOptionsDropdown';
@@ -22,7 +31,7 @@ import { WipeModal, type WipeScope } from './modals/WipeModal';
import ScriptureNavControls from './controls/ScriptureNavControls';
import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext';
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
-import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref';
+import { segmentContainsVerse, toSerializedVerseRef } from '../utils/verse-ref';
/** Host-injected callback to update this WebView's definition (used to toggle the tab title). */
type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition'];
@@ -135,8 +144,10 @@ function InterlinearizerLoaderInner({
isDraftLoading,
draft,
draftVersion,
+ segmentationVersion,
dirty,
autosaveAnalysis,
+ autosaveSegmentation,
loadFromProject,
newDraft,
getDraftSnapshot,
@@ -232,29 +243,105 @@ function InterlinearizerLoaderInner({
],
);
- const { book, isLoading, bookError, tokenizeError } = useInterlinearizerBookData({
+ const {
+ book: verseBook,
+ isLoading,
+ bookError,
+ tokenizeError,
+ } = useInterlinearizerBookData({
projectId,
scrRef,
});
- // The active reference handed to the interlinearizer, resolved so it always names a verse some
- // segment starts at (when the requested chapter has segments at all). A reference whose verse is
- // a segment start passes through unchanged. Otherwise:
- //
- // - Verse 0 with no verse-0 segment is a plain whole-chapter selection (the host emits
- // `verseNum: 0` for those as well as for superscriptions), so it falls back to the chapter's
- // first numbered verse rather than leaving nothing highlighted.
- // - Any other unmatched verse resolves to the nearest preceding segment start in the same
- // chapter. This covers the host's next-verse button, which emits `verseNum + 1` without
- // clamping — bumping forward from a chapter's last verse delivers a verse past the chapter's
- // end, which must land on the chapter's last segment, not fall through to the views' anchor
- // fallback (the start of the book). It also covers verses inside a multi-verse segment, which
- // resolve to the segment that contains them.
- // - A reference to a chapter with no segments (including any book mismatch during a cross-book
- // swap) passes through unchanged; the views' own fallbacks handle that transient.
+ /**
+ * The book the views render: the verse-tokenized book re-grouped into the user's custom segments.
+ * Identical (by reference) to `verseBook` when no custom boundaries are set, so the common case
+ * incurs no extra work. `verseBook` is retained separately because the segmentation operations
+ * need the default verse boundaries it carries.
+ *
+ * `draft.segmentation` is read fresh from the ref-held draft at recompute time; the deps are the
+ * two version counters that cover every path that can change it — `segmentationVersion` for
+ * boundary edits and `draftVersion` for wholesale replacements (New / Open / Wipe). The draft
+ * object itself is deliberately NOT a dep: gloss auto-saves replace the draft identity on every
+ * write without touching the boundaries, and keying on it would re-run the full-book
+ * re-segmentation (and cascade a fresh `book` identity through every downstream index and memo)
+ * after every gloss edit. `isDraftLoading` covers the one replacement that bumps neither counter:
+ * the initial draft load, which must invalidate a book computed while the stored boundaries were
+ * still in flight.
+ */
+ const book = useMemo(
+ () => (verseBook ? resegmentBook(verseBook, draft?.segmentation) : undefined),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value
+ [verseBook, segmentationVersion, draftVersion, isDraftLoading],
+ );
+
+ /**
+ * Maps each merged-away default verse boundary's word-token split anchor — the verse's first word
+ * token, the ref the boundary slots are keyed by — to the removed default start ref (the verse's
+ * first token of any type, per the delta's `removedVerseStarts`). The two differ when a verse
+ * begins with punctuation (e.g. an opening quote); mapping through the word anchor lets the slot
+ * render the former-boundary tick and dispatch a split that cancels the removal exactly. Deps
+ * mirror the `book` memo above: the version counters cover every path that changes the
+ * segmentation, `isDraftLoading` covers the initial draft load, and the draft identity is
+ * deliberately not a dep.
+ */
+ const formerBoundaries = useMemo>(() => {
+ const map = new Map();
+ const removed = draft?.segmentation?.removedVerseStarts ?? [];
+ if (removed.length === 0 || !verseBook) return map;
+ const removedSet = new Set(removed);
+ verseBook.segments.forEach((verse) => {
+ const startRef = verse.tokens[0]?.ref;
+ if (startRef === undefined || !removedSet.has(startRef)) return;
+ const wordAnchorRef = verse.tokens.find(isWordToken)?.ref;
+ if (wordAnchorRef !== undefined) map.set(wordAnchorRef, startRef);
+ });
+ return map;
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value
+ }, [verseBook, segmentationVersion, draftVersion, isDraftLoading]);
+
+ /**
+ * Boundary-editing operations passed down to the views. Each reads the draft's latest boundary
+ * delta synchronously (so rapid edits compose correctly), applies the relevant pure transform
+ * against the original verse book, and auto-saves the normalized result — clearing the field back
+ * to `undefined` when the edit restores the default verse segmentation.
+ */
+ const segmentationDispatch = useMemo(() => {
+ const apply = (next: ReturnType) => {
+ autosaveSegmentation(isDefaultSegmentation(next) ? undefined : next);
+ };
+ return {
+ merge: (secondSegmentStartRef) => {
+ /* v8 ignore next -- boundary controls only render once the book has loaded */
+ if (!verseBook) return;
+ apply(mergeSegments(verseBook, getDraftSnapshot()?.segmentation, secondSegmentStartRef));
+ },
+ split: (tokenRef) => {
+ /* v8 ignore next -- boundary controls only render once the book has loaded */
+ if (!verseBook) return;
+ apply(splitSegmentBefore(verseBook, getDraftSnapshot()?.segmentation, tokenRef));
+ },
+ move: (fromRef, toRef) => {
+ /* v8 ignore next -- the cross-segment link only renders once the book has loaded */
+ if (!verseBook) return;
+ apply(moveBoundary(verseBook, getDraftSnapshot()?.segmentation, fromRef, toRef));
+ },
+ };
+ }, [verseBook, getDraftSnapshot, autosaveSegmentation]);
+
+ // The active reference handed to the interlinearizer. The host emits `verseNum: 0` both for a
+ // chapter's verse-0 superscription (which has its own segment) and for a plain whole-chapter
+ // selection (which does not). Keep verse 0 when the loaded book actually has a verse-0 segment for
+ // that chapter — so a Psalm superscription becomes the active verse — and otherwise fall back to
+ // the chapter's first numbered verse, so an ordinary chapter selection still lands on verse 1
+ // rather than leaving nothing highlighted. A reference contained in any segment's verse range —
+ // including a verse absorbed mid-segment by a merge — passes through unchanged; the views resolve
+ // it to its containing segment. Only a reference no segment contains at all (the host's
+ // next-verse over-shooting the chapter's end) resolves to the nearest preceding segment start in
+ // the same chapter (falling through unchanged when the chapter has none).
const activeScrRef = useMemo(() => {
if (!book) return scrRef;
- if (book.segments.some((segment) => isSameVerse(segment.startRef, scrRef))) return scrRef;
+ if (book.segments.some((segment) => segmentContainsVerse(segment, scrRef))) return scrRef;
if (scrRef.verseNum === 0) return { ...scrRef, verseNum: 1 };
let preceding: ScriptureRef | undefined;
book.segments.forEach(({ startRef }) => {
@@ -330,8 +417,12 @@ function InterlinearizerLoaderInner({
'interlinearizer.saveAnalysis',
activeProject.id,
JSON.stringify(snapshot.analysis),
+ // Send the draft's boundary state on every Save; `null` clears any stored boundaries so a
+ // reverted segmentation propagates to the project rather than leaving it stale.
+ // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries
+ JSON.stringify(snapshot.segmentation ?? null),
);
- markSynced(snapshot.analysis);
+ markSynced(snapshot.analysis, snapshot.segmentation);
} catch (e) {
logger.error('Interlinearizer: failed to save draft to project', e);
} finally {
@@ -514,6 +605,9 @@ function InterlinearizerLoaderInner({
setPhraseMode={setPhraseMode}
viewOptions={viewOptions}
showSuggestions={showSuggestions}
+ segmentationDispatch={segmentationDispatch}
+ formerBoundaries={formerBoundaries}
+ segmentationVersion={segmentationVersion}
/>
)}
diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx
index 98b01c3b..03ea80ce 100644
--- a/src/components/PhraseBox.tsx
+++ b/src/components/PhraseBox.tsx
@@ -104,6 +104,13 @@ type PhraseBoxProps = Readonly<{
* the view. All fragments of that phrase receive the highlighted style simultaneously.
*/
isHighlighted?: boolean;
+ /**
+ * When `true`, this box's tokens are part of a hovered operation preview — a link icon or a
+ * boundary merge/split control whose candidate tokens would join into one phrase/segment or break
+ * off into a new segment. Renders the strong `phrase-candidate` outline, visually distinct from
+ * (and taking precedence over) the ordinary hover and focus styles.
+ */
+ isCandidate?: boolean;
/**
* Token refs that would become free (solo) if the currently hovered split/unlink button were
* clicked. Each matching chip renders with a destructive border as a preview; when every token in
@@ -152,6 +159,8 @@ type PhraseBoxProps = Readonly<{
* sets for hovered fragment only)
* @param props.isHighlighted - When true, all fragments of the hovered/focused phrase receive the
* highlighted border style simultaneously
+ * @param props.isCandidate - When true, this box's tokens are part of a hovered operation preview
+ * (link or boundary merge/split) and render the strong candidate outline
* @param props.splitFreeTokenRefs - Token refs that would become free after a hovered split; each
* matching chip renders with a destructive border as a preview
* @param props.punctuationBetween - Punctuation tokens between adjacent word tokens, in document
@@ -161,6 +170,7 @@ type PhraseBoxProps = Readonly<{
export function PhraseBox({
isFocused = false,
isHighlighted = false,
+ isCandidate = false,
splitFreeTokenRefs,
punctuationBetween,
groupKey,
@@ -333,6 +343,9 @@ export function PhraseBox({
if (phraseMode.kind === 'view') {
const viewBorderClass = (() => {
if (isBoxSplitFree) return 'tw:phrase-destructive';
+ // Candidate previews outrank focus/hover: while a preview is hovered, showing what the
+ // operation affects matters more than showing what is focused.
+ if (isCandidate) return 'tw:phrase-candidate';
if (isFocused) return 'tw:phrase-focused';
if (isHighlighted) return 'tw:phrase-hovered';
return 'tw:phrase-dimmed';
diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx
index e0a7fb05..7e2d4fc6 100644
--- a/src/components/PhraseStripContext.tsx
+++ b/src/components/PhraseStripContext.tsx
@@ -40,7 +40,10 @@ export type PhraseStripContextValue = Readonly<{
* function for them.
*/
onHoverPhrase: (phraseId: string | undefined) => void;
- /** Called with the candidate token refs (or `undefined`) when a link icon is hovered. */
+ /**
+ * Called with the candidate token refs (or `undefined`) when a link icon or a boundary
+ * merge/split control is hovered; the affected groups render the strong candidate preview style.
+ */
onHoverCandidateTokens: (refs: readonly string[] | undefined) => void;
/** Called with the would-be-free token refs (or `undefined`) when a split/unlink icon is hovered. */
onHoverSplitFreeTokens: (refs: readonly string[] | undefined) => void;
diff --git a/src/components/PhraseStripParts.tsx b/src/components/PhraseStripParts.tsx
index e1efc91a..2c91de70 100644
--- a/src/components/PhraseStripParts.tsx
+++ b/src/components/PhraseStripParts.tsx
@@ -1,13 +1,238 @@
/** @file Shared render parts for the two phrase strips (SegmentView and ContinuousView). */
+import { useLocalizedStrings } from '@papi/frontend/react';
+import { FoldHorizontal, Scissors } from 'lucide-react';
import { memo } from 'react';
+import type { ReactNode } from 'react';
import MemoizedPhraseBox from './PhraseBox';
import type { PhraseMode } from '../types/phrase-mode';
import { usePhraseStripContext } from './PhraseStripContext';
+import { useSegmentation } from './SegmentationStore';
import { InertTokenChip } from './TokenChip';
import MemoizedTokenLinkIcon from './TokenLinkIcon';
import type { FocusContext, LinkSlot, TokenGroup } from '../types/token-layout';
+import { isWordToken } from '../types/type-guards';
import { resolveSlotFocus } from '../utils/token-layout';
+/** Localized labels for the merge/split boundary controls; hoisted so the array reference is stable. */
+const BOUNDARY_STRING_KEYS = [
+ '%interlinearizer_boundaryControl_merge%',
+ '%interlinearizer_boundaryControl_split%',
+ '%interlinearizer_boundaryControl_formerBoundary%',
+] as const satisfies `%${string}%`[];
+
+/** Props for {@link BoundaryButton}. */
+type BoundaryButtonProps = Readonly<{
+ /** Accessible label, doubling as the tooltip. */
+ label: string;
+ /** `data-testid` for the button element. */
+ testId: string;
+ /** The icon rendered inside the button. */
+ icon: ReactNode;
+ /** When `true` the control renders inert; used while a phrase mode (edit / unlink) is active. */
+ disabled: boolean;
+ /**
+ * Lazily computes the word-token refs the operation would affect. Called on hover (not render),
+ * so mounting hundreds of slots does no per-render array building.
+ *
+ * @returns The refs to highlight as the operation's preview.
+ */
+ getPreviewRefs: () => readonly string[];
+ /** The boundary edit to run on click. */
+ action: () => void;
+}>;
+
+/**
+ * One boundary-edit button (merge or split): shared markup, hover-preview wiring, and click
+ * cleanup. Entering highlights the refs from `getPreviewRefs` through the shared candidate-token
+ * channel (the same one the link icon uses), leaving clears, and click clears synchronously before
+ * running the edit so the highlight can't linger over the re-segmented content.
+ *
+ * @param props - Component props.
+ * @param props.label - Accessible label and tooltip.
+ * @param props.testId - `data-testid` for the button element.
+ * @param props.icon - The icon rendered inside the button.
+ * @param props.disabled - Renders the control inert while a phrase mode is active.
+ * @param props.getPreviewRefs - Lazily computes the refs the operation would affect.
+ * @param props.action - The boundary edit to run on click.
+ * @returns The styled boundary button.
+ */
+function BoundaryButton({
+ label,
+ testId,
+ icon,
+ disabled,
+ getPreviewRefs,
+ action,
+}: BoundaryButtonProps) {
+ const { onHoverCandidateTokens } = usePhraseStripContext();
+ return (
+
+ );
+}
+
+/** Props for {@link BoundaryControl}. */
+type BoundaryControlProps = Readonly<{
+ /** Segment id of the group before the slot, or `undefined` for the leading slot. */
+ prevSegmentId: string | undefined;
+ /** Segment id of the group after the slot, or `undefined` for the trailing slot. */
+ nextSegmentId: string | undefined;
+ /**
+ * Last word token before the slot, or `undefined` for a leading slot. A slot with no word token
+ * before it sits on an existing segment start, where a split would be a no-op, so no control
+ * renders there.
+ */
+ prevTokenRef: string | undefined;
+ /** First word token after the slot, used as the split anchor. */
+ nextTokenRef: string | undefined;
+}>;
+
+/**
+ * Renders the boundary-edit control for one slot, always visible (not hover-gated). A slot
+ * straddling two different segments shows a merge control (combine the next segment into the
+ * previous one); a slot inside one segment shows a split control (start a new segment at the next
+ * token). Leading/trailing slots (a word token missing on either side) render nothing — a leading
+ * slot sits on an existing segment start, where a split would be a no-op.
+ *
+ * Hovering either control previews its effect through the shared candidate-token highlight (the
+ * same channel the link icon uses): merge highlights every word token of both segments (they become
+ * one segment); split highlights the word tokens from the split anchor to the segment end (the run
+ * that breaks off into the new segment). The preview is cleared on leave and synchronously on click
+ * so it can't linger over the re-segmented content.
+ *
+ * An intra-segment slot sitting on a merged-away default verse start additionally renders a faint
+ * dashed tick — the former verse boundary — so the user can see where a split would restore the
+ * original segmentation. The tick shows even when the split control itself is suppressed by the
+ * not-mid-phrase guard, since it is informational rather than interactive.
+ *
+ * The not-mid-phrase rule is a UI guard applied here: no split control renders at a boundary that
+ * would cut a phrase — including the gap between two fragments of a discontiguous phrase. (The
+ * segmentation dispatch itself accepts such boundaries and force-breaks the straddled phrases; only
+ * callers that cannot see token chunks take that path.) Merge needs no such guard: removing a
+ * boundary can never leave a phrase straddling one.
+ *
+ * Both controls render disabled while a phrase mode (edit / confirm-unlink) is active, matching the
+ * link icons: a boundary edit mid-mode could re-segment the phrase the mode UI is operating on
+ * (e.g. canceling an edit would then restore a phrase spanning the new boundary).
+ *
+ * @param props - Component props.
+ * @param props.prevSegmentId - Segment id before the slot.
+ * @param props.nextSegmentId - Segment id after the slot.
+ * @param props.prevTokenRef - Last word token before the slot.
+ * @param props.nextTokenRef - First word token after the slot (split anchor).
+ * @returns A merge or split button (with an optional former-boundary tick), or `undefined` when the
+ * slot is at a segment or book edge or nothing applies at this boundary.
+ */
+function BoundaryControl({
+ prevSegmentId,
+ nextSegmentId,
+ prevTokenRef,
+ nextTokenRef,
+}: BoundaryControlProps) {
+ const { dispatch, segmentById, formerBoundaries, straddledBoundaryRefs } = useSegmentation();
+ const { phraseMode } = usePhraseStripContext();
+ const [localizedStrings] = useLocalizedStrings(BOUNDARY_STRING_KEYS);
+ if (
+ prevSegmentId === undefined ||
+ nextSegmentId === undefined ||
+ prevTokenRef === undefined ||
+ nextTokenRef === undefined
+ ) {
+ return undefined;
+ }
+
+ const boundaryEditsDisabled = phraseMode.kind !== 'view';
+
+ const control = (() => {
+ if (prevSegmentId !== nextSegmentId) {
+ const nextSegment = segmentById.get(nextSegmentId);
+ const secondStart = nextSegment?.tokens[0]?.ref;
+ /* v8 ignore next -- a rendered cross-segment slot always resolves the next segment's start */
+ if (nextSegment === undefined || secondStart === undefined) return undefined;
+ return (
+ }
+ disabled={boundaryEditsDisabled}
+ // Merging joins every token of both segments into one; preview exactly that.
+ getPreviewRefs={() =>
+ [
+ /* v8 ignore next -- a rendered cross-segment slot's previous segment is always mapped */
+ ...(segmentById.get(prevSegmentId)?.tokens ?? []),
+ ...nextSegment.tokens,
+ ]
+ .filter(isWordToken)
+ .map((t) => t.ref)
+ }
+ action={() => dispatch.merge(secondStart)}
+ />
+ );
+ }
+ // The not-mid-phrase UI guard: no split control at a boundary that would cut a phrase.
+ if (straddledBoundaryRefs.has(nextTokenRef)) return undefined;
+ // A split on a former boundary dispatches the original removed default start — which may be a
+ // leading punctuation token no word-anchored slot could name — so the restore cancels the
+ // removal exactly and the delta can normalize back to the default segmentation.
+ const splitRef = formerBoundaries.get(nextTokenRef) ?? nextTokenRef;
+ return (
+ }
+ disabled={boundaryEditsDisabled}
+ // Splitting breaks the run from the anchor to the segment end off into a new segment;
+ // preview exactly that run.
+ getPreviewRefs={() => {
+ const segmentTokens = segmentById.get(nextSegmentId)?.tokens ?? [];
+ const anchorIndex = segmentTokens.findIndex((t) => t.ref === nextTokenRef);
+ /* v8 ignore next -- an intra-segment slot's anchor is always a token of its segment */
+ if (anchorIndex === -1) return [];
+ return segmentTokens
+ .slice(anchorIndex)
+ .filter(isWordToken)
+ .map((t) => t.ref);
+ }}
+ action={() => dispatch.split(splitRef)}
+ />
+ );
+ })();
+
+ // The former-boundary tick: an intra-segment slot sitting on a merged-away default verse start.
+ const formerBoundaryMarker =
+ prevSegmentId === nextSegmentId && formerBoundaries.has(nextTokenRef) ? (
+
+ ) : undefined;
+
+ if (control === undefined && formerBoundaryMarker === undefined) return undefined;
+ return (
+
+ {formerBoundaryMarker}
+ {control}
+
+ );
+}
+
/**
* Duration, in milliseconds, of the link-slot opacity fade transition. Exported so `ContinuousView`
* can re-center the focused phrase for exactly this long after `committedActiveSegmentId` flips,
@@ -37,10 +262,10 @@ type PhraseSlotProps = Readonly<{
}>;
/**
- * Renders one between-group slot: the link/unlink icon plus any punctuation tokens that sit in the
- * gap. Pure — both views feed it identical inputs so the slot renders the same in either layout.
- * The link icon's phrase mode, document-order lookup, and hover callbacks come from
- * {@link PhraseStripContext}.
+ * Renders one between-group slot: the link/unlink icon, the boundary merge/split control, plus any
+ * punctuation tokens that sit in the gap. Pure — both views feed it identical inputs so the slot
+ * renders the same in either layout. The link icon's phrase mode, document-order lookup, and hover
+ * callbacks come from {@link PhraseStripContext}.
*
* @param props - Component props
* @param props.slot - The slot's neighboring groups and gap punctuation
@@ -61,6 +286,7 @@ export function PhraseSlot({
hoveredPhraseId,
}: PhraseSlotProps) {
const { hideInactiveLinkButtons, activeSegmentId, skipLinkTransition } = usePhraseStripContext();
+ const { segmentOrder } = useSegmentation();
const { prevGroup, nextGroup, punctuation } = slot;
if (!prevGroup && !nextGroup && punctuation.length === 0) return undefined;
const prevToken = prevGroup?.tokens[prevGroup.tokens.length - 1];
@@ -71,7 +297,13 @@ export function PhraseSlot({
prevPhraseId !== undefined &&
prevPhraseId === nextPhraseId &&
(prevPhraseId === hoveredPhraseId || prevPhraseId === focus.focusedPhraseId);
- const slotFocus = resolveSlotFocus(prevSegmentId, nextSegmentId, focus, focusedSideIsPrev);
+ const slotFocus = resolveSlotFocus(
+ prevSegmentId,
+ nextSegmentId,
+ focus,
+ focusedSideIsPrev,
+ segmentOrder,
+ );
// The slot is "in the active segment" only when both neighboring phrases belong to it. A link
// that crosses a verse boundary (one side in the active verse, the other in an adjacent verse) is
// therefore treated as inactive and hidden too. When hideInactiveLinkButtons is on, link buttons
@@ -90,27 +322,35 @@ export function PhraseSlot({
style={{ overflowAnchor: 'none' }}
>
{hasLinkableNeighbors && (
-
-
+
+
+
+
-
+ >
)}
{punctuation.length > 0 && (
@@ -135,6 +375,8 @@ type PhraseGroupProps = Readonly<{
isFocused: boolean;
/** Whether this group should render highlighted (computed by the parent). */
isHighlighted: boolean;
+ /** Whether this group's tokens are part of a hovered operation preview (computed by the parent). */
+ isCandidate: boolean;
/** Token refs that would become free after a hovered split/unlink (computed by the parent). */
splitFreeTokenRefs: ReadonlySet;
/** Whether the edit/unlink controls pill should show above this group. */
@@ -178,6 +420,7 @@ type PhraseGroupProps = Readonly<{
* @param props.group - The phrase group to render
* @param props.isFocused - Whether this group is the navigation focus
* @param props.isHighlighted - Whether this group renders highlighted
+ * @param props.isCandidate - Whether this group's tokens are part of a hovered operation preview
* @param props.splitFreeTokenRefs - Token refs in this group that preview as becoming free
* @param props.showControls - Whether to show the controls pill
* @param props.showGlossInput - Whether to show the gloss input
@@ -194,6 +437,7 @@ export const MemoizedPhraseGroup = memo(function PhraseGroup({
group,
isFocused,
isHighlighted,
+ isCandidate,
splitFreeTokenRefs,
showControls,
showGlossInput,
@@ -231,6 +475,7 @@ export const MemoizedPhraseGroup = memo(function PhraseGroup({
;
/** Token refs that would become free after a hovered split/unlink. */
splitFreeTokenRefs: ReadonlySet;
@@ -321,7 +566,7 @@ type PhraseStripProps = Readonly<{
* @param props.focus - Resolved focus context
* @param props.hoveredPhraseId - PhraseId hovered anywhere in the view
* @param props.hoveredGroupKey - Group key of the hovered phrase box
- * @param props.candidateTokenRefs - Token refs a hovered link would join
+ * @param props.candidateTokenRefs - Token refs a hovered link or boundary control would affect
* @param props.splitFreeTokenRefs - Token refs that would become free after a hovered split
* @param props.onHoverPhrase - Phrase-box enter/leave callback
* @param props.setHoveredGroupKey - Hovered-group-key setter
@@ -365,11 +610,15 @@ export function PhraseStrip({
// controls follow the usual hover rules on any phrase.
const phraseControlsAllowed =
!simplifyPhrases || (phraseId !== undefined && phraseId === focus.focusedPhraseId);
+ // Candidate tokens are a hovered operation preview (link icon or boundary merge/split); their
+ // groups render the strong candidate tier — distinct from the hover/focus highlight — and the
+ // preview never reveals a phrase's edit controls the way a hover does.
+ const isCandidate =
+ phraseMode.kind === 'view' && group.tokens.some((t) => candidateTokenRefs.has(t.ref));
const isHighlighted = (() => {
if (phraseMode.kind === 'view') {
if (phraseId !== undefined && phraseId === hoveredPhraseId) return true;
if (phraseId !== undefined && phraseId === focus.focusedPhraseId) return true;
- if (group.tokens.some((t) => candidateTokenRefs.has(t.ref))) return true;
return false;
}
return phraseId !== undefined && phraseId === phraseMode.phraseId;
@@ -380,6 +629,7 @@ export function PhraseStrip({
group={group}
isFocused={item.isFocused}
isHighlighted={isHighlighted}
+ isCandidate={isCandidate}
splitFreeTokenRefs={
phraseControlsAllowed && phraseMode.kind === 'view'
? splitFreeTokenRefs
diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx
index 3786581e..91635fb0 100644
--- a/src/components/SegmentListView.tsx
+++ b/src/components/SegmentListView.tsx
@@ -1,21 +1,100 @@
+import { useLocalizedStrings } from '@papi/frontend/react';
import type { SerializedVerseRef } from '@sillsdev/scripture';
-import type { Book, ScriptureRef, Token } from 'interlinearizer';
-import { LocateFixed } from 'lucide-react';
-import { Fragment, useCallback, useEffect, useMemo, useRef } from 'react';
+import type { Book, ScriptureRef, Segment, Token } from 'interlinearizer';
+import { FoldVertical, LocateFixed } from 'lucide-react';
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { PhraseMode } from '../types/phrase-mode';
import type { ViewOptions } from '../types/view-options';
+import { useSegmentation } from './SegmentationStore';
import MemoizedSegmentView from './SegmentView';
import useSegmentWindow from '../hooks/useSegmentWindow';
-import { isSameVerse } from '../utils/verse-ref';
+import { buildSegmentLabels } from '../utils/segment-labels';
+import { segmentContainsVerse } from '../utils/verse-ref';
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
+/** Localized label for the between-rows merge control; hoisted so the array reference is stable. */
+const MERGE_STRING_KEYS = [
+ '%interlinearizer_boundaryControl_merge%',
+] as const satisfies `%${string}%`[];
+
+/** Props for {@link MergeRowButton}. */
+type MergeRowButtonProps = Readonly<{
+ /** The segment below the gap this button sits in — the one a click merges into its predecessor. */
+ segment: Segment;
+ /**
+ * When `true` the control renders inert. Set while a phrase mode (edit / confirm-unlink) is
+ * active, matching the in-gap boundary controls: a merge mid-mode could re-segment the phrase the
+ * mode UI is operating on.
+ */
+ disabled: boolean;
+ /**
+ * Reports hover over this button: the segment's id on enter, `undefined` on leave. The list uses
+ * it to tint the two rows the merge would join.
+ */
+ onHoverChange: (segmentId: string | undefined) => void;
+}>;
+
+/**
+ * The merge control rendered in the gap between two adjacent segment rows. Clicking it merges the
+ * lower segment into the one above — the segment-list counterpart of the merge control in the
+ * continuous strip's cross-segment slots. Always mounted (not hover-gated) so the row gap is the
+ * only boundary affordance in this view, and it doubles as the undo for a split.
+ *
+ * The button spans the full row width so the whole gap is a click target, with the fold glyph
+ * centered in it. Hovering previews the merge two ways: the button itself tints across its full
+ * width (a band bridging the gap), and the hover is reported to the list, which outlines and tints
+ * the two rows a click would join. The hover is cleared synchronously on click so the preview can't
+ * linger over the merged content.
+ *
+ * @param props - Component props.
+ * @param props.segment - The segment below the gap.
+ * @param props.disabled - Renders the control inert while a phrase mode is active.
+ * @param props.onHoverChange - Reports hover so the list can tint the two affected rows.
+ * @returns A full-width merge-boundary button, or `undefined` when the segment has no tokens.
+ */
+function MergeRowButton({ segment, disabled, onHoverChange }: MergeRowButtonProps) {
+ const { dispatch } = useSegmentation();
+ const [localizedStrings] = useLocalizedStrings(MERGE_STRING_KEYS);
+ const secondSegmentStartRef = segment.tokens[0]?.ref;
+ /* v8 ignore next -- a rendered segment always has at least one token */
+ if (secondSegmentStartRef === undefined) return undefined;
+ const mergeLabel = localizedStrings['%interlinearizer_boundaryControl_merge%'];
+ return (
+
+ );
+}
+
/** Props for {@link SegmentListView}. */
type SegmentListViewProps = Readonly<{
/** Tokenized book whose segments are windowed and rendered. */
book: Book;
/** Current scripture reference; its verse is the recenter anchor and active-verse highlight. */
scrRef: SerializedVerseRef;
+ /**
+ * Monotonic counter bumped on every boundary edit. Forwarded to {@link useSegmentWindow} so it can
+ * tell a boundary edit (redraw in place) apart from a re-tokenization of the loaded book
+ * (recenter with a fade) when the segments identity changes.
+ */
+ segmentationVersion: number;
/** Token ref of the currently focused word token, or `undefined` when nothing is focused. */
focusedTokenRef: string | undefined;
/** When true, the horizontal token strip is shown above this list (changes display mode). */
@@ -72,6 +151,7 @@ type SegmentListViewProps = Readonly<{
* @param props - Component props
* @param props.book - Tokenized book whose segments are windowed and rendered.
* @param props.scrRef - Current scripture reference; its verse is the recenter anchor.
+ * @param props.segmentationVersion - Monotonic boundary-edit counter forwarded to the window.
* @param props.focusedTokenRef - Token ref of the currently focused word token, or `undefined`.
* @param props.continuousScroll - When true, the horizontal token strip is shown above this list.
* @param props.displayContinuousScroll - Continuous-scroll mode the segments actually render; owned
@@ -96,6 +176,7 @@ type SegmentListViewProps = Readonly<{
export default function SegmentListView({
book,
scrRef,
+ segmentationVersion,
focusedTokenRef,
continuousScroll,
displayContinuousScroll,
@@ -134,6 +215,13 @@ export default function SegmentListView({
return ids;
}, [book.segments]);
+ /**
+ * Verse-based display label of every segment (bare verse, lettered split portion, or verse
+ * range), keyed by segment id. Computed over the whole `book.segments` list (not just the mounted
+ * window) so the lettering is stable regardless of which slice happens to be mounted.
+ */
+ const segmentLabels = useMemo(() => buildSegmentLabels(book.segments), [book.segments]);
+
const scrollContainerRef = useRef(undefined);
/**
@@ -160,6 +248,7 @@ export default function SegmentListView({
} = useSegmentWindow({
book,
scrRef,
+ segmentationVersion,
focusedTokenRef,
continuousScroll,
scrollContainerRef,
@@ -191,6 +280,13 @@ export default function SegmentListView({
? tokenSegmentMap.get(displayFocusedTokenRef)
: undefined;
+ /**
+ * Id of the lower segment of the row gap whose merge button is hovered, or `undefined` when none
+ * is. The hovered gap's two adjacent rows (this segment and its predecessor) render outlined and
+ * tinted so it is visible which rows a click would join.
+ */
+ const [mergeHoverSegmentId, setMergeHoverSegmentId] = useState(undefined);
+
return (
- {windowSegments.map((seg) => (
-
- {!chapterLabelInVerse && chapterStartIds.has(seg.id) && (
-
- {`Chapter ${seg.startRef.chapter}`}
-
- )}
-
-
- ))}
+ {windowSegments.map((seg, segIndex) => {
+ /* v8 ignore next 2 -- the ?? arm is a defensive fallback for the Map.get type: every
+ windowed segment comes from book.segments, so the lookup always resolves */
+ const label = segmentLabels.get(seg.id) ?? '';
+ return (
+
+ {segIndex > 0 && (
+
+ )}
+ {!chapterLabelInVerse && chapterStartIds.has(seg.id) && (
+
+ {`Chapter ${seg.startRef.chapter}`}
+
+ )}
+
+
+
+
+ );
+ })}
diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx
index b0be9723..e37a249b 100644
--- a/src/components/SegmentView.tsx
+++ b/src/components/SegmentView.tsx
@@ -13,6 +13,7 @@ import {
import type { PhraseMode } from '../types/phrase-mode';
import type { ViewOptions } from '../types/view-options';
import type { RenderUnit } from '../types/token-layout';
+import type { SegmentLabel } from '../utils/segment-labels';
import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/token-layout';
import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore';
import MemoizedArcOverlay from './ArcOverlay';
@@ -60,6 +61,12 @@ type SegmentViewProps = Readonly<{
onSelect: (ref: ScriptureRef, tokenRef?: string) => void;
/** The segment to render. */
segment: Segment;
+ /**
+ * The segment's verse-based display label (e.g. `"5"`, `"5a"`, `"5b–7"`), computed by the list
+ * over the whole book (letters depend on how neighboring segments divide a verse, which this
+ * component cannot see from its own segment alone).
+ */
+ label: SegmentLabel;
/** Current phrase-interaction mode; controls token click behavior and disabled state. */
phraseMode: PhraseMode;
/** Setter for `phraseMode`; passed to phrase boxes so they can transition modes. */
@@ -100,6 +107,8 @@ type SegmentViewProps = Readonly<{
* the segment's first word token; in `token-chip` mode only word tokens trigger this callback
* with the clicked token. `tokenRef` is omitted only when the segment has no word token.
* @param props.segment - The segment to render
+ * @param props.label - The segment's verse-based display label, computed by the list over the whole
+ * book.
* @param props.phraseMode - Current phrase-interaction mode
* @param props.setPhraseMode - Setter for `phraseMode`
* @param props.hoveredPhraseId - PhraseId currently hovered anywhere in the interlinearizer
@@ -122,6 +131,7 @@ export function SegmentView({
isActive,
onSelect,
segment,
+ label,
phraseMode,
setPhraseMode,
hoveredPhraseId,
@@ -175,11 +185,19 @@ export function SegmentView({
? 'tw:w-full tw:rounded tw:border tw:border-border tw:bg-muted/50 tw:p-2'
: 'tw:w-full tw:rounded tw:border tw:border-transparent tw:p-2 tw:transition-colors tw:hover:bg-muted/30';
- // When chapter info is folded into the verse label, every verse reads `chapter:verse`; otherwise
- // it stays a bare verse number (the chapter is carried by SegmentListView's inline header).
- const verseLabelText = chapterLabelInVerse ? `${chapter}:${verse}` : `${verse}`;
-
- const segmentHeader = {verseLabelText};
+ // The label is verse-based: bare verse number for a whole verse, lettered portions for a split
+ // verse, and a range for a multi-verse segment. When chapter info is folded into the label it
+ // reads `chapter:label`; the chapter qualifies the label's start, and a chapter-crossing range
+ // already carries the end's chapter (`29–2:1` → `1:29–2:1`), so no chapter is duplicated.
+ // Otherwise the label stays bare (the chapter is carried by SegmentListView's inline header).
+ const verseLabelText = chapterLabelInVerse ? `${chapter}:${label}` : label;
+
+ // normal-case overrides section-label's uppercase so split-portion letters render as the
+ // lowercase 1a/1b the label scheme specifies (the label is digits and letters only, so nothing
+ // else relied on the uppercasing).
+ const segmentHeader = (
+ {verseLabelText}
+ );
/**
* `false` until just after the first paint, then `true`. Gates the link-slot fade transition: the
diff --git a/src/components/SegmentationStore.tsx b/src/components/SegmentationStore.tsx
new file mode 100644
index 00000000..ac85c73a
--- /dev/null
+++ b/src/components/SegmentationStore.tsx
@@ -0,0 +1,118 @@
+/**
+ * @file Render-scoped context exposing segment-boundary editing to the deep leaves that trigger it
+ * (the cross-segment link icon and the merge/split boundary controls).
+ *
+ * The {@link SegmentationDispatch} closes over the draft's current boundary delta and the original
+ * verse-tokenized book, applying the pure transforms in `utils/segmentation.ts` and auto-saving
+ * the result. Boundary edits flow draft → re-segmentation → new `book.segments`, so consumers
+ * only need to call a dispatch method; they never see the delta itself.
+ */
+import type { Segment } from 'interlinearizer';
+import { createContext, useContext } from 'react';
+import type { ReactNode } from 'react';
+
+/** The boundary-editing operations available to leaf controls. Each one auto-saves the result. */
+export type SegmentationDispatch = Readonly<{
+ /**
+ * Merges the segment that begins at `secondSegmentStartRef` into the segment before it.
+ *
+ * @param secondSegmentStartRef - First-token ref of the segment to merge into its predecessor.
+ */
+ merge: (secondSegmentStartRef: string) => void;
+ /**
+ * Splits a segment so a new one begins at `tokenRef`.
+ *
+ * @param tokenRef - The token ref the new segment should begin at.
+ */
+ split: (tokenRef: string) => void;
+ /**
+ * Moves a boundary from `fromRef` to `toRef` — used to pull a single edge token across a segment
+ * boundary when a cross-segment phrase link is made.
+ *
+ * @param fromRef - The current segment-start ref to remove.
+ * @param toRef - The new segment-start ref to add.
+ */
+ move: (fromRef: string, toRef: string) => void;
+}>;
+
+/** The strip-wide segmentation context: the dispatch plus the lookups its call sites need. */
+export type SegmentationContextValue = Readonly<{
+ /** Boundary-editing operations. */
+ dispatch: SegmentationDispatch;
+ /** Segment id → segment, used to resolve a segment's first-token start ref. */
+ segmentById: ReadonlyMap;
+ /** Segment id → its index in document order, used to test segment adjacency. */
+ segmentOrder: ReadonlyMap;
+ /**
+ * Maps each merged-away default verse boundary's word-token split anchor (the verse's first word
+ * token) to the removed default start ref. The slots sitting on these anchors render a faint
+ * former-boundary tick so the original segmentation stays visible, and a split there dispatches
+ * the mapped ref so the restore cancels the removal exactly — even when the verse begins with
+ * punctuation, whose ref no word-anchored slot could otherwise name.
+ *
+ * This map exists to bridge two anchor conventions: `SegmentationDelta.removedVerseStarts`
+ * anchors on a verse's leading token of **any** type (the default starts in
+ * `utils/segmentation`), while the boundary slots are keyed by word tokens. The two diverge only
+ * when a verse begins with punctuation; this lookup reconciles them at that seam. If the delta
+ * ever anchors removed starts on word tokens too, this map and its
+ * loader→context→`BoundaryControl` plumbing can be removed.
+ */
+ formerBoundaries: ReadonlyMap;
+ /**
+ * Word-token refs where placing a segment boundary would cut a phrase. The split control
+ * suppresses itself at these refs (the not-mid-phrase UI guard); the segmentation dispatch itself
+ * accepts such boundaries and force-breaks the straddled phrases.
+ */
+ straddledBoundaryRefs: ReadonlySet;
+}>;
+
+/** No-op dispatch used as the default outside a provider (e.g. in isolated component tests). */
+export const NO_OP_SEGMENTATION_DISPATCH: SegmentationDispatch = {
+ merge: () => {},
+ split: () => {},
+ move: () => {},
+};
+
+/**
+ * Default context for components rendered without a {@link SegmentationProvider}: the dispatch is
+ * inert and the lookups are empty. Lets `SegmentView` / `ContinuousView` / `TokenLinkIcon` be
+ * unit-tested in isolation without wiring a provider, while the real app always supplies one.
+ */
+const DEFAULT_VALUE: SegmentationContextValue = {
+ dispatch: NO_OP_SEGMENTATION_DISPATCH,
+ segmentById: new Map(),
+ segmentOrder: new Map(),
+ formerBoundaries: new Map(),
+ straddledBoundaryRefs: new Set(),
+};
+
+const SegmentationContext = createContext(undefined);
+
+/** Props for {@link SegmentationProvider}. */
+type SegmentationProviderProps = Readonly<{
+ /** The segmentation context value; callers should memoize it to preserve leaf memoization. */
+ value: SegmentationContextValue;
+ /** The subtree that can edit segment boundaries. */
+ children: ReactNode;
+}>;
+
+/**
+ * Provides the {@link SegmentationContextValue} to the interlinear views beneath it.
+ *
+ * @param props - Component props.
+ * @param props.value - The segmentation context value.
+ * @param props.children - The subtree.
+ * @returns The children wrapped in the context provider.
+ */
+export function SegmentationProvider({ value, children }: SegmentationProviderProps) {
+ return {children};
+}
+
+/**
+ * Reads the segmentation context, falling back to an inert default when no provider is present.
+ *
+ * @returns The current {@link SegmentationContextValue}, or an inert default outside a provider.
+ */
+export function useSegmentation(): SegmentationContextValue {
+ return useContext(SegmentationContext) ?? DEFAULT_VALUE;
+}
diff --git a/src/components/TokenLinkIcon.tsx b/src/components/TokenLinkIcon.tsx
index e179787b..692367a4 100644
--- a/src/components/TokenLinkIcon.tsx
+++ b/src/components/TokenLinkIcon.tsx
@@ -4,7 +4,9 @@ import { Link2, Link2Off } from 'lucide-react';
import { memo, useCallback } from 'react';
import { usePhraseDispatch } from './AnalysisStore';
import { usePhraseStripContext } from './PhraseStripContext';
+import { useSegmentation } from './SegmentationStore';
import type { SlotFocusInfo } from '../types/token-layout';
+import { isWordToken } from '../types/type-guards';
import { computeSplitFreeRefs, sortByDocOrder, splitPhraseAtBoundary } from '../utils/phrase-arc';
/** Props for {@link TokenLinkIcon}. */
@@ -65,17 +67,24 @@ export function TokenLinkIcon({
slotFocus,
isPhraseRevealed,
}: TokenLinkIconProps) {
- const { focusedSideIsPrev, focusedPhraseLink, focusedFreeToken, isSameSegmentAsFocus } =
- slotFocus;
+ const {
+ focusedSideIsPrev,
+ focusedPhraseLink,
+ focusedFreeToken,
+ isSameSegmentAsFocus,
+ isAdjacentEdgeOfFocus,
+ } = slotFocus;
const {
phraseMode,
tokenDocOrder,
+ tokenSegmentMap,
onHoverPhrase: onHoverCandidatePhrase,
onHoverCandidateTokens,
onHoverSplitFreeTokens,
crossSegmentLinkTooltip,
} = usePhraseStripContext();
const { createPhrase, updatePhrase, deletePhrase, mergePhrases } = usePhraseDispatch();
+ const { dispatch: segmentationDispatch, segmentById } = useSegmentation();
const inSamePhrase =
prevPhraseLink !== undefined &&
@@ -106,6 +115,64 @@ export function TokenLinkIcon({
tokenDocOrder,
]);
+ /**
+ * Moves the segment boundary at this slot so the pulled edge token joins the focused token's
+ * segment, when this is a cross-segment adjacent-edge link. The pulled token is the neighbor on
+ * the far side of the slot; moving the boundary by one token keeps both segments contiguous.
+ *
+ * `focusedSideIsPrev = true`: focus is the previous (left) segment; `nextToken` is the adjacent
+ * segment's first word, so the boundary moves forward to the token after it. `false`: focus is
+ * the next (right) segment; `prevToken` is the previous segment's last word, so the boundary
+ * moves back to start at it.
+ *
+ * The segment whose start ref is being moved is always resolved via `nextToken.ref`: in the
+ * `true` case that is the adjacent (right) segment whose boundary moves forward; in the `false`
+ * case `nextToken` sits in the _focused_ segment, whose start moves back to include `prevToken`.
+ * Hence the neutral name `boundarySegment` rather than "adjacent" — it names whichever segment
+ * owns the boundary being moved, which is not always the non-focused neighbor.
+ */
+ const performBoundaryPull = useCallback(() => {
+ /* v8 ignore next -- only invoked from handleLinkClick after the same defined-token guards */
+ if (!prevToken || !nextToken) return;
+ const boundarySegmentId = tokenSegmentMap.get(nextToken.ref);
+ const boundarySegment =
+ boundarySegmentId === undefined ? undefined : segmentById.get(boundarySegmentId);
+ if (!boundarySegment) return;
+ const currentStart = boundarySegment.tokens[0]?.ref;
+ /* v8 ignore next -- a rendered segment always has at least one token */
+ if (currentStart === undefined) return;
+ if (focusedSideIsPrev) {
+ const index = boundarySegment.tokens.findIndex((t) => t.ref === nextToken.ref);
+ // The new boundary must land on a word token: a boundary ref names where the next segment
+ // starts, and the delta's added starts are word refs by contract. Skipping punctuation also
+ // keeps punctuation trailing the pulled word (e.g. a comma) traveling with it.
+ const newStart = boundarySegment.tokens.slice(index + 1).find(isWordToken)?.ref;
+ // No word token remains past the pulled one (at most trailing punctuation), so the adjacent
+ // segment merges wholly into the focused one rather than leaving a word-less remainder.
+ if (newStart === undefined) segmentationDispatch.merge(currentStart);
+ else segmentationDispatch.move(currentStart, newStart);
+ } else {
+ // Mirror of the true branch: `prevToken` is the previous segment's last word. Moving the
+ // boundary onto it strands whatever precedes it in that segment; when those preceding tokens
+ // are all punctuation (no word remains before the pulled one), that remainder would be a
+ // word-less segment. Resolve the previous segment and, in that case, merge it wholly into the
+ // focused one instead. When nothing precedes the pulled token (it is the segment's first
+ // token) the move strands nothing, so proceed with the move as before.
+ const prevSegmentId = tokenSegmentMap.get(prevToken.ref);
+ const prevSegment =
+ /* v8 ignore next -- a rendered prevToken always maps to a segment; undefined is defensive */
+ prevSegmentId === undefined ? undefined : segmentById.get(prevSegmentId);
+ /* v8 ignore next -- prevToken always resolves to a rendered segment in practice */
+ if (!prevSegment) return;
+ const pullIndex = prevSegment.tokens.findIndex((t) => t.ref === prevToken.ref);
+ const precedingTokens = prevSegment.tokens.slice(0, pullIndex);
+ const wouldStrandWordlessRemainder =
+ precedingTokens.length > 0 && !precedingTokens.some(isWordToken);
+ if (wouldStrandWordlessRemainder) segmentationDispatch.merge(currentStart);
+ else segmentationDispatch.move(currentStart, prevToken.ref);
+ }
+ }, [prevToken, nextToken, focusedSideIsPrev, tokenSegmentMap, segmentById, segmentationDispatch]);
+
/**
* Joins the neighbor on the far side of this slot into the focused phrase (or free token).
*
@@ -129,6 +196,10 @@ export function TokenLinkIcon({
/* v8 ignore next -- button only renders when both tokens exist and focus is defined */
if (!prevToken || !nextToken || focusedSideIsPrev === undefined) return;
+ // For a cross-segment edge link, first move the boundary so the pulled token joins the focused
+ // segment; the phrase mutation below then proceeds as for a within-segment link.
+ if (isAdjacentEdgeOfFocus) performBoundaryPull();
+
// The neighbor is the token/phrase on the opposite side of this slot from focus.
const neighborLink = focusedSideIsPrev ? nextPhraseLink : prevPhraseLink;
const neighborToken = focusedSideIsPrev ? nextToken : prevToken;
@@ -196,6 +267,8 @@ export function TokenLinkIcon({
focusedSideIsPrev,
focusedPhraseLink,
focusedFreeToken,
+ isAdjacentEdgeOfFocus,
+ performBoundaryPull,
tokenDocOrder,
createPhrase,
updatePhrase,
@@ -260,14 +333,32 @@ export function TokenLinkIcon({
);
}
- // Link icon: active in view mode when focus is set and both neighbors are in the same segment.
+ // A cross-segment (adjacent-edge) pull moves the boundary by exactly one token, so it is valid
+ // only when the pulled edge token is free: pulling a token that belongs to a phrase would leave
+ // that phrase straddling the moved boundary. (The not-mid-phrase rule is a UI guard — the
+ // segmentation dispatch itself accepts such boundaries and force-breaks the straddled phrases.)
+ const pulledTokenInPhrase =
+ focusedSideIsPrev === undefined
+ ? false
+ : (focusedSideIsPrev ? nextPhraseLink : prevPhraseLink) !== undefined;
+ const adjacentEdgeValid = isAdjacentEdgeOfFocus && !pulledTokenInPhrase;
+
+ // Link icon: active in view mode when focus is set and either both neighbors are in the focused
+ // segment (a within-segment link) or this slot is a valid adjacent edge of the focused segment (a
+ // cross-segment link that pulls the free edge token across and moves the boundary).
const isActive =
- phraseMode.kind === 'view' && focusedSideIsPrev !== undefined && isSameSegmentAsFocus;
+ phraseMode.kind === 'view' &&
+ focusedSideIsPrev !== undefined &&
+ (isSameSegmentAsFocus || adjacentEdgeValid);
const linkDisabled = isUnlinkMode || isEditMode || !isActive;
- // Show a tooltip only when inactive because the slot is outside the focused segment (not when
- // disabled for other reasons like unlink/edit mode where the reason is already visible in the UI).
+ // Show a tooltip only when inactive because the slot is a cross-segment slot that cannot host a
+ // link (not when disabled for other reasons like unlink/edit mode, where the reason is already
+ // visible in the UI).
const crossSegmentDisabled =
- phraseMode.kind === 'view' && focusedSideIsPrev !== undefined && !isSameSegmentAsFocus;
+ phraseMode.kind === 'view' &&
+ focusedSideIsPrev !== undefined &&
+ !isSameSegmentAsFocus &&
+ !adjacentEdgeValid;
const linkTitle = crossSegmentDisabled ? crossSegmentLinkTooltip : undefined;
// Highlight exactly what would be absorbed if the button were clicked — mirrors handleLinkClick.
diff --git a/src/components/modals/ProjectMetadataModal.tsx b/src/components/modals/ProjectMetadataModal.tsx
index 7b2a0276..74ecc8bb 100644
--- a/src/components/modals/ProjectMetadataModal.tsx
+++ b/src/components/modals/ProjectMetadataModal.tsx
@@ -18,6 +18,7 @@ const PROJECT_METADATA_MODAL_STRING_KEYS: `%${string}%`[] = [
'%interlinearizer_modal_metadata_analysis_language_label%',
'%interlinearizer_modal_metadata_language_placeholder%',
'%interlinearizer_modal_metadata_created_label%',
+ '%interlinearizer_modal_metadata_modified_label%',
'%interlinearizer_modal_metadata_source_label%',
'%interlinearizer_modal_metadata_save%',
'%interlinearizer_modal_metadata_close%',
@@ -44,6 +45,8 @@ type ProjectMetadataModalProps = Readonly<{
analysisLanguages: string[];
/** ISO 8601 creation timestamp. */
createdAt: string;
+ /** ISO 8601 timestamp of the most recent modification. */
+ updatedAt: string;
/** Callback invoked when the modal should be dismissed without saving. */
onClose: () => void;
/** Optional callback invoked with updated metadata after a successful save. */
@@ -51,6 +54,12 @@ type ProjectMetadataModalProps = Readonly<{
name?: string;
description?: string;
analysisLanguages: string[];
+ /**
+ * The server-refreshed modification timestamp, when the backend returned it. Threaded through
+ * so the caller's cached `activeProject` reflects the new Modified time immediately rather than
+ * keeping the pre-save value.
+ */
+ updatedAt?: string;
}) => void;
/** Optional callback invoked with the deleted project ID after deletion. */
onProjectDeleted?: (deletedProjectId: string) => void;
@@ -72,6 +81,7 @@ export function ProjectMetadataModal({
targetProjectId,
analysisLanguages,
createdAt,
+ updatedAt,
onClose,
onProjectSaved,
onProjectDeleted,
@@ -88,6 +98,7 @@ export function ProjectMetadataModal({
const { isSubmitting, runGuarded } = useSubmitGuard();
const formattedDate = useMemo(() => new Date(createdAt).toLocaleString(), [createdAt]);
+ const formattedModifiedDate = useMemo(() => new Date(updatedAt).toLocaleString(), [updatedAt]);
/**
* Parsed analysis-language tags from the comma-separated field. Computed once and reused by both
@@ -121,10 +132,19 @@ export function ProjectMetadataModal({
targetProjectId,
);
if (!updatedProjectJson) return;
+ // The command returns the saved project JSON with a refreshed `updatedAt`; parse it out so
+ // the caller's cached copy shows the new Modified time. Falls back to omitting it if the
+ // payload is unexpectedly shaped rather than surfacing a parse error to the user.
+ const parsed: unknown = JSON.parse(updatedProjectJson);
+ const refreshedUpdatedAt =
+ parsed && typeof parsed === 'object' && 'updatedAt' in parsed
+ ? parsed.updatedAt
+ : undefined;
onProjectSaved?.({
name: newName,
description: newDescription,
analysisLanguages: parsedLanguages,
+ ...(typeof refreshedUpdatedAt === 'string' && { updatedAt: refreshedUpdatedAt }),
});
onClose();
} catch (e) {
@@ -229,6 +249,10 @@ export function ProjectMetadataModal({
label={localizedStrings['%interlinearizer_modal_metadata_created_label%']}
value={formattedDate}
/>
+ DraftProject | undefined;
loadFromProject: (project: OpenableProject) => void;
newDraft: (config: NewDraftConfig) => void;
- markSynced: (savedAnalysis: TextAnalysis) => void;
+ markSynced: (
+ savedAnalysis: TextAnalysis,
+ savedSegmentation: SegmentationDelta | undefined,
+ ) => void;
modal: ModalState;
projectId: string;
setModal: (modal: ModalState) => void;
@@ -142,12 +150,18 @@ export default function ProjectModals({
/**
* Called when the metadata modal saves changes. Updates `activeProject` state when the edited
- * project is the currently active one.
+ * project is the currently active one, carrying through the server-refreshed `updatedAt` so the
+ * cached Modified time (shown in the picker and on modal reopen) stays current.
*
- * @param updated - The updated name, description, and analysisLanguages.
+ * @param updated - The updated name, description, analysisLanguages, and refreshed `updatedAt`.
*/
const handleMetadataProjectSaved = useCallback(
- (updated: { name?: string; description?: string; analysisLanguages: string[] }) => {
+ (updated: {
+ name?: string;
+ description?: string;
+ analysisLanguages: string[];
+ updatedAt?: string;
+ }) => {
if (activeProject && resolvedMetadataProject?.id === activeProject.id) {
setActiveProject({ ...activeProject, ...updated });
}
@@ -170,8 +184,9 @@ export default function ProjectModals({
/**
* Loads the given project into the draft as a working copy and makes it the Save target. Fetches
- * the full project (with analysis) via `interlinearizer.getProject`, validates it, then seeds the
- * draft and dismisses the modal. Logs and notifies on failure, leaving the draft untouched.
+ * the full project (with analysis and any stored segment boundaries) via
+ * `interlinearizer.getProject`, validates it, then seeds the draft and dismisses the modal. Logs
+ * and notifies on failure, leaving the draft untouched.
*
* @param project - The project summary the user chose to open.
* @returns A promise that resolves once the draft is loaded or the failure has been handled.
@@ -185,7 +200,15 @@ export default function ProjectModals({
parsed && typeof parsed === 'object' && 'analysis' in parsed
? parsed.analysis
: undefined;
- if (!isInterlinearProjectSummary(parsed) || !isTextAnalysis(analysis)) {
+ const segmentation =
+ parsed && typeof parsed === 'object' && 'segmentation' in parsed
+ ? parsed.segmentation
+ : undefined;
+ if (
+ !isInterlinearProjectSummary(parsed) ||
+ !isTextAnalysis(analysis) ||
+ (segmentation !== undefined && !isSegmentationDelta(segmentation))
+ ) {
await papi.notifications
.send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' })
.catch(() => {});
@@ -195,6 +218,7 @@ export default function ProjectModals({
loadFromProject({
analysisLanguages: parsed.analysisLanguages,
...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }),
+ ...(segmentation !== undefined && { segmentation }),
analysis,
});
setActiveProject(project);
@@ -349,8 +373,9 @@ export default function ProjectModals({
/**
* Saves the current draft as a brand-new project: creates the project with the draft's languages
- * / target, writes the draft's analysis into it, then makes it the active Save target and clears
- * the dirty flag. Backend commands surface their own error notifications; here we only log.
+ * / target, writes the draft's analysis and segment boundaries into it, then makes it the active
+ * Save target and clears the dirty flag. Backend commands surface their own error notifications;
+ * here we only log.
*
* @param name - Trimmed project name, or `undefined`.
* @param description - Trimmed project description, or `undefined`.
@@ -383,9 +408,13 @@ export default function ProjectModals({
'interlinearizer.saveAnalysis',
created.id,
JSON.stringify(snapshot.analysis),
+ // Carry the draft's boundary state into the new project; `null` (the clear sentinel) is
+ // harmless on a freshly created project, which stores no boundaries yet.
+ // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries
+ JSON.stringify(snapshot.segmentation ?? null),
);
setActiveProject(created);
- markSynced(snapshot.analysis);
+ markSynced(snapshot.analysis, snapshot.segmentation);
setModal('none');
} catch (e) {
logger.error('Interlinearizer: failed to save draft as new project', e);
@@ -395,11 +424,11 @@ export default function ProjectModals({
);
/**
- * Overwrites an existing project with the current draft: writes the draft's analysis into the
- * chosen project, reconciles the project's declared config (analysis languages / alignment
- * target) with the draft so the metadata matches the glosses now stored in it, makes it the
- * active Save target, and clears the dirty flag. The backend surfaces its own error notification;
- * here we only log.
+ * Overwrites an existing project with the current draft: writes the draft's analysis and segment
+ * boundaries into the chosen project, reconciles the project's declared config (analysis
+ * languages / alignment target) with the draft so the metadata matches the glosses now stored in
+ * it, makes it the active Save target, and clears the dirty flag. The backend surfaces its own
+ * error notification; here we only log.
*
* @param project - The existing project to overwrite.
* @returns A promise that resolves once the overwrite completes or the failure has been handled.
@@ -415,12 +444,16 @@ export default function ProjectModals({
'interlinearizer.saveAnalysis',
project.id,
JSON.stringify(snapshot.analysis),
+ // Send the draft's boundary state so the overwritten project's boundaries match the
+ // analysis just written; `null` clears any boundaries the target had stored before.
+ // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries
+ JSON.stringify(snapshot.segmentation ?? null),
);
// Push the draft's analysis languages / alignment target onto the project so its declared
// metadata stays consistent with the glosses just written (mirroring how Save As → New
// carries the draft's config into the created project). The project's name and description
// are intentionally preserved — overwriting keeps the target's identity.
- await papi.commands.sendCommand(
+ const updatedJson = await papi.commands.sendCommand(
'interlinearizer.updateProjectMetadata',
project.id,
project.name,
@@ -428,14 +461,22 @@ export default function ProjectModals({
snapshot.analysisLanguages,
snapshot.targetProjectId,
);
- setActiveProject({
- ...project,
- analysisLanguages: snapshot.analysisLanguages,
- // Assign explicitly (rather than a conditional spread) so a target binding on the
- // overwritten project is cleared when the draft has none, matching what was persisted.
- targetProjectId: snapshot.targetProjectId,
- });
- markSynced(snapshot.analysis);
+ // Prefer the server-returned project (it carries the refreshed `updatedAt`) as the new
+ // active project; fall back to the pre-save object with the draft's config if the payload is
+ // missing or malformed so the Save still completes.
+ const parsedUpdated: unknown = updatedJson ? JSON.parse(updatedJson) : undefined;
+ setActiveProject(
+ isInterlinearProjectSummary(parsedUpdated)
+ ? parsedUpdated
+ : {
+ ...project,
+ analysisLanguages: snapshot.analysisLanguages,
+ // Assign explicitly (rather than a conditional spread) so a target binding on the
+ // overwritten project is cleared when the draft has none, matching what was persisted.
+ targetProjectId: snapshot.targetProjectId,
+ },
+ );
+ markSynced(snapshot.analysis, snapshot.segmentation);
setModal('none');
} catch (e) {
logger.error('Interlinearizer: failed to overwrite project with draft', e);
@@ -522,6 +563,7 @@ export default function ProjectModals({
targetProjectId={resolvedMetadataProject.targetProjectId}
analysisLanguages={resolvedMetadataProject.analysisLanguages}
createdAt={resolvedMetadataProject.createdAt}
+ updatedAt={resolvedMetadataProject.updatedAt}
onClose={handleMetadataClose}
onProjectSaved={handleMetadataProjectSaved}
onProjectDeleted={handleMetadataProjectDeleted}
diff --git a/src/components/modals/SelectInterlinearProjectModal.tsx b/src/components/modals/SelectInterlinearProjectModal.tsx
index d3b39028..46abe7e4 100644
--- a/src/components/modals/SelectInterlinearProjectModal.tsx
+++ b/src/components/modals/SelectInterlinearProjectModal.tsx
@@ -16,8 +16,36 @@ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [
'%interlinearizer_modal_select_name_unnamed%',
'%interlinearizer_modal_select_info_button_label%',
'%interlinearizer_modal_select_active_badge%',
+ '%interlinearizer_modal_select_modified_prefix%',
];
+/**
+ * Compares two ISO 8601 timestamps for a descending (newest-first) sort by their parsed epoch
+ * milliseconds, so ordering is locale-independent (unlike `localeCompare`, whose result can vary by
+ * collator).
+ *
+ * @param a - The first ISO 8601 timestamp.
+ * @param b - The second ISO 8601 timestamp.
+ * @returns A negative number when `a` is newer than `b` (sorts first), positive when older, `0`
+ * when the two timestamps are equal.
+ */
+function compareUpdatedAtDescending(a: string, b: string): number {
+ return Date.parse(b) - Date.parse(a);
+}
+
+/**
+ * Formats the modified-date subline for a project row, e.g. `"Modified Jan 1, 2026, 12:00 PM"`. The
+ * prefix is a localized label; the timestamp is rendered in the user's locale via
+ * `toLocaleString`.
+ *
+ * @param prefix - Localized `"Modified"` label to precede the date.
+ * @param updatedAt - ISO 8601 modification timestamp.
+ * @returns The prefix followed by the locale-formatted timestamp.
+ */
+function formatModified(prefix: string, updatedAt: string): string {
+ return `${prefix} ${new Date(updatedAt).toLocaleString()}`;
+}
+
/**
* Modal that lists all existing interlinearizer projects for a source project and lets the user
* select one, view its details (via the info icon), or request that a new one be created. Fires
@@ -89,7 +117,14 @@ export function SelectInterlinearProjectModal({
'Interlinearizer: skipped malformed project entries',
parsed.length - valid.length,
);
- setProjects(valid);
+ // Most-recently-modified first so the project the user is likeliest to reopen sits at the top
+ // and the modified date reads as a meaningful distinguisher between otherwise-identical
+ // unnamed projects. Ordered by parsed epoch time so it stays locale-independent (no
+ // `localeCompare`/collator involvement).
+ const sorted = [...valid].sort((a, b) =>
+ compareUpdatedAtDescending(a.updatedAt, b.updatedAt),
+ );
+ setProjects(sorted);
} catch (e) {
logger.error('Interlinearizer: failed to load projects for source', e);
await papi.notifications
@@ -126,24 +161,32 @@ export function SelectInterlinearProjectModal({