- {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && (
-
- {displayContinuousScroll && (
-
-
+
+
+ {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && (
+
+ {phraseMode.kind === 'confirm-unlink' ? (
+
+ ) : (
+
+ )}
)}
+
+ {displayContinuousScroll && (
+
+
+
+ )}
-
+
+
-
+
);
}
diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx
index a1b78792..6dda5b9c 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}
/>
)}