diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index e0bbe074..d29628f1 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -201,7 +201,8 @@ "%interlinearizer_copyModal_create%": "Create copy", "%interlinearizer_copyModal_cancel%": "Cancel", "%interlinearizer_warning_pt9Sync_failed%": "The Paratext 9 data couldn't be refreshed; showing the last imported data.", - "%interlinearizer_modal_metadata_lastSynced_label%": "Last synced" + "%interlinearizer_modal_metadata_lastSynced_label%": "Last synced", + "%interlinearizer_error_load_import_analysis%": "Could not load the imported interlinear data. Please try again." } } } diff --git a/src/__tests__/components/ArcOverlay.test.tsx b/src/__tests__/components/ArcOverlay.test.tsx index d3acd4bc..1f15b135 100644 --- a/src/__tests__/components/ArcOverlay.test.tsx +++ b/src/__tests__/components/ArcOverlay.test.tsx @@ -121,6 +121,17 @@ describe('ArcOverlay', () => { expect(document.querySelector('path')).toBeInTheDocument(); }); + it('hides the split button but keeps the arc when the analysis is read-only', () => { + const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']); + renderOverlay({ + arcPaths: [makeArcPath('p1', 'tok-a')], + phraseLinkById: new Map([['p1', phraseLink]]), + readOnly: true, + }); + expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument(); + expect(document.querySelector('path')).toBeInTheDocument(); + }); + it('with simplifyPhrases on, keeps the split button for the focused phrase', () => { const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']); renderOverlay({ diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index a624945f..fe2db2ff 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -101,9 +101,12 @@ const mockPhraseLinkById = new Map(); /** Read once per `Interlinearizer` render, so this doubles as a render counter. */ let phraseLinkByIdMapReads = 0; +/** What the mocked `useAnalysisReadOnly` returns; module state, so each test resets it. */ +const mockReadOnly = { current: false }; + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, - useAnalysisReadOnly: () => false, + useAnalysisReadOnly: () => mockReadOnly.current, /** * Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of * scope. @@ -426,6 +429,7 @@ beforeEach(() => { capturedSegmentation = undefined; // The merge control's label comes from a localized string. mockKeyAsValueLocalizedStrings(); + mockReadOnly.current = false; }); describe('Interlinearizer', () => { @@ -1641,6 +1645,13 @@ describe('between-rows merge control', () => { expect(button).toHaveAttribute('title', 'Merge'); }); + it('renders no merge control while the analysis is read-only', () => { + mockReadOnly.current = true; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('segment-merge-indicator')).not.toBeInTheDocument(); + }); + it('renders no merge control while a phrase mode is active', () => { // A merge mid-mode could re-segment the phrase the mode UI is operating on, so the between-rows // control is omitted entirely (not merely disabled) throughout a phrase edit. diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 5bebca51..5d525eb8 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -183,6 +183,9 @@ type CapturedStoreProps = { /* eslint-enable react/no-unused-prop-types */ let capturedStoreProps: CapturedStoreProps | undefined; +/** The `initialAnalysis` each provider mount was seeded with, oldest first. */ +const seededAnalyses: (TextAnalysis | undefined)[] = []; + // Spy wrapper around the real provider rather than a replacement for it: the lifetime tests compare // store identity across a book change, so the store has to be genuine, while the props the loader // passes still need to be observable. @@ -193,8 +196,10 @@ jest.mock('../../components/AnalysisStore', () => { return { ...actual, /** Records the props, then renders the real provider unchanged. */ - AnalysisStoreProvider(props: CapturedStoreProps) { + AnalysisStoreProvider({ initialAnalysis, ...rest }: CapturedStoreProps) { + const props = { initialAnalysis, ...rest }; capturedStoreProps = props; + seededAnalyses.push(initialAnalysis); return createElement(actual.AnalysisStoreProvider, props); }, }; @@ -538,6 +543,7 @@ describe('InterlinearizerLoader', () => { beforeEach(() => { capturedInterlinearizerProps = undefined; capturedStoreProps = undefined; + seededAnalyses.length = 0; interlinearizerMountCount = 0; mockBookData(); mockOptimisticSetting(); @@ -1180,7 +1186,7 @@ describe('InterlinearizerLoader', () => { ); }); - it('returns to the plain view when an offer-run report is closed', async () => { + it('offers only Open on an offer-run report', async () => { mockOfferProbe(); mockImportCommands({ importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT }, @@ -1191,13 +1197,35 @@ describe('InterlinearizerLoader', () => { await userEvent.click( screen.getByRole('button', { name: '%interlinearizer_pt9ConvertPrompt_yes%' }), ); + await screen.findByTestId('pt9-import-report'); + expect( + screen.queryByRole('button', { name: '%interlinearizer_pt9ImportModal_close%' }), + ).not.toBeInTheDocument(); + }); + it('opens the conversion when an offer-run report is dismissed', async () => { + mockOfferProbe(); + mockImportCommands({ + importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT }, + }); + await act(async () => { + renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) }); + }); await userEvent.click( - screen.getByRole('button', { name: '%interlinearizer_pt9ImportModal_close%' }), + screen.getByRole('button', { name: '%interlinearizer_pt9ConvertPrompt_yes%' }), + ); + await screen.findByTestId('pt9-import-report'); + + await userEvent.click( + screen.getByRole('button', { name: '%interlinearizer_pt9ImportModal_open%' }), ); expect(screen.getByTestId('project-modals')).toHaveAttribute('data-modal', 'none'); + expect(screen.getByTestId('project-modals')).toHaveAttribute( + 'data-active-project-name', + 'Paratext 9 Interlinear', + ); }); it('persists the empty draft and runs no import on No', async () => { @@ -1324,6 +1352,54 @@ describe('InterlinearizerLoader', () => { ); }); + it('drops an Open whose summary arrives after the report was dismissed', async () => { + let releaseSummary = () => {}; + const summaryHeld = new Promise((resolve) => { + releaseSummary = resolve; + }); + let importDone = false; + mockSendCommand.mockImplementation(async (...args) => { + if (args[0] === 'interlinearizer.getProject') { + // Hold only the summary the report's Open asks for, not the import view's own load. + if (importDone) await summaryHeld; + return JSON.stringify({ ...FRESH_IMPORT_SUMMARY, analysis: emptyAnalysis() }); + } + if (args[0] === 'interlinearizer.importPt9Project') { + importDone = true; + return JSON.stringify({ + outcome: 'imported', + projectId: 'import-1', + report: IMPORT_REPORT, + }); + } + return JSON.stringify(emptyDraft(testProjectId)); + }); + await act(async () => { + renderLoader(); + }); + await userEvent.click(screen.getByTestId('tab-toolbar-project-menu')); + await userEvent.click(screen.getByTestId('select-modal-import-pt9')); + await screen.findByTestId('pt9-import-report'); + + await userEvent.click( + screen.getByRole('button', { name: '%interlinearizer_pt9ImportModal_open%' }), + ); + // Back out to the select modal while the summary is still in flight, then let it land. + await userEvent.click( + screen.getByRole('button', { name: '%interlinearizer_pt9ImportModal_close%' }), + ); + await act(async () => { + releaseSummary(); + await summaryHeld; + }); + + expect(screen.getByTestId('project-modals')).toHaveAttribute('data-modal', 'select'); + expect(screen.getByTestId('project-modals')).not.toHaveAttribute( + 'data-active-project-name', + 'Paratext 9 Interlinear', + ); + }); + it('returns to the select modal when an import report is closed', async () => { mockImportCommands({ importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT }, @@ -1447,6 +1523,38 @@ describe('InterlinearizerLoader', () => { ); }); + it('titles the open-path sync as a sync while it runs', async () => { + let releaseImport = () => {}; + const importHeld = new Promise((resolve) => { + releaseImport = resolve; + }); + mockSendCommand.mockImplementation(async (...args) => { + if (args[0] === 'interlinearizer.getProject') + return JSON.stringify({ ...FRESH_IMPORT_SUMMARY, analysis: emptyAnalysis() }); + if (args[0] === 'interlinearizer.importPt9Project') { + await importHeld; + return JSON.stringify({ outcome: 'imported', projectId: 'import-1' }); + } + return JSON.stringify(emptyDraft(testProjectId)); + }); + mockPdpGet.mockResolvedValue({ + getPt9InterlinearManifest: async () => ({ 'Lexicon.xml': 'bbbb2222' }), + }); + await act(async () => { + renderLoader(); + }); + await userEvent.click(screen.getByTestId('tab-toolbar-project-menu')); + await userEvent.click(screen.getByTestId('select-modal-open-import')); + + expect(await screen.findByTestId('pt9-import-running')).toHaveTextContent( + '%interlinearizer_pt9ImportModal_syncing%', + ); + await act(async () => { + releaseImport(); + await importHeld; + }); + }); + it('opens the stored import with a warning when the open-path sync fails', async () => { mockImportCommands(); mockPdpGet.mockRejectedValue(new Error('provider unavailable')); @@ -1712,6 +1820,51 @@ describe('InterlinearizerLoader', () => { severity: 'error', }); }); + + it('seeds the store with the synced analysis, not the one the sync replaced', async () => { + const syncedAnalysis = emptyAnalysis(); + syncedAnalysis.tokenAnalyses.push({ + ...FIXTURE_STAMPS, + id: 't-synced', + surfaceText: 'In', + gloss: { en: 'synced' }, + }); + let synced = false; + mockSendCommand.mockImplementation(async (...args) => { + if (args[0] === 'interlinearizer.getProject') + return JSON.stringify({ + ...FRESH_IMPORT_SUMMARY, + analysis: synced ? syncedAnalysis : emptyAnalysis(), + }); + if (args[0] === 'interlinearizer.importPt9Project') { + synced = true; + return JSON.stringify({ + outcome: 'imported', + projectId: 'import-1', + report: IMPORT_REPORT, + }); + } + return JSON.stringify(emptyDraft(testProjectId)); + }); + await renderImportView(); + + await userEvent.click(screen.getByTestId('pt9-sync-button')); + await screen.findByTestId('pt9-import-report'); + + expect(seededAnalyses.at(-1)).toEqual(syncedAnalysis); + }); + + it('says so in the view when the imported analysis fails to load', async () => { + jest.mocked(papi.notifications.send).mockRejectedValue(new Error('ui offline')); + mockSendCommand.mockImplementation(async (...args) => + args[0] === 'interlinearizer.getProject' ? '' : JSON.stringify(emptyDraft(testProjectId)), + ); + await act(async () => + renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_IMPORT_PROJECT }) }), + ); + + expect(await screen.findByTestId('import-load-error')).toBeInTheDocument(); + }); }); describe('modal interactions', () => { @@ -3059,6 +3212,7 @@ describe('analysis store lifetime', () => { probeWriteGloss = undefined; capturedInterlinearizerProps = undefined; capturedStoreProps = undefined; + seededAnalyses.length = 0; interlinearizerMountCount = 0; mockBookData(); mockOptimisticSetting(); diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 0532b4e7..c641b746 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -47,9 +47,12 @@ const mockUsePhraseDispatch = jest.fn, []>().m /** Stable mock fn capturing `useSegmentFreeTranslationDispatch` calls so tests can assert on them. */ const mockSegmentFreeTranslationDispatch = jest.fn(); +/** What the mocked `useAnalysisReadOnly` returns; module state, so each test resets it. */ +const mockReadOnly = { current: false }; + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, - useAnalysisReadOnly: () => false, + useAnalysisReadOnly: () => mockReadOnly.current, AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode; analysisLanguage: string }>) { return children; }, @@ -236,6 +239,7 @@ describe('SegmentView', () => { }); mockCandidateTokenRefs.current = new Set(); mockSplitFreeTokenRefs.current = new Set(); + mockReadOnly.current = false; }); it('renders word token chips in token-chip mode (default)', () => { @@ -595,6 +599,12 @@ describe('SegmentView', () => { expect(screen.queryByTestId('baseline-split-gap')).not.toBeInTheDocument(); }); + it('shows no split gap while the analysis is read-only, even with Alt held', () => { + mockReadOnly.current = true; + renderBaseline(); + expect(screen.queryByTestId('baseline-split-gap')).not.toBeInTheDocument(); + }); + it('splits at the resolved anchor on an Alt+click of the gap', () => { const { dispatch } = renderBaseline(); fireEvent.click(screen.getByTestId('baseline-split-gap'), { altKey: true }); diff --git a/src/components/ArcOverlay.tsx b/src/components/ArcOverlay.tsx index bb0efc3a..95704999 100644 --- a/src/components/ArcOverlay.tsx +++ b/src/components/ArcOverlay.tsx @@ -106,6 +106,11 @@ type ArcOverlayProps = Readonly<{ * keeps interactive controls only on the focused phrase. Arcs themselves are unaffected. */ simplifyPhrases?: boolean; + /** + * When `true`, no split button renders: splitting an arc mutates the analysis, which a read-only + * one does not allow. The arcs themselves still draw. + */ + readOnly?: boolean; }>; /** @@ -128,6 +133,7 @@ export function ArcOverlay({ onSplitHoverChange, onHoverPhrase, simplifyPhrases = false, + readOnly = false, }: ArcOverlayProps) { const [splitHoveredArc, setSplitHoveredArc] = useState(); @@ -288,7 +294,10 @@ export function ArcOverlay({ {hoveredArcPaths.map(renderArcPath)} )} + {/* The arcs above still draw read-only; only their split control goes away, since splitting + would mutate the analysis. */} {phraseMode.kind === 'view' && + !readOnly && sortedArcPaths // When simplifyPhrases is on, only the focused phrase keeps its split button; every // other phrase's button is hidden while its arc stays drawn. diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 3f25acc7..8d15428d 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -11,7 +11,7 @@ import type { ViewOptions } from '../types/view-options'; import { resolvedOrEmpty } from '../utils/localized-strings'; import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/token-layout'; import { buildVerseStartLabelsByTokenRef, slotVerseLabel } from '../utils/verse-superscripts'; -import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; +import { useAnalysisReadOnly, usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; import { PhraseStripProvider } from './PhraseStripContext'; import { PhraseStrip, LINK_SLOT_TRANSITION_MS, type StripItem } from './PhraseStripParts'; import { @@ -167,6 +167,7 @@ export default function ContinuousView({ const committedPhraseLinkByRef = usePhraseLinkMap(); const committedPhraseLinkById = usePhraseLinkByIdMap(); + const readOnly = useAnalysisReadOnly(); const editPhraseTokens = useEditPhraseTokens(phraseMode); @@ -1198,6 +1199,7 @@ export default function ContinuousView({ onSplitHoverChange={handleSplitHoverChange} onHoverPhrase={setHoveredPhraseId} simplifyPhrases={simplifyPhrases} + readOnly={readOnly} />
(undefined); + + /** Whether the import's analysis failed to load, which the view reports in place of the text. */ + const [importLoadFailed, setImportLoadFailed] = useState(false); useEffect(() => { if (!isImportView || !activeProject) { setImportAnalysis(undefined); + setImportLoadFailed(false); return undefined; } + setImportLoadFailed(false); let ignore = false; (async () => { try { @@ -331,12 +337,14 @@ function InterlinearizerLoaderInner({ if (isTextAnalysis(analysis)) { setImportAnalysis(analysis); } else { + setImportLoadFailed(true); await papi.notifications .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) .catch(() => {}); } } catch (e) { logger.error('Interlinearizer: failed to load the imported analysis', e); + if (!ignore) setImportLoadFailed(true); await papi.notifications .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) .catch(() => {}); @@ -586,6 +594,10 @@ function InterlinearizerLoaderInner({ const [modal, setModal] = useState('none'); + /** Tracks the open modal for async handlers that must not act on a dialog the user has left. */ + const modalRef = useRef(modal); + modalRef.current = modal; + /** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */ const [wipeModalOpen, setWipeModalOpen] = useState(false); @@ -729,11 +741,16 @@ function InterlinearizerLoaderInner({ [projectId, fetchSummary, setActiveProject], ); - /** Opens the freshly imported project from the report into the read-only view. */ + /** + * Opens the freshly imported project from the report into the read-only view. The report stays + * dismissable while the summary is fetched, so a summary arriving after the user left is + * dropped. + */ const handlePt9Open = useCallback(async () => { /* v8 ignore next -- Open only renders on a report, which always sets the imported id first */ if (!pt9ImportedId) return; const summary = await fetchSummary(pt9ImportedId); + if (modalRef.current !== 'importPt9') return; if (summary) { setActiveProject(summary); setModal('none'); @@ -760,7 +777,7 @@ function InterlinearizerLoaderInner({ /** * Declines the first-open offer (dismissing the dialog means No): persists the empty draft so the - * offer never repeats for this source, then continues into the draft as an open does today. + * offer never repeats for this source, then continues into the draft as an ordinary open does. */ const handlePt9OfferNo = useCallback(() => { setOfferPt9Import(false); @@ -776,8 +793,8 @@ function InterlinearizerLoaderInner({ /** * Creates the editable copy and asks {@link ProjectModals} to open it through the normal - * draft-open flow, so the existing unsaved-work protection applies unchanged. The command sends - * its own error notification; here we only log. + * draft-open flow, so the unsaved-work protection applies unchanged. The command owns the error + * notification. */ const handleCopySubmit = useCallback( async (name: string, description?: string) => { @@ -1052,6 +1069,12 @@ function InterlinearizerLoaderInner({ {resolvedOrEmpty(localizedStrings['%interlinearizer_loading%'])}

)} + + {!hasError && !showLoading && importLoadFailed && ( +

+ {localizedStrings['%interlinearizer_error_load_import_analysis%']} +

+ )}
); @@ -1258,7 +1281,8 @@ function InterlinearizerLoaderInner({ {modal === 'importPt9' && ( diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index 1e5de611..a1e1ba9e 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -47,8 +47,7 @@ function PhraseGlossInput({ setDraft(committed); }, [committed]); - // Surface uncommitted typing to the unsaved indicator before the gloss commits on blur. A - // read-only phrase has no input, so it never reports. + // Surface uncommitted typing to the unsaved indicator before the gloss commits on blur. useReportGlossEditing(!disabled && !readOnly && draft !== committed); // A read-only analysis shows the phrase gloss as plain text, not as an input. diff --git a/src/components/SegmentFreeTranslationInput.tsx b/src/components/SegmentFreeTranslationInput.tsx index 283a04fc..dbb653c6 100644 --- a/src/components/SegmentFreeTranslationInput.tsx +++ b/src/components/SegmentFreeTranslationInput.tsx @@ -44,8 +44,7 @@ export default function SegmentFreeTranslationInput({ setDraft(committed); }, [committed]); - // Surface uncommitted typing to the unsaved indicator before the translation commits on blur. A - // read-only segment has no input, so it never reports. + // Surface uncommitted typing to the unsaved indicator before the translation commits on blur. useReportGlossEditing(!readOnly && draft !== committed); // A read-only analysis shows the free translation as plain text - or nothing when it has none - diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx index 435e4a45..1f7d2824 100644 --- a/src/components/SegmentListView.tsx +++ b/src/components/SegmentListView.tsx @@ -15,6 +15,7 @@ import { buildSegmentLabels } from '../utils/segment-labels'; import { segmentContainsVerse } from '../utils/verse-ref'; import { buildVerseStartLabels } from '../utils/verse-superscripts'; import { useAltHeldValue } from './AltHeldContext'; +import { useAnalysisReadOnly } from './AnalysisStore'; import { useFocus, useFocusActions } from './FocusStore'; import { useSegmentation } from './SegmentationStore'; import MemoizedSegmentView from './SegmentView'; @@ -195,6 +196,7 @@ export default function SegmentListView({ const { selectSegment } = useFocusActions(); const [localizedStrings] = useLocalizedStrings(HEADER_STRING_KEYS); + const readOnly = useAnalysisReadOnly(); const recenterTooltip = tooltipContentOrUndefined( resolvedOrEmpty(localizedStrings['%interlinearizer_segmentList_scrollToActiveVerse%']), ); @@ -421,8 +423,9 @@ export default function SegmentListView({ // segment's boundary with a culled predecessor is still editable. const canMerge = mergeableSegmentIds.has(seg.id); // Omit the merge control while a phrase mode is active: a merge could re-segment the - // phrase the mode UI is operating on. - const showMergeControl = canMerge && phraseMode.kind === 'view'; + // phrase the mode UI is operating on. A read-only analysis offers no boundary editing + // at all. + const showMergeControl = canMerge && phraseMode.kind === 'view' && !readOnly; return ( {showMergeControl && } diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index 0f55d614..563d0453 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -19,7 +19,7 @@ import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/tok import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings'; import { resolveSplitAnchor } from '../utils/split-anchor'; import { slotVerseLabel, verseStartToken } from '../utils/verse-superscripts'; -import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; +import { useAnalysisReadOnly, usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; import { useAltHeldValue } from './AltHeldContext'; import MemoizedArcOverlay from './ArcOverlay'; import SegmentFreeTranslationInput from './SegmentFreeTranslationInput'; @@ -302,6 +302,7 @@ export function SegmentView({ const [localizedStrings] = useLocalizedStrings(STRING_KEYS); const { dispatch, formerBoundaries, straddledBoundaryRefs } = useSegmentation(); + const readOnly = useAnalysisReadOnly(); const phraseLinkByRef = usePhraseLinkMap(); const phraseLinkById = usePhraseLinkByIdMap(); @@ -375,7 +376,8 @@ export function SegmentView({ */ const splitGapByOffset = useMemo(() => { const map = new Map(); - if (phraseMode.kind !== 'view') return map; + // A read-only analysis offers no boundary editing, so no gap is splittable. + if (readOnly || phraseMode.kind !== 'view') return map; const { tokens, baselineText } = segment; const tokenByRef = new Map(tokens.map((t) => [t.ref, t])); let prevWord: Token | undefined; @@ -398,7 +400,7 @@ export function SegmentView({ pendingPunct = []; }); return map; - }, [segment, phraseMode.kind, straddledBoundaryRefs, formerBoundaries]); + }, [segment, phraseMode.kind, straddledBoundaryRefs, formerBoundaries, readOnly]); /** * The ordered baseline-text render pieces: plain-text runs, inline verse superscripts, and @@ -758,6 +760,7 @@ export function SegmentView({ onSplitHoverChange={handleSplitHoverChange} onHoverPhrase={onHoverPhrase} simplifyPhrases={simplifyPhrases} + readOnly={readOnly} /> ) * report; `offer` titles it the same but the report carries a single Open, dismissal included - * the user already chose to convert, so the report is information on the way in, not a fork; * `sync` titles it as a sync and offers only Close (the project is already open). - * @param props.onOpen - Called when the user opens the imported project from the report; only - * rendered in `import` mode. + * @param props.onOpen - Called when the user opens the imported project from the report; not + * offered in `sync` mode, where the project is already open. * @param props.onClose - Called when the user dismisses the report or failure. */ export function Pt9ImportModal({