+
);
}
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..b8388975 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,18 @@ 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`; otherwise it 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..994324c7
--- /dev/null
+++ b/src/components/SegmentationStore.tsx
@@ -0,0 +1,111 @@
+/**
+ * @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.
+ */
+ 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..c3e09741 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,47 @@ 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 {
+ 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 +179,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 +250,8 @@ export function TokenLinkIcon({
focusedSideIsPrev,
focusedPhraseLink,
focusedFreeToken,
+ isAdjacentEdgeOfFocus,
+ performBoundaryPull,
tokenDocOrder,
createPhrase,
updatePhrase,
@@ -260,14 +316,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/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx
index ffd230c0..7d67a1c7 100644
--- a/src/components/modals/ProjectModals.tsx
+++ b/src/components/modals/ProjectModals.tsx
@@ -1,11 +1,15 @@
import type { UseWebViewStateHook } from '@papi/core';
import papi, { logger } from '@papi/frontend';
-import type { DraftProject, TextAnalysis } from 'interlinearizer';
+import type { DraftProject, SegmentationDelta, TextAnalysis } from 'interlinearizer';
import { useCallback, useState } from 'react';
import type { NewDraftConfig, OpenableProject } from '../../hooks/useDraftProject';
import useSubmitGuard from '../../hooks/useSubmitGuard';
import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary';
-import { isInterlinearProjectSummary, isTextAnalysis } from '../../types/type-guards';
+import {
+ isInterlinearProjectSummary,
+ isSegmentationDelta,
+ isTextAnalysis,
+} from '../../types/type-guards';
import { CreateProjectModal, type CreateDraftConfig } from './CreateProjectModal';
import { DiscardDraftConfirm } from './DiscardDraftConfirm';
import { ProjectMetadataModal } from './ProjectMetadataModal';
@@ -48,7 +52,8 @@ type PendingReplace =
* called by {@link createAndPersistProject} before the backend round-trip so the editor is ready
* regardless of whether persistence succeeds.
* @param props.markSynced - Marks the draft as saved (clears `dirty`) after a successful Save As,
- * given the analysis that was persisted; a no-op if an edit landed during the save.
+ * given the analysis and boundary delta that were persisted; a no-op if an edit landed during the
+ * save.
* @param props.modal - Which modal is currently open.
* @param props.projectId - PAPI source project ID passed from the host.
* @param props.setModal - Setter for which modal is open.
@@ -75,7 +80,10 @@ export default function ProjectModals({
getDraftSnapshot: () => 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;
@@ -170,8 +178,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 +194,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 +212,7 @@ export default function ProjectModals({
loadFromProject({
analysisLanguages: parsed.analysisLanguages,
...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }),
+ ...(segmentation !== undefined && { segmentation }),
analysis,
});
setActiveProject(project);
@@ -349,8 +367,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 +402,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 +418,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,6 +438,10 @@ 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
@@ -435,7 +462,7 @@ export default function ProjectModals({
// overwritten project is cleared when the draft has none, matching what was persisted.
targetProjectId: snapshot.targetProjectId,
});
- markSynced(snapshot.analysis);
+ markSynced(snapshot.analysis, snapshot.segmentation);
setModal('none');
} catch (e) {
logger.error('Interlinearizer: failed to overwrite project with draft', e);
diff --git a/src/hooks/useBookIndexes.ts b/src/hooks/useBookIndexes.ts
index aec490ce..94011d03 100644
--- a/src/hooks/useBookIndexes.ts
+++ b/src/hooks/useBookIndexes.ts
@@ -6,22 +6,38 @@ import { isWordToken } from '../types/type-guards';
export interface BookIndexes {
/** Maps every segment id to the segment; used to resolve a focused token's verse. */
segmentById: ReadonlyMap;
+ /** Maps every segment id to its index in document order; used to test segment adjacency. */
+ segmentOrder: ReadonlyMap;
/**
* Maps every word token ref to its flat book-level index; used to sort phrase tokens in document
* order.
*/
tokenDocOrder: ReadonlyMap;
+ /**
+ * Maps every token ref — words and punctuation — to its flat book-level index. The canonical
+ * document order for boundary refs, which can name punctuation tokens (e.g. the move target of a
+ * cross-segment pull) that the word-only {@link BookIndexes.tokenDocOrder} cannot resolve. Word
+ * tokens rank consistently in both maps (both are ascending document positions), so comparing a
+ * word-only order against this map's values is safe.
+ */
+ fullTokenOrder: ReadonlyMap;
/** Maps every token ref to the id of the segment that contains it. */
tokenSegmentMap: ReadonlyMap;
/** Maps every word token ref to the token; used by views to resolve focus context. */
wordTokenByRef: ReadonlyMap;
+ /**
+ * Every word token ref in document order — the inverse of {@link BookIndexes.tokenDocOrder}
+ * (`wordRefByOrder[tokenDocOrder.get(ref)] === ref`). Used to enumerate the word refs inside a
+ * document-order interval, e.g. the boundary anchors a phrase straddles.
+ */
+ wordRefByOrder: readonly string[];
}
/**
* Builds the book-wide lookup indexes the interlinear views share, in a single pass over
* `book.segments`. The indexes always travel together through the view prop plumbing, so deriving
* them in one memo keeps them in lockstep (one traversal, one identity change per book change)
- * instead of four separate memos each walking the segment list.
+ * instead of separate memos each walking the segment list.
*
* @param book - The tokenized book to index.
* @returns The lookup indexes; stable identities until `book.segments` changes.
@@ -29,21 +45,35 @@ export interface BookIndexes {
export default function useBookIndexes(book: Book): BookIndexes {
return useMemo(() => {
const segmentById = new Map();
+ const segmentOrder = new Map();
const tokenDocOrder = new Map();
+ const fullTokenOrder = new Map();
const tokenSegmentMap = new Map();
const wordTokenByRef = new Map();
- let wordIndex = 0;
- book.segments.forEach((seg) => {
+ const wordRefByOrder: string[] = [];
+ let tokenIndex = 0;
+ book.segments.forEach((seg, segIndex) => {
segmentById.set(seg.id, seg);
+ segmentOrder.set(seg.id, segIndex);
seg.tokens.forEach((token) => {
tokenSegmentMap.set(token.ref, seg.id);
+ fullTokenOrder.set(token.ref, tokenIndex);
+ tokenIndex += 1;
if (isWordToken(token)) {
- tokenDocOrder.set(token.ref, wordIndex);
- wordIndex += 1;
+ tokenDocOrder.set(token.ref, wordRefByOrder.length);
+ wordRefByOrder.push(token.ref);
wordTokenByRef.set(token.ref, token);
}
});
});
- return { segmentById, tokenDocOrder, tokenSegmentMap, wordTokenByRef };
+ return {
+ segmentById,
+ segmentOrder,
+ tokenDocOrder,
+ fullTokenOrder,
+ tokenSegmentMap,
+ wordTokenByRef,
+ wordRefByOrder,
+ };
}, [book.segments]);
}
diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts
index 0bfd289b..ba676dd5 100644
--- a/src/hooks/useDraftProject.ts
+++ b/src/hooks/useDraftProject.ts
@@ -1,6 +1,11 @@
/** @file Hook owning the always-present, auto-saved draft buffer for one source project. */
import papi, { logger } from '@papi/frontend';
-import type { DraftProject, InterlinearProject, TextAnalysis } from 'interlinearizer';
+import type {
+ DraftProject,
+ InterlinearProject,
+ SegmentationDelta,
+ TextAnalysis,
+} from 'interlinearizer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { emptyAnalysis, emptyDraft } from '../types/empty-factories';
import { removeBookFromAnalysis } from '../utils/analysis-book';
@@ -11,7 +16,7 @@ const AUTOSAVE_DEBOUNCE_MS = 300;
/** The subset of an {@link InterlinearProject} needed to open it into the draft as a working copy. */
export type OpenableProject = Pick<
InterlinearProject,
- 'analysis' | 'analysisLanguages' | 'targetProjectId'
+ 'analysis' | 'analysisLanguages' | 'targetProjectId' | 'segmentation'
>;
/** Configuration for starting a fresh, empty draft via {@link UseDraftProjectResult.newDraft}. */
@@ -39,6 +44,15 @@ export type UseDraftProjectResult = {
* auto-saves deliberately do not bump it, so editing never remounts the editor.
*/
draftVersion: number;
+ /**
+ * Monotonic counter bumped on every change to the draft's segment-boundary delta (per-edit
+ * auto-saves included). Unlike {@link UseDraftProjectResult.draftVersion} it is deliberately kept
+ * out of the editor's remount key: the resegmented book is derived from `draft.segmentation`,
+ * which lives in a ref, so a boundary edit needs a re-render to recompute the book — but must not
+ * remount the editor (that would drop analysis-edit, scroll, and focus state). Consumers thread
+ * this into the memo that resegments the book so the new boundaries take effect in place.
+ */
+ segmentationVersion: number;
/**
* Whether the draft has diverged from its active project since the last Save / Save As / Open /
* New. Drives the discard confirmation and the tab's unsaved-changes indicator.
@@ -59,6 +73,13 @@ export type UseDraftProjectResult = {
* @param analysis - The updated analysis from the store.
*/
autosaveAnalysis: (analysis: TextAnalysis) => void;
+ /**
+ * Persists an edited segment-boundary delta into the draft and marks it dirty. Pass `undefined`
+ * (or a default/empty delta) to clear custom boundaries back to the default verse segmentation.
+ *
+ * @param segmentation - The updated boundary delta, or `undefined` for the default segmentation.
+ */
+ autosaveSegmentation: (segmentation: SegmentationDelta | undefined) => void;
/**
* Replaces the draft with a working copy of an existing project's analysis and config — the
* "Open" flow.
@@ -88,14 +109,19 @@ export type UseDraftProjectResult = {
wipeAll: () => void;
/**
* Marks the draft as synced (not dirty) after a successful Save / Save As — but only when the
- * draft has not changed since the snapshot that was persisted. Pass the exact analysis that was
- * written; if a later auto-save replaced it (an edit made during the save round-trip), the draft
- * is left dirty so the unsaved-changes indicator and the next Save reflect that un-persisted edit
- * rather than being cleared against a now-stale snapshot.
+ * draft has not changed since the snapshot that was persisted. Pass the exact analysis and
+ * boundary delta that were written; if a later auto-save replaced either (an edit made during the
+ * save round-trip), the draft is left dirty so the unsaved-changes indicator and the next Save
+ * reflect that un-persisted edit rather than being cleared against a now-stale snapshot.
*
* @param savedAnalysis - The `TextAnalysis` reference that was actually persisted to the project.
+ * @param savedSegmentation - The `SegmentationDelta` reference that was persisted alongside it
+ * (`undefined` when the draft had the default segmentation).
*/
- markSynced: (savedAnalysis: TextAnalysis) => void;
+ markSynced: (
+ savedAnalysis: TextAnalysis,
+ savedSegmentation: SegmentationDelta | undefined,
+ ) => void;
};
/**
@@ -119,6 +145,7 @@ export default function useDraftProject(
const draftRef = useRef(undefined);
const [isDraftLoading, setIsDraftLoading] = useState(true);
const [draftVersion, setDraftVersion] = useState(0);
+ const [segmentationVersion, setSegmentationVersion] = useState(0);
const [dirty, setDirty] = useState(false);
// Read the latest platform language via a ref so the load effect (keyed on sourceProjectId)
@@ -219,13 +246,22 @@ export default function useDraftProject(
[persist],
);
- const autosaveAnalysis = useCallback(
- (analysis: TextAnalysis) => {
+ /**
+ * Shared per-edit auto-save pipeline: derives the next draft from the current one via `mutate`,
+ * swaps it into the ref, debounces the persistence write, and marks the draft dirty. No version
+ * bump (no remount) and `setDirty(true)` is a no-op when already dirty, so editing does not
+ * re-render.
+ *
+ * @param mutate - Produces the next draft from the current one; must set `dirty: true`.
+ * @returns `true` when the edit was applied; `false` when no draft has loaded yet.
+ */
+ const autosaveDraft = useCallback(
+ (mutate: (current: DraftProject) => DraftProject): boolean => {
const { current } = draftRef;
/* v8 ignore next -- auto-save only fires from the mounted editor, which exists only post-load */
- if (!current) return;
+ if (!current) return false;
- const next: DraftProject = { ...current, analysis, dirty: true };
+ const next = mutate(current);
draftRef.current = next;
// Debounce writes so rapid keystrokes don't queue unbounded commands to the backend.
if (autosaveTimeoutRef.current !== undefined) clearTimeout(autosaveTimeoutRef.current);
@@ -233,18 +269,51 @@ export default function useDraftProject(
autosaveTimeoutRef.current = undefined;
persist(next);
}, AUTOSAVE_DEBOUNCE_MS);
- // No version bump (no remount) and a no-op when already dirty, so editing does not re-render.
setDirty(true);
+ return true;
},
[persist],
);
+ const autosaveAnalysis = useCallback(
+ (analysis: TextAnalysis) => {
+ autosaveDraft((current) => ({ ...current, analysis, dirty: true }));
+ },
+ [autosaveDraft],
+ );
+
+ const autosaveSegmentation = useCallback(
+ (segmentation: SegmentationDelta | undefined) => {
+ // Treat an empty delta (no removed or added starts) the same as `undefined`: it represents the
+ // default segmentation, so clear the field rather than persisting a redundant custom object.
+ const hasCustomBoundaries =
+ segmentation !== undefined &&
+ (segmentation.removedVerseStarts.length > 0 || segmentation.addedStarts.length > 0);
+ const applied = autosaveDraft((current) => {
+ const next: DraftProject = { ...current, dirty: true };
+ // Store custom boundaries when present; clear the field for the default segmentation so the
+ // persisted draft stays minimal.
+ if (hasCustomBoundaries) next.segmentation = segmentation;
+ else delete next.segmentation;
+ return next;
+ });
+ /* v8 ignore next -- auto-save only fires from the mounted editor, which exists only post-load */
+ if (!applied) return;
+ // The resegmented book is derived from `draftRef.current.segmentation`, which lives in a ref;
+ // `setDirty(true)` bails out of the re-render when the draft was already dirty, so bump a
+ // dedicated version to force the loader to re-read the boundaries and recompute the book.
+ setSegmentationVersion((v) => v + 1);
+ },
+ [autosaveDraft],
+ );
+
const loadFromProject = useCallback(
(project: OpenableProject) => {
applyReplacement({
sourceProjectId,
analysisLanguages: project.analysisLanguages,
...(project.targetProjectId !== undefined && { targetProjectId: project.targetProjectId }),
+ ...(project.segmentation !== undefined && { segmentation: project.segmentation }),
analysis: project.analysis,
dirty: false,
});
@@ -292,20 +361,25 @@ export default function useDraftProject(
// the unsaved-changes indicator (dirty: false) so the user is not nagged to save an empty
// draft. The active project is intentionally left untouched, so a subsequent Save still targets
// it. Per-book wipe stays dirty, since it is a partial edit the user will usually want to save.
- applyReplacement({ ...current, analysis: emptyAnalysis(), dirty: false });
+ // Custom segment boundaries are part of the working state, so a whole-draft wipe clears them too.
+ const next: DraftProject = { ...current, analysis: emptyAnalysis(), dirty: false };
+ delete next.segmentation;
+ applyReplacement(next);
}, [applyReplacement]);
const markSynced = useCallback(
- (savedAnalysis: TextAnalysis) => {
+ (savedAnalysis: TextAnalysis, savedSegmentation: SegmentationDelta | undefined) => {
const { current } = draftRef;
/* v8 ignore next -- save is only reachable from the mounted editor */
if (!current) return;
- // If an edit landed during the save round-trip, autosaveAnalysis has already swapped a newer
- // analysis (a fresh object) into the ref and marked the draft dirty. Leave it dirty so the
- // unsaved indicator and the next Save reflect that un-persisted edit, rather than clearing it
- // against the now-stale snapshot we just wrote.
- if (current.analysis !== savedAnalysis) return;
+ // If an edit landed during the save round-trip, the auto-save has already swapped a newer
+ // analysis or boundary delta (a fresh object) into the ref and marked the draft dirty. Leave
+ // it dirty so the unsaved indicator and the next Save reflect that un-persisted edit, rather
+ // than clearing it against the now-stale snapshot we just wrote. Both fields are compared:
+ // a boundary edit carries `analysis` over by reference, so checking the analysis alone would
+ // wrongly clear the dirty flag over an unsaved segmentation change.
+ if (current.analysis !== savedAnalysis || current.segmentation !== savedSegmentation) return;
// Cancel any pending debounced autosave before persisting the clean state so a stale
// {dirty: true} timer cannot fire after this and overwrite the {dirty: false} record.
@@ -325,9 +399,11 @@ export default function useDraftProject(
isDraftLoading,
draft: draftRef.current,
draftVersion,
+ segmentationVersion,
dirty,
getDraftSnapshot,
autosaveAnalysis,
+ autosaveSegmentation,
loadFromProject,
newDraft,
wipeBook,
diff --git a/src/hooks/useSegmentWindow.ts b/src/hooks/useSegmentWindow.ts
index 6de99b25..0aeb55c1 100644
--- a/src/hooks/useSegmentWindow.ts
+++ b/src/hooks/useSegmentWindow.ts
@@ -3,7 +3,7 @@ import type { SerializedVerseRef } from '@sillsdev/scripture';
import type { RefObject } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { RECENTER_FADE_MS } from '../components/recenter-fade';
-import { isSameVerse } from '../utils/verse-ref';
+import { segmentContainsVerse } from '../utils/verse-ref';
import useLatestRef from './useLatestRef';
import useRecenterSnap from './useRecenterSnap';
@@ -54,6 +54,16 @@ export interface UseSegmentWindowArgs {
book: Book;
/** Current scripture reference; the active verse it names is the recenter anchor. */
scrRef: SerializedVerseRef;
+ /**
+ * Monotonic counter the loader bumps on every boundary edit (merge/split/move). The hook uses it
+ * to classify a segments-identity change: a change carrying a version bump is a boundary edit —
+ * the window redraws in place with no fade — while a change without one is a re-tokenization of
+ * the loaded book (or a book swap) and recenters with the fade. The cause is stated by this
+ * explicit signal rather than reverse-engineered from anchor coordinates, which misclassify in
+ * both directions (a merge absorbing the active verse's segment start changes the anchor verse; a
+ * re-tokenization can keep it).
+ */
+ segmentationVersion: number;
/**
* Token ref of the currently focused word token, or `undefined` when nothing is focused. Gated
* alongside {@link UseSegmentWindowResult.displayScrRef} so the per-token focus highlight and the
@@ -146,18 +156,19 @@ export interface UseSegmentWindowResult {
}
/**
- * Finds the index in `segments` of the segment that owns the verse named by `scrRef`. Matches on
- * book, chapter, and verse so a window that spans chapters resolves the anchor unambiguously. Falls
- * back to the first segment of the same book+chapter, then to `0`, so there is always a valid
- * anchor.
+ * Finds the index in `segments` of the segment that owns the verse named by `scrRef`. Matches by
+ * verse-range containment (first segment in document order whose range includes the verse), so a
+ * verse absorbed into a multi-verse segment — or the later portions of a split verse — resolves to
+ * the segment that actually contains it rather than only to exact segment starts. Falls back to the
+ * first segment of the same book+chapter, then to `0`, so there is always a valid anchor.
*
* @param segments - The book's flat segment list.
* @param scrRef - The scripture reference whose owning segment to locate.
* @returns The index of the anchor segment, clamped to a valid position (or `0` when empty).
*/
function findAnchorIndex(segments: readonly Segment[], scrRef: SerializedVerseRef): number {
- const exact = segments.findIndex((seg) => isSameVerse(seg.startRef, scrRef));
- if (exact !== -1) return exact;
+ const containing = segments.findIndex((seg) => segmentContainsVerse(seg, scrRef));
+ if (containing !== -1) return containing;
const chapter = segments.findIndex(
(seg) => seg.startRef.book === scrRef.book && seg.startRef.chapter === scrRef.chapterNum,
);
@@ -196,6 +207,8 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange {
* @param args - Hook arguments.
* @param args.book - The tokenized book whose `segments` are windowed.
* @param args.scrRef - Current scripture reference; its verse is the recenter anchor.
+ * @param args.segmentationVersion - Monotonic boundary-edit counter; classifies segments-identity
+ * changes as boundary edits (no fade) vs. re-tokenizations (recenter with fade).
* @param args.scrollContainerRef - Ref to the scrollable list container.
* @param args.consumeInternalNav - Returns whether the navigation to a given verse was internal
* (and clears the marker); used to suppress the fade for navigation that came from within the
@@ -205,6 +218,7 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange {
export default function useSegmentWindow({
book,
scrRef,
+ segmentationVersion,
focusedTokenRef,
continuousScroll,
scrollContainerRef,
@@ -534,30 +548,41 @@ export default function useSegmentWindow({
// strip so the two views fade as one. The fade fires for every external nav, even one already
// inside the window, so the two views can never disagree about whether a fade is happening.
//
- // `prevAnchorRef` tracks both the anchor index AND the segments identity so that a book change
- // (which can produce the same anchor index as the previous book) still triggers a recenter.
- const prevAnchorRef = useRef<{ index: number; segments: readonly Segment[] }>({
- index: anchorIndex,
- segments,
- });
+ // A segments-identity change carrying a `segmentationVersion` bump is NOT a navigation: it is a
+ // boundary edit (merge/split from the mounted controls). The window slice already re-renders the
+ // new segments in the same commit, so fading afterwards would flash content the user is already
+ // looking at and snap it away from the point they just clicked — sync the display refs (a merge
+ // that absorbs the active verse's segment start re-resolves the anchor verse in the same commit)
+ // and let the redraw stand. A segments change withOUT a version bump is a re-tokenization of the
+ // loaded book or a book swap at the same anchor index: the mounted index range no longer matches
+ // the content, so it recenters like any external change.
+ const prevAnchorRef = useRef<{
+ index: number;
+ segments: readonly Segment[];
+ segmentationVersion: number;
+ }>({ index: anchorIndex, segments, segmentationVersion });
useEffect(() => {
- const sameAnchor =
- anchorIndex === prevAnchorRef.current.index && segments === prevAnchorRef.current.segments;
+ const prev = prevAnchorRef.current;
+ // Refresh the whole snapshot up front so no early return can leave a field stale for a later
+ // comparison.
+ prevAnchorRef.current = { index: anchorIndex, segments, segmentationVersion };
+ const sameAnchor = anchorIndex === prev.index && segments === prev.segments;
if (sameAnchor) return;
- prevAnchorRef.current = { index: anchorIndex, segments };
const currentScrRef = scrRefRef.current;
- if (consumeInternalNavRef.current(currentScrRef)) {
+ const isBoundaryEdit =
+ segments !== prev.segments && segmentationVersion !== prev.segmentationVersion;
+ if (isBoundaryEdit || consumeInternalNavRef.current(currentScrRef)) {
setDisplayScrRef(currentScrRef);
setDisplayFocusedTokenRef(focusedTokenRefRef.current);
return;
}
triggerRecenter();
- // scrRef is read (via ref) only to key the internal-nav check; anchorIndex and segments already
- // capture every verse/book change we recenter on, and triggerRecenter has a stable identity. The
- // timeout is owned by triggerRecenter (recenterTimeoutRef), not torn down here, so an incidental
- // re-render that re-runs this effect can never cancel an in-flight fade.
+ // scrRef is read (via ref) only to key the internal-nav check; anchorIndex, segments, and
+ // segmentationVersion already capture every change we classify on, and triggerRecenter has a
+ // stable identity. The timeout is owned by triggerRecenter (recenterTimeoutRef), not torn down
+ // here, so an incidental re-render that re-runs this effect can never cancel an in-flight fade.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [anchorIndex, segments, triggerRecenter]);
+ }, [anchorIndex, segments, segmentationVersion, triggerRecenter]);
// Track within-verse focus moves (arrow/click that stays in the active verse) immediately. These
// change `focusedTokenRef` without changing `anchorIndex`, so the recenter effect above never
diff --git a/src/main.ts b/src/main.ts
index a12391d3..75cfc4a5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -7,10 +7,11 @@ import type {
SavedWebViewDefinition,
WebViewDefinition,
} from '@papi/core';
+import type { SegmentationDelta } from 'interlinearizer';
import interlinearizerReact from './interlinearizer.web-view?inline';
import interlinearizerStyles from './interlinearizer.web-view.scss?inline';
import * as projectStorage from './services/projectStorage';
-import { isDraftProject, isTextAnalysis } from './types/type-guards';
+import { isDraftProject, isSegmentationDelta, isTextAnalysis } from './types/type-guards';
// #region WebView provider
@@ -265,6 +266,8 @@ async function getInterlinearProject(interlinearProjectId: string): Promise {
try {
const analysis = JSON.parse(analysisJson);
if (!isTextAnalysis(analysis)) {
throw new TypeError('saveInterlinearAnalysis: analysisJson does not conform to TextAnalysis');
}
- await projectStorage.updateAnalysis(executionToken, interlinearProjectId, analysis);
+ // undefined ⇒ leave boundaries unchanged; null ⇒ clear them; an object ⇒ set them.
+ let segmentation: SegmentationDelta | null | undefined;
+ if (segmentationJson !== undefined) {
+ const parsed: unknown = JSON.parse(segmentationJson);
+ // eslint-disable-next-line no-null/no-null -- JSON.parse('null') yields null, the clear sentinel
+ if (parsed === null) {
+ // eslint-disable-next-line no-null/no-null -- explicit "clear boundaries" sentinel from the WebView
+ segmentation = null;
+ } else if (isSegmentationDelta(parsed)) {
+ segmentation = parsed;
+ } else {
+ throw new TypeError(
+ 'saveInterlinearAnalysis: segmentationJson does not conform to SegmentationDelta',
+ );
+ }
+ }
+ await projectStorage.updateAnalysis(
+ executionToken,
+ interlinearProjectId,
+ analysis,
+ segmentation,
+ );
} catch (e) {
logger.error('Interlinearizer: failed to save analysis', e);
await papi.notifications
@@ -547,6 +572,13 @@ export async function activate(context: ExecutionActivationContext): Promise 0) {
+ baselineText += MERGE_SEPARATOR;
+ cursor += MERGE_SEPARATOR.length;
+ }
+ // Consume the contiguous sub-run of tokens from this verse, shifting each token's offsets into
+ // the new concatenated baseline while keeping its ref and surface text unchanged.
+ const subStart = runIndex;
+ const base = run[subStart].token.charStart;
+ while (runIndex < run.length && run[runIndex].verse === verse) {
+ const { token } = run[runIndex];
+ tokens.push({
+ ...token,
+ charStart: cursor + (token.charStart - base),
+ charEnd: cursor + (token.charEnd - base),
+ });
+ runIndex += 1;
+ }
+ const lastCharEnd = run[runIndex - 1].token.charEnd;
+ const piece = verse.baselineText.slice(base, lastCharEnd);
+ baselineText += piece;
+ cursor += piece.length;
+ }
+
+ // Anchor the new range to the covered span; a mid-verse edge carries a sub-verse charIndex.
+ const startRef: ScriptureRef = startsAtVerseBoundary
+ ? firstVerse.startRef
+ : { ...firstVerse.startRef, charIndex: firstSourced.token.charStart };
+ const endsAtVerseBoundary =
+ lastSourced.token.ref === lastVerse.tokens[lastVerse.tokens.length - 1]?.ref;
+ const endRef: ScriptureRef = endsAtVerseBoundary
+ ? lastVerse.endRef
+ : { ...lastVerse.endRef, charIndex: lastSourced.token.charEnd };
+
+ return { id, startRef, endRef, baselineText, tokens };
+}
+
+/**
+ * Re-groups `book`'s verse segments into the user's custom segments per `delta`.
+ *
+ * Returns `book` unchanged (by reference) for the default segmentation, so the common no-custom-
+ * boundaries case incurs no work and no identity churn. Otherwise the flat token stream is cut at
+ * the effective boundaries; a run that is exactly one original verse reuses that verse's `Segment`
+ * object verbatim, while merged or split runs are rebuilt via {@link buildSegment}.
+ *
+ * @param book - The verse-tokenized book from {@link tokenizeBook}.
+ * @param delta - The user's boundary delta, or `undefined` for the default verse segmentation.
+ * @returns A book with the custom segmentation applied, or `book` itself when `delta` is the
+ * default.
+ */
+export function resegmentBook(book: Book, delta: SegmentationDelta | undefined): Book {
+ if (isDefaultSegmentation(delta)) return book;
+
+ const starts = effectiveStarts(book, delta);
+
+ // Cut the flat token stream into runs, beginning a new run at each effective start (but never
+ // splitting off a run that has no word/structural content yet — leading tokens stay with the
+ // first run).
+ const runs: SourcedToken[][] = [];
+ let current: SourcedToken[] = [];
+ book.segments.forEach((verse) => {
+ verse.tokens.forEach((token) => {
+ if (starts.has(token.ref) && current.length > 0) {
+ runs.push(current);
+ current = [];
+ }
+ current.push({ token, verse });
+ });
+ });
+ /* v8 ignore next -- a non-default delta always yields at least one token, so current is non-empty */
+ if (current.length > 0) runs.push(current);
+
+ const segments: Segment[] = runs.map((run) => {
+ const firstVerse = run[0].verse;
+ // Reuse the original verse Segment when the run is exactly that verse — preserves its id,
+ // baselineText, token offsets, and object identity.
+ const isWholeUntouchedVerse =
+ run.length === firstVerse.tokens.length && run.every((s) => s.verse === firstVerse);
+ return isWholeUntouchedVerse ? firstVerse : buildSegment(run);
+ });
+
+ return { ...book, segments };
+}
diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts
index 14298c32..36b0b071 100644
--- a/src/services/projectStorage.ts
+++ b/src/services/projectStorage.ts
@@ -1,6 +1,11 @@
import papi, { logger } from '@papi/backend';
import type { ExecutionToken } from '@papi/core';
-import type { DraftProject, InterlinearProject, TextAnalysis } from 'interlinearizer';
+import type {
+ DraftProject,
+ InterlinearProject,
+ SegmentationDelta,
+ TextAnalysis,
+} from 'interlinearizer';
import { emptyAnalysis, emptyDraft } from '../types/empty-factories';
import { isDraftProject } from '../types/type-guards';
@@ -256,11 +261,14 @@ export async function getProjectsForSource(
}
/**
- * Replaces the analysis of an existing interlinearizer project.
+ * Replaces the analysis of an existing interlinearizer project, and optionally its custom segment
+ * boundaries, in one atomic write.
*
* @param token - The execution token for storage access.
* @param id - The interlinearizer project UUID to update.
* @param analysis - The new `TextAnalysis` to persist.
+ * @param segmentation - The new boundary delta to persist (`SegmentationDelta`), `null` to clear
+ * any stored boundaries, or `undefined` to leave the project's existing boundaries unchanged.
* @returns The updated project record, or `undefined` if no project with the given ID exists.
* @throws {SyntaxError} If the project's storage value contains invalid JSON.
* @throws If `papi.storage.readUserData` or `papi.storage.writeUserData` rejects for a non-ENOENT
@@ -270,11 +278,15 @@ export async function updateAnalysis(
token: ExecutionToken,
id: string,
analysis: TextAnalysis,
+ segmentation?: SegmentationDelta | null,
): Promise {
return enqueueProjectOp(id, async () => {
const project = await getProject(token, id);
if (!project) return undefined;
const updated: InterlinearProject = { ...project, analysis };
+ // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel
+ if (segmentation === null) delete updated.segmentation;
+ else if (segmentation !== undefined) updated.segmentation = segmentation;
await papi.storage.writeUserData(token, projectKey(id), JSON.stringify(updated));
return updated;
});
diff --git a/src/tailwind.css b/src/tailwind.css
index bb4d09c4..2b15d6d7 100644
--- a/src/tailwind.css
+++ b/src/tailwind.css
@@ -181,6 +181,18 @@
@apply tw:border-foreground/55 tw:bg-muted/25;
}
+@utility phrase-candidate {
+ /*
+ * An operation preview (hovered link icon or boundary merge/split control): the tokens that would
+ * join into one phrase/segment or break off into a new segment. Deliberately stronger than
+ * phrase-hovered — a full-strength ring-colored border doubled by a ring shadow — so the preview
+ * is unmistakable and reads differently from an ordinary phrase hover.
+ */
+ --phrase-stroke: var(--ring);
+ --phrase-stroke-opacity: 1;
+ @apply tw:border-ring tw:bg-muted/50 tw:ring-1 tw:ring-ring;
+}
+
@utility phrase-dimmed {
--phrase-stroke: var(--border);
--phrase-stroke-opacity: 1;
diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts
index fd01cc7c..da46f9a9 100644
--- a/src/types/interlinearizer.d.ts
+++ b/src/types/interlinearizer.d.ts
@@ -162,11 +162,15 @@ declare module 'papi-shared-types' {
'interlinearizer.getProject': (interlinearProjectId: string) => Promise;
/**
- * Persists an updated `TextAnalysis` for an interlinearizer project. Called from the WebView
- * after each gloss write so that analysis changes survive tab restores and project switches.
+ * Persists an updated `TextAnalysis` (and optionally custom segment boundaries) for an
+ * interlinearizer project. Called from the WebView on Save so analysis and boundary changes
+ * survive tab restores and project switches.
*
* @param interlinearProjectId UUID of the interlinearizer project to update.
* @param analysisJson JSON-stringified `TextAnalysis` to persist.
+ * @param segmentationJson Optional JSON-stringified `SegmentationDelta` to persist, or the
+ * string `"null"` to clear any stored custom boundaries. Omit entirely to leave the project's
+ * existing boundaries unchanged.
* @returns Promise that resolves to void once the analysis has been written to storage.
* @throws If JSON parsing or storage fails. Error is logged and an error notification is sent
* before rethrowing so callers do not need to send a second notification.
@@ -174,6 +178,7 @@ declare module 'papi-shared-types' {
'interlinearizer.saveAnalysis': (
interlinearProjectId: string,
analysisJson: string,
+ segmentationJson?: string,
) => Promise;
/**
@@ -1095,7 +1100,47 @@ declare module 'interlinearizer' {
}
// ---------------------------------------------------------------------------
- // §6 InterlinearProject — persisted project envelope
+ // §6 SegmentationDelta — user-defined segment boundaries
+ // ---------------------------------------------------------------------------
+
+ /**
+ * A user's custom segment boundaries, stored as a **delta from the default one-segment-per-verse
+ * segmentation** rather than as explicit segment definitions.
+ *
+ * The text layer is rebuilt from USJ on every load as one `Segment` per verse (see {@link Book}).
+ * A segment is otherwise just a maximal contiguous run of the book's document-order token stream
+ * between "start" tokens; the default start tokens are each verse's first token. This delta
+ * records where the user's boundaries differ from that default:
+ *
+ * - A verse's first token listed in `removedVerseStarts` no longer starts a segment, so that verse
+ * is **merged** into the preceding segment.
+ * - A mid-verse token listed in `addedStarts` starts a new segment, **splitting** its verse.
+ *
+ * Boundaries are anchored to token refs (stable opaque ids), so the model degrades gracefully
+ * when the baseline text drifts: an anchor whose token no longer exists is ignored on load,
+ * leaving every other boundary intact. Because a segment can only ever be a contiguous run
+ * between start tokens, discontiguous segments are unrepresentable by construction.
+ *
+ * Absent (`undefined`) ⇒ the default verse segmentation. The empty delta (both arrays empty) is
+ * equivalent.
+ */
+ export interface SegmentationDelta {
+ /**
+ * Word-token refs that are a verse's first token in the default segmentation but should **not**
+ * start a segment — i.e. the verse is merged into the preceding segment. A ref whose token no
+ * longer exists is ignored on load.
+ */
+ removedVerseStarts: string[];
+
+ /**
+ * Mid-verse word-token refs that should start a new segment — i.e. the verse is split before
+ * this token. A ref whose token no longer exists is ignored on load.
+ */
+ addedStarts: string[];
+ }
+
+ // ---------------------------------------------------------------------------
+ // §7 InterlinearProject — persisted project envelope
// ---------------------------------------------------------------------------
/**
@@ -1166,6 +1211,13 @@ declare module 'interlinearizer' {
* aligns source and target tokens.
*/
links?: AlignmentLink[];
+
+ /**
+ * User-defined segment boundaries as a delta from the default verse segmentation. Absent
+ * (`undefined`) ⇒ the default one-segment-per-verse segmentation. See
+ * {@link SegmentationDelta}.
+ */
+ segmentation?: SegmentationDelta;
}
/**
@@ -1218,10 +1270,17 @@ declare module 'interlinearizer' {
* project.
*/
dirty: boolean;
+
+ /**
+ * User-defined segment boundaries being edited, as a delta from the default verse segmentation.
+ * Absent (`undefined`) ⇒ the default one-segment-per-verse segmentation. Carried to the active
+ * project on Save. See {@link SegmentationDelta}.
+ */
+ segmentation?: SegmentationDelta;
}
// ---------------------------------------------------------------------------
- // §7 ActiveProject — runtime pairing of project envelope and text layers
+ // §8 ActiveProject — runtime pairing of project envelope and text layers
// ---------------------------------------------------------------------------
/**
diff --git a/src/types/token-layout.ts b/src/types/token-layout.ts
index 3cf32287..8ba5d7e9 100644
--- a/src/types/token-layout.ts
+++ b/src/types/token-layout.ts
@@ -37,10 +37,18 @@ export type SlotFocusInfo = {
*/
focusedSideIsPrev: boolean | undefined;
/**
- * `true` when both slot neighbors are in the same segment as the focused token. Phrases cannot
- * span segments, so the link button is disabled when this is `false`.
+ * `true` when both slot neighbors are in the same segment as the focused token. Within one
+ * segment the link button joins tokens into a phrase as usual.
*/
isSameSegmentAsFocus: boolean;
+ /**
+ * `true` when this slot is the boundary between the focused token's segment and an immediately
+ * adjacent segment — i.e. one neighbor is in the focused segment and the other is in the segment
+ * directly before or after it in document order. The cross-segment link button is active only at
+ * these edges, so pulling an adjacent segment's edge token into the focused phrase moves the
+ * boundary by exactly one token and keeps both segments contiguous.
+ */
+ isAdjacentEdgeOfFocus: boolean;
/** The phrase containing the focused token, or `undefined` when the focused token is free. */
focusedPhraseLink: PhraseAnalysisLink | undefined;
/** The focused token when it is not part of any phrase ("free"); `undefined` otherwise. */
diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts
index 73482924..1dc7b2ba 100644
--- a/src/types/type-guards.ts
+++ b/src/types/type-guards.ts
@@ -1,5 +1,11 @@
/** @file Type guards for narrowing interlinearizer types and validating parsed JSON payloads. */
-import type { AssignmentStatus, DraftProject, TextAnalysis, Token } from 'interlinearizer';
+import type {
+ AssignmentStatus,
+ DraftProject,
+ SegmentationDelta,
+ TextAnalysis,
+ Token,
+} from 'interlinearizer';
import type { InterlinearProjectSummary } from './interlinear-project-summary';
/**
@@ -235,6 +241,27 @@ export function isTextAnalysis(value: unknown): value is TextAnalysis {
);
}
+/**
+ * Type guard for {@link SegmentationDelta} parsed from unknown JSON. Both arrays must be present and
+ * contain only strings, so a malformed delta is rejected before it can corrupt re-segmentation.
+ *
+ * @param value - The value to test, typically a parsed JSON object of unknown shape.
+ * @returns `true` if `value` satisfies the {@link SegmentationDelta} shape, narrowing its type
+ * accordingly.
+ */
+export function isSegmentationDelta(value: unknown): value is SegmentationDelta {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ 'removedVerseStarts' in value &&
+ Array.isArray(value.removedVerseStarts) &&
+ value.removedVerseStarts.every((r) => typeof r === 'string') &&
+ 'addedStarts' in value &&
+ Array.isArray(value.addedStarts) &&
+ value.addedStarts.every((r) => typeof r === 'string')
+ );
+}
+
/**
* Type guard for {@link DraftProject} parsed from unknown JSON. Validates the envelope fields and
* delegates the `analysis` to {@link isTextAnalysis}, so malformed drafts are rejected before
@@ -258,6 +285,7 @@ export function isDraftProject(value: unknown): value is DraftProject {
(!('targetProjectId' in value) || typeof value.targetProjectId === 'string') &&
(!('suggestedName' in value) || typeof value.suggestedName === 'string') &&
(!('suggestedDescription' in value) || typeof value.suggestedDescription === 'string') &&
+ (!('segmentation' in value) || isSegmentationDelta(value.segmentation)) &&
'analysis' in value &&
isTextAnalysis(value.analysis)
);
diff --git a/src/utils/phrase-arc.ts b/src/utils/phrase-arc.ts
index d781d7f4..3e81ab7d 100644
--- a/src/utils/phrase-arc.ts
+++ b/src/utils/phrase-arc.ts
@@ -165,6 +165,66 @@ export function splitPhraseAtBoundary(
dispatch.updatePhrase(phraseLink.analysisId, before.length >= 2 ? before : after);
}
+/**
+ * A phrase cut by a proposed segment boundary, paired with the in-phrase split point that puts each
+ * resulting fragment wholly on its own side of the boundary.
+ */
+export type StraddledPhrase = {
+ /** The phrase that would span the boundary. */
+ link: PhraseAnalysisLink;
+ /**
+ * The phrase's last token before the boundary; splitting just after it (via
+ * {@link splitPhraseAtBoundary}) severs the phrase cleanly at the boundary.
+ */
+ splitAfterTokenRef: string;
+};
+
+/**
+ * Finds every phrase that a segment boundary placed before `boundaryRef` would cut — phrases with
+ * tokens on both sides of the boundary in document order. A discontiguous phrase counts even when
+ * `boundaryRef` itself is not one of its tokens (the boundary can fall in the gap between two
+ * fragments). Used by the segmentation dispatch to force-break straddling phrases, and by the split
+ * control to suppress itself at boundaries that would force-break — one predicate, so the two can't
+ * drift apart.
+ *
+ * @param boundaryRef - Token ref the new segment would begin at.
+ * @param phraseLinks - The phrase links to test (one entry per phrase).
+ * @param tokenDocOrder - Map from token ref to flat document index. Must contain `boundaryRef` and
+ * the phrase tokens; a boundary ref absent from the map yields no matches.
+ * @returns The straddled phrases with their split points; empty when the boundary cuts nothing.
+ */
+export function phrasesStraddlingBoundary(
+ boundaryRef: string,
+ phraseLinks: Iterable,
+ tokenDocOrder: ReadonlyMap,
+): StraddledPhrase[] {
+ const boundaryOrder = tokenDocOrder.get(boundaryRef);
+ if (boundaryOrder === undefined) return [];
+ const straddled: StraddledPhrase[] = [];
+ Array.from(phraseLinks).forEach((link) => {
+ let lastBefore: TokenSnapshot | undefined;
+ let lastBeforeOrder = Number.NEGATIVE_INFINITY;
+ let hasAfter = false;
+ link.tokens.forEach((t) => {
+ const order = tokenDocOrder.get(t.tokenRef);
+ /* v8 ignore next -- phrase tokens are word tokens, which the doc-order map always contains */
+ if (order === undefined) return;
+ if (order < boundaryOrder) {
+ if (order > lastBeforeOrder) {
+ lastBefore = t;
+ lastBeforeOrder = order;
+ }
+ } else {
+ hasAfter = true;
+ }
+ });
+ if (lastBefore && hasAfter) {
+ straddled.push({ link, splitAfterTokenRef: lastBefore.tokenRef });
+ }
+ });
+ return straddled;
+}
+
// #endregion
// #region Arc geometry and strip sizing
diff --git a/src/utils/segment-labels.ts b/src/utils/segment-labels.ts
new file mode 100644
index 00000000..b8675b7a
--- /dev/null
+++ b/src/utils/segment-labels.ts
@@ -0,0 +1,104 @@
+/**
+ * @file Pure helpers deriving the display label of each segment in a (re)segmented book.
+ *
+ * After boundary edits (merge/split) a segment no longer corresponds 1:1 to a verse, so its label
+ * cannot simply be the start verse number. Labels stay verse-based: an unsplit verse's segment is
+ * labeled with its bare verse number, each portion of a split verse is lettered (`1a`, `1b`, …),
+ * and a segment spanning several verses shows the range between its two ends (`1c–2`, `29–2:1`).
+ *
+ * Every function here is pure and store-free (mirrors `segmentation.ts`).
+ */
+import type { ScriptureRef, Segment } from 'interlinearizer';
+
+/**
+ * Display label of one segment: the verse (or lettered verse portion) it starts at, extended with
+ * an en-dash range end when the segment spans more than one verse. Examples: `"5"` (whole verse),
+ * `"5a"` / `"5b"` (portions of a split verse), `"5b–7"` (a split portion merged with following
+ * verses), and `"29–2:1"` (a chapter-crossing segment; the end carries its chapter for
+ * disambiguation).
+ */
+export type SegmentLabel = string;
+
+/**
+ * Formats a zero-based portion index as a lowercase letter suffix — `a` through `z`, continuing
+ * bijectively (`aa`, `ab`, …) should a verse ever be split into more than 26 portions.
+ *
+ * @param index - Zero-based index of the portion within its verse.
+ * @returns The letter suffix for the portion.
+ */
+function portionLetter(index: number): string {
+ let remaining = index;
+ let letters = '';
+ do {
+ letters = String.fromCharCode(97 + (remaining % 26)) + letters;
+ remaining = Math.floor(remaining / 26) - 1;
+ } while (remaining >= 0);
+ return letters;
+}
+
+/**
+ * Keys a reference by chapter and verse so portions of the same verse collate together. The book is
+ * omitted: all segments of one call belong to the same book.
+ *
+ * @param ref - The reference to key.
+ * @returns The `chapter:verse` key.
+ */
+function verseKey(ref: ScriptureRef): string {
+ return `${ref.chapter}:${ref.verse}`;
+}
+
+/**
+ * Builds the display label of every segment in book order, keyed by segment id.
+ *
+ * A verse wholly inside one segment contributes a bare verse number; a verse divided across
+ * segments (by a split, or by a boundary sitting mid-verse) gets its portions lettered `a`, `b`, …
+ * in document order. Each segment's label is its start verse (with letter when that verse is
+ * divided), extended to `start–end` when the segment ends in a different verse, with the end
+ * qualified by its chapter (`start–chapter:verse`) when the segment crosses a chapter boundary.
+ *
+ * @param segments - The book's segments in document order.
+ * @returns Map from segment id to its {@link SegmentLabel}.
+ */
+export function buildSegmentLabels(segments: readonly Segment[]): Map {
+ // First pass: assign each segment its zero-based portion index within its start and end verses,
+ // and tally how many portions each verse is divided into. Segments are contiguous and in document
+ // order, so a verse's portions are consumed consecutively; once a segment ends past a verse, no
+ // later segment touches it.
+ const portionCounts = new Map();
+ const portionIndices = segments.map((seg) => {
+ const startKey = verseKey(seg.startRef);
+ const endKey = verseKey(seg.endRef);
+ const startPortion = portionCounts.get(startKey) ?? 0;
+ portionCounts.set(startKey, startPortion + 1);
+ let endPortion = startPortion;
+ if (endKey !== startKey) {
+ endPortion = portionCounts.get(endKey) ?? 0;
+ portionCounts.set(endKey, endPortion + 1);
+ }
+ return { startPortion, endPortion };
+ });
+
+ // Second pass: format each label, lettering only the ends whose verse is actually divided
+ // (portion count > 1) so unsplit verses keep their bare numbers.
+ const labels = new Map();
+ segments.forEach((seg, i) => {
+ const { startPortion, endPortion } = portionIndices[i];
+ /* v8 ignore next -- every start key was inserted in the first pass, so the ?? arm is unreachable */
+ const isStartSplit = (portionCounts.get(verseKey(seg.startRef)) ?? 0) > 1;
+ const startText = `${seg.startRef.verse}${isStartSplit ? portionLetter(startPortion) : ''}`;
+ if (seg.startRef.chapter === seg.endRef.chapter && seg.startRef.verse === seg.endRef.verse) {
+ labels.set(seg.id, startText);
+ return;
+ }
+ /* v8 ignore next -- every end key was inserted in the first pass, so the ?? arm is unreachable */
+ const isEndSplit = (portionCounts.get(verseKey(seg.endRef)) ?? 0) > 1;
+ const endText = `${seg.endRef.verse}${isEndSplit ? portionLetter(endPortion) : ''}`;
+ labels.set(
+ seg.id,
+ seg.startRef.chapter === seg.endRef.chapter
+ ? `${startText}–${endText}`
+ : `${startText}–${seg.endRef.chapter}:${endText}`,
+ );
+ });
+ return labels;
+}
diff --git a/src/utils/segmentation.ts b/src/utils/segmentation.ts
new file mode 100644
index 00000000..874d2577
--- /dev/null
+++ b/src/utils/segmentation.ts
@@ -0,0 +1,265 @@
+/**
+ * @file Pure transforms over a {@link SegmentationDelta} — the user's custom segment boundaries
+ * expressed as a delta from the default one-segment-per-verse segmentation.
+ *
+ * A segment is a maximal contiguous run of the book's document-order token stream between "start"
+ * tokens. The default start tokens are each verse's first token; the delta records where the
+ * user's boundaries differ (a removed verse start merges that verse into the previous segment; an
+ * added start splits a verse). Because a segment can only be a contiguous run between starts,
+ * discontiguous segments are unrepresentable.
+ *
+ * Every function here is pure and store-free (mirrors `phrase-arc.ts`). They take the original
+ * verse-tokenized {@link Book} (from `tokenizeBook`, before re-segmentation) so they can derive
+ * the default verse starts; they never need the re-segmented book.
+ */
+import type { Book, SegmentationDelta } from 'interlinearizer';
+
+/** An empty delta — equivalent to the default verse segmentation. */
+const EMPTY_DELTA: SegmentationDelta = { removedVerseStarts: [], addedStarts: [] };
+
+/**
+ * The ref of the book's very first token — the start of the first segment, which can never be
+ * merged leftward.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @returns The first token's ref, or `undefined` when the book has no tokens.
+ */
+function bookFirstTokenRef(verseBook: Book): string | undefined {
+ return verseBook.segments[0]?.tokens[0]?.ref;
+}
+
+/**
+ * The default segment-start refs — each verse segment's first token (of any type, so a verse's
+ * leading punctuation stays with that verse).
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @returns The set of first-token refs, one per verse segment that has tokens.
+ */
+export function defaultVerseStarts(verseBook: Book): Set {
+ const starts = new Set();
+ verseBook.segments.forEach((seg) => {
+ const first = seg.tokens[0];
+ if (first) starts.add(first.ref);
+ });
+ return starts;
+}
+
+/**
+ * Every token ref in the book, used to drop delta anchors whose token no longer exists.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @returns The set of all token refs.
+ */
+function allTokenRefs(verseBook: Book): Set {
+ const refs = new Set();
+ verseBook.segments.forEach((seg) => seg.tokens.forEach((t) => refs.add(t.ref)));
+ return refs;
+}
+
+/**
+ * Document-order index for every token ref, used to keep delta arrays canonically sorted.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @returns Map from token ref to its flat document index.
+ */
+function docOrder(verseBook: Book): Map {
+ const order = new Map();
+ let i = 0;
+ verseBook.segments.forEach((seg) =>
+ seg.tokens.forEach((t) => {
+ order.set(t.ref, i);
+ i += 1;
+ }),
+ );
+ return order;
+}
+
+/**
+ * The effective set of segment-start refs after applying `delta` to the default verse starts:
+ * `(defaults \ removedVerseStarts) ∪ addedStarts`, with added anchors dropped when their token no
+ * longer exists and the book's first token always forced to be a start. Shared with `resegmentBook`
+ * so re-segmentation and the editing operations agree on where boundaries fall.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The user's boundary delta, or `undefined` for the default segmentation.
+ * @returns The set of token refs that begin a segment.
+ */
+export function effectiveStarts(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+): Set {
+ const defaults = defaultVerseStarts(verseBook);
+ const removed = new Set(delta?.removedVerseStarts ?? []);
+ const starts = new Set();
+ defaults.forEach((ref) => {
+ if (!removed.has(ref)) starts.add(ref);
+ });
+ if (delta) {
+ const all = allTokenRefs(verseBook);
+ delta.addedStarts.forEach((ref) => {
+ if (all.has(ref)) starts.add(ref);
+ });
+ }
+ const first = bookFirstTokenRef(verseBook);
+ // The first segment can never be merged away, so its start is always present.
+ if (first !== undefined) starts.add(first);
+ return starts;
+}
+
+/**
+ * Returns a canonicalized copy of `delta`: each array deduped, stripped of no-op entries (a removed
+ * ref that is not a default start, or an added ref that is already a default start or whose token
+ * is gone), and sorted by document order so equal segmentations serialize identically.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The delta to canonicalize.
+ * @returns A normalized {@link SegmentationDelta}.
+ */
+function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta {
+ const defaults = defaultVerseStarts(verseBook);
+ const all = allTokenRefs(verseBook);
+ const order = docOrder(verseBook);
+ const first = bookFirstTokenRef(verseBook);
+ const byOrder = (a: string, b: string) =>
+ /* v8 ignore next -- ?? 0 fallback for refs absent from order; filtered arrays only hold real refs */
+ (order.get(a) ?? 0) - (order.get(b) ?? 0);
+
+ const removedVerseStarts = [...new Set(delta.removedVerseStarts)]
+ .filter((ref) => defaults.has(ref) && ref !== first)
+ .sort(byOrder);
+ const addedStarts = [...new Set(delta.addedStarts)]
+ .filter((ref) => all.has(ref) && !defaults.has(ref))
+ .sort(byOrder);
+
+ return { removedVerseStarts, addedStarts };
+}
+
+/**
+ * Makes `ref` begin a segment — i.e. splits before it.
+ *
+ * - When `ref` is a default verse start that was merged away, it is un-merged (dropped from
+ * `removedVerseStarts`).
+ * - Otherwise `ref` is recorded as an added start.
+ *
+ * No-op (returns an equivalent normalized delta) when `ref` already begins a segment.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The current delta, or `undefined` for the default segmentation.
+ * @param ref - The token ref that should begin a segment.
+ * @returns The updated, normalized delta.
+ */
+export function addBoundaryBefore(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+ ref: string,
+): SegmentationDelta {
+ const current = delta ?? EMPTY_DELTA;
+ const defaults = defaultVerseStarts(verseBook);
+ if (defaults.has(ref)) {
+ return normalize(verseBook, {
+ removedVerseStarts: current.removedVerseStarts.filter((r) => r !== ref),
+ addedStarts: current.addedStarts,
+ });
+ }
+ return normalize(verseBook, {
+ removedVerseStarts: current.removedVerseStarts,
+ addedStarts: [...current.addedStarts, ref],
+ });
+}
+
+/**
+ * Stops `ref` from beginning a segment — i.e. merges it into the preceding segment.
+ *
+ * - When `ref` is a default verse start, it is recorded in `removedVerseStarts`.
+ * - Otherwise (it was an added split) it is dropped from `addedStarts`.
+ *
+ * No-op when `ref` is the book's first token (the first segment cannot be merged leftward).
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The current delta, or `undefined` for the default segmentation.
+ * @param ref - The segment-start token ref to remove.
+ * @returns The updated, normalized delta.
+ */
+export function removeBoundaryAt(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+ ref: string,
+): SegmentationDelta {
+ const current = delta ?? EMPTY_DELTA;
+ if (ref === bookFirstTokenRef(verseBook)) return normalize(verseBook, current);
+ const defaults = defaultVerseStarts(verseBook);
+ if (defaults.has(ref)) {
+ return normalize(verseBook, {
+ removedVerseStarts: [...current.removedVerseStarts, ref],
+ addedStarts: current.addedStarts,
+ });
+ }
+ return normalize(verseBook, {
+ removedVerseStarts: current.removedVerseStarts,
+ addedStarts: current.addedStarts.filter((r) => r !== ref),
+ });
+}
+
+/**
+ * Moves a boundary from `fromRef` to `toRef` in one step — the primitive behind pulling a single
+ * edge token across a segment boundary. Removes the start at `fromRef` and adds one at `toRef`.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The current delta, or `undefined` for the default segmentation.
+ * @param fromRef - The current segment-start ref to remove.
+ * @param toRef - The new segment-start ref to add.
+ * @returns The updated, normalized delta.
+ */
+export function moveBoundary(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+ fromRef: string,
+ toRef: string,
+): SegmentationDelta {
+ return addBoundaryBefore(verseBook, removeBoundaryAt(verseBook, delta, fromRef), toRef);
+}
+
+/**
+ * Merges the segment that starts at `secondSegmentStartRef` into the segment before it. Thin alias
+ * for {@link removeBoundaryAt}, named for the explicit merge control.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The current delta, or `undefined` for the default segmentation.
+ * @param secondSegmentStartRef - The first-token ref of the segment being merged into its
+ * predecessor.
+ * @returns The updated, normalized delta.
+ */
+export function mergeSegments(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+ secondSegmentStartRef: string,
+): SegmentationDelta {
+ return removeBoundaryAt(verseBook, delta, secondSegmentStartRef);
+}
+
+/**
+ * Splits a segment so a new one begins at `ref`. Thin alias for {@link addBoundaryBefore}, named for
+ * the explicit split control.
+ *
+ * @param verseBook - The original verse-tokenized book.
+ * @param delta - The current delta, or `undefined` for the default segmentation.
+ * @param ref - The token ref the new segment should begin at.
+ * @returns The updated, normalized delta.
+ */
+export function splitSegmentBefore(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+ ref: string,
+): SegmentationDelta {
+ return addBoundaryBefore(verseBook, delta, ref);
+}
+
+/**
+ * Whether `delta` represents the default verse segmentation (absent or both arrays empty).
+ *
+ * @param delta - The delta to test.
+ * @returns `true` when applying `delta` yields the default segmentation.
+ */
+export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boolean {
+ return !delta || (delta.removedVerseStarts.length === 0 && delta.addedStarts.length === 0);
+}
diff --git a/src/utils/token-layout.ts b/src/utils/token-layout.ts
index 0c9f4c6d..4b3cd083 100644
--- a/src/utils/token-layout.ts
+++ b/src/utils/token-layout.ts
@@ -61,6 +61,9 @@ export function resolveFocusContext(
* @param focus - Resolved focus context for the whole strip.
* @param focusedSideIsPrev - The layout-specific bool indicating whether focus is start-ward of
* this slot.
+ * @param segmentOrder - Segment id → document-order index, used to detect when this slot is the
+ * boundary between the focused segment and an immediately adjacent one. Defaults to empty (no
+ * adjacency, e.g. single-segment SegmentView slots).
* @returns Slot focus info ready to pass as `slotFocus` to `MemoizedTokenLinkIcon`.
*/
export function resolveSlotFocus(
@@ -68,14 +71,34 @@ export function resolveSlotFocus(
nextSegmentId: string | undefined,
focus: FocusContext,
focusedSideIsPrev: boolean | undefined,
+ segmentOrder: ReadonlyMap = new Map(),
): SlotFocusInfo {
+ const { focusedSegmentId } = focus;
const isSameSegmentAsFocus =
- focus.focusedSegmentId !== undefined &&
- prevSegmentId === focus.focusedSegmentId &&
- nextSegmentId === focus.focusedSegmentId;
+ focusedSegmentId !== undefined &&
+ prevSegmentId === focusedSegmentId &&
+ nextSegmentId === focusedSegmentId;
+ // The slot is an adjacent edge when exactly one neighbor is the focused segment, the other is a
+ // different segment, and the two are neighbors in document order.
+ const isAdjacentEdgeOfFocus = (() => {
+ if (
+ focusedSegmentId === undefined ||
+ prevSegmentId === undefined ||
+ nextSegmentId === undefined
+ )
+ return false;
+ if (prevSegmentId === nextSegmentId) return false;
+ const focusedIsPrev = prevSegmentId === focusedSegmentId;
+ const focusedIsNext = nextSegmentId === focusedSegmentId;
+ if (!focusedIsPrev && !focusedIsNext) return false;
+ const prevIndex = segmentOrder.get(prevSegmentId);
+ const nextIndex = segmentOrder.get(nextSegmentId);
+ return prevIndex !== undefined && nextIndex !== undefined && nextIndex - prevIndex === 1;
+ })();
return {
focusedSideIsPrev,
isSameSegmentAsFocus,
+ isAdjacentEdgeOfFocus,
focusedPhraseLink: focus.focusedPhraseLink,
focusedFreeToken: focus.focusedFreeToken,
};
@@ -88,6 +111,7 @@ export function resolveSlotFocus(
export const NO_SLOT_FOCUS: SlotFocusInfo = {
focusedSideIsPrev: undefined,
isSameSegmentAsFocus: false,
+ isAdjacentEdgeOfFocus: false,
focusedPhraseLink: undefined,
focusedFreeToken: undefined,
};
diff --git a/src/utils/verse-ref.ts b/src/utils/verse-ref.ts
index 244ff79a..801c5764 100644
--- a/src/utils/verse-ref.ts
+++ b/src/utils/verse-ref.ts
@@ -1,5 +1,5 @@
import type { SerializedVerseRef } from '@sillsdev/scripture';
-import type { ScriptureRef } from 'interlinearizer';
+import type { ScriptureRef, Segment } from 'interlinearizer';
/**
* Whether `ref` and `scrRef` name the same verse, bridging `ScriptureRef`'s `chapter`/`verse` field
@@ -15,6 +15,29 @@ export function isSameVerse(ref: ScriptureRef, scrRef: SerializedVerseRef): bool
);
}
+/**
+ * Whether `segment`'s verse range contains the verse named by `scrRef`. Containment is verse-level:
+ * the segment contains the verse when the verse falls between the segment's start and end
+ * references (inclusive, ordered by chapter then verse) in the same book. Character anchors are
+ * ignored — after a mid-verse split, every portion of the split verse "contains" it. Used wherever
+ * a verse must resolve to the segment that owns it (navigation, active highlight), since after a
+ * merge or split a segment's start verse alone no longer identifies every verse it covers.
+ *
+ * @param segment - The segment whose verse range to test.
+ * @param scrRef - Verse coordinate in the platform's `SerializedVerseRef` shape.
+ * @returns `true` when the verse lies within the segment's range.
+ */
+export function segmentContainsVerse(segment: Segment, scrRef: SerializedVerseRef): boolean {
+ if (segment.startRef.book !== scrRef.book) return false;
+ const afterStart =
+ scrRef.chapterNum > segment.startRef.chapter ||
+ (scrRef.chapterNum === segment.startRef.chapter && scrRef.verseNum >= segment.startRef.verse);
+ const beforeEnd =
+ scrRef.chapterNum < segment.endRef.chapter ||
+ (scrRef.chapterNum === segment.endRef.chapter && scrRef.verseNum <= segment.endRef.verse);
+ return afterStart && beforeEnd;
+}
+
/**
* Converts an internal `ScriptureRef` to the platform's `SerializedVerseRef` shape, dropping any
* character anchor.
diff --git a/user-questions.md b/user-questions.md
index 0b9ee51d..59a59afa 100644
--- a/user-questions.md
+++ b/user-questions.md
@@ -236,3 +236,85 @@ to A/B the "screen fills with suggestions" concern):
blur?
Remove the demo toggle (and these affordances' tuning) once the treatment is decided.
+
+## User-defined segment boundaries
+
+Segments were previously fixed to verses (rebuilt from USJ on every load). Users can now define
+their own segment boundaries, with no dedicated edit mode:
+
+- The gap between two token groups shows an always-visible **split** control (start a new segment
+ at the next token), or a **merge** control when the gap is a segment boundary (combine the
+ segment into the one before it — this appears in the continuous strip, where adjacent segments
+ share a row).
+- In the segment list, an always-visible **merge** button sits between adjacent segment rows.
+- Linking a phrase across a verse boundary pulls the adjacent segment's free **edge** token into
+ the focused segment (only the immediate adjacent-edge link buttons are active for this).
+
+Boundaries are stored as a delta from the default verse segmentation on the draft and carried to
+the project on Save. The only structural rule is contiguity: a segment is a contiguous run of the
+book's tokens, so discontiguous segments are unrepresentable and only whole contiguous chunks can
+move between adjacent segments.
+
+Decisions made during development that we'd like reviewed:
+
+1. **Merged-segment separator.** When two verses are merged into one segment, their baseline texts
+ are joined with a single space. This is reasonable for whitespace-delimited scripts but wrong for
+ scriptio continua (Chinese, Thai, …) and for cases where the USFM implied a different break.
+ Should the separator be configurable per project/writing system, or derived from the source?
+
+2. **Split-segment baseline display.** A segment created by splitting a verse currently keeps only
+ the baseline text spanning **its own tokens** — `buildSegment` slices the verse baseline from the
+ first token's `charStart` to the last token's `charEnd` and shifts each token's offsets into the
+ new string, so the `baselineText.slice(charStart, charEnd) === surfaceText` invariant still holds.
+ The trade-off is that any whitespace or punctuation sitting between the split boundary and the
+ adjacent token's edge is dropped from both halves (e.g. the space at position 5 between "Alpha"
+ and "beta" in "Alpha beta." belongs to neither half). The alternative — keeping the whole verse's
+ baseline text under each half — avoids dropping edge characters but duplicates the verse text in
+ the baseline-text display mode. Current choice: trim each half to its own span, accepting the
+ dropped edge whitespace.
+
+3. **Free translation when merging.** A segment's free translation is keyed by segment id. An
+ untouched or merged segment keeps the **leading** verse's id (so its free translation survives);
+ the **absorbed** verse's free translation is retained in storage but hidden while merged, and
+ reappears if the segments are split back apart. Splitting keeps the first half's free translation
+ and starts later halves blank. Is "hide-and-restore" the desired behavior, or should merging
+ prompt the user to keep/discard the absorbed verse's translation?
+
+4. **Boundary edits and the unsaved indicator.** Merging/splitting/pulling a boundary marks the
+ draft dirty (lighting the tab `●`), exactly like a gloss edit. Confirm this is desired, or whether
+ boundary edits should be treated differently from analysis edits.
+
+5. **Boundary controls are always available (no edit mode).** There is no separate
+ boundary-editing mode: the merge/split controls share the gaps with the phrase link icons and
+ are always visible, as are the segment list's between-row merge buttons. (They disable, like the
+ link icons, while a phrase is being edited or an unlink is awaiting confirmation, so a boundary
+ edit can't re-segment the phrase that UI is operating on.) Always-visible keeps the controls
+ discoverable but places a scissors one small icon away from a link button at all times. Both
+ actions are cheaply reversible (merge undoes split and vice versa, and boundary edits never harm
+ phrases — see item 7). Two questions:
+ - Is the added visual density of always-visible in-gap controls acceptable, or should they be
+ revealed on hover instead? (A hover-reveal variant is a small change; we can ship both behind
+ a view toggle for field comparison if useful.)
+ - Is the misclick risk (scissors next to link icon) acceptable in practice?
+
+6. **Chapter superscriptions are ordinary segments.** A chapter heading (a `d` descriptive title,
+ e.g. a Psalm superscription) is extracted as a synthetic **verse 0** segment that sits in
+ document order between the previous chapter's last verse and the new chapter's verse 1. Verse 0
+ participates in boundary editing like any other segment: it can be merged into the previous
+ chapter's last verse, absorb the verse after it, or be split. This means a user can deliberately
+ (or accidentally) fold a Psalm title into verse text; the edit is always reversible by splitting
+ the heading back out. Two questions:
+ - Is "heading merges like any verse" acceptable, or should merging a superscription warn or be
+ prevented? (An earlier build treated verse 0 as a hard wall that no edit could touch; that
+ protection was removed in favor of uniform, predictable behavior.)
+ - When a heading is merged into a neighbor, its free translation follows the hide-and-restore
+ behavior of item 3 — confirm that parallels hold for headings too.
+
+7. **Boundaries that would cut a phrase.** The stored model accepts any contiguous re-segmentation,
+ including one that lands a boundary in the middle of an existing phrase; when that happens the
+ straddled phrase is **force-broken** — split at the boundary (a one-token side becomes a free
+ token again). The token-chip views never offer such an edit (the split control hides and the
+ cross-segment pull disables at boundaries that would cut a phrase), so today force-breaking can
+ only be triggered by future surfaces that re-segment without showing phrases. Confirm that
+ silent force-breaking (no confirmation prompt) is acceptable for those surfaces, given the
+ alternative is a phrase spanning two segments, which the editing model forbids.