Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"%webView_platformScriptureEditor_configureChecks%": "Configure Checks...",
"%webView_platformScriptureEditor_copyrightInfo%": "Copyright Info",
"%webView_platformScriptureEditor_edit%": "Edit",
"%webView_platformScriptureEditor_emptyState_noProject%": "No project selected",
"%webView_platformScriptureEditor_error_bookNotFoundProject%": "This book does not exist in this project. Use Paratext 9 to create the book and try again.",
"%webView_platformScriptureEditor_error_bookNotFoundResource%": "This book does not exist in this resource.",
"%webView_platformScriptureEditor_error_permissions_format%": "Project {projectName}: You do not have permission to edit this chapter.",
Expand Down Expand Up @@ -133,6 +134,7 @@
"%webView_platformScriptureEditor_configureChecks%": "Configurar verificaciones...",
"%webView_platformScriptureEditor_copyrightInfo%": "Información de copyright",
"%webView_platformScriptureEditor_edit%": "Editar",
"%webView_platformScriptureEditor_emptyState_noProject%": "Ningún proyecto seleccionado",
"%webView_platformScriptureEditor_error_bookNotFoundProject%": "Este libro no existe en este proyecto. Use Paratext 9 para crear el libro y vuelva a intentarlo.",
"%webView_platformScriptureEditor_error_bookNotFoundResource%": "Este libro no existe en este recurso.",
"%webView_platformScriptureEditor_error_permissions_format%": "Proyecto {projectName}: No tiene permiso para editar este capítulo.",
Expand Down
85 changes: 70 additions & 15 deletions extensions/src/platform-scripture-editor/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
convertScriptureRangeToEditorRange,
formatEditorTitle,
openCommentListAndSelectThread,
resolveOpenEditorDispatch,
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
} from './platform-scripture-editor.utils';
import { MarkersViewNotifier } from './markers-view-notifier.model';
Expand Down Expand Up @@ -185,17 +186,50 @@ async function open(
projectForWebView.isEditable = await pdp.getSetting('platform.isEditable');
}
if (projectForWebView.projectId) {
// Decide where to route this open. The dispatch helper centralizes the simple-mode invariants
// (one editor slot, no duplicate-(project, readonly) tabs) and the empty-editor probe; see
// resolveOpenEditorDispatch JSDoc for the priority order.
const allOpenDefs = await papi.webViews.getAllOpenWebViewDefinitions();
const allScriptureEditors = allOpenDefs
.filter((def) => def.webViewType === SCRIPTURE_EDITOR_WEBVIEW_TYPE)
.map((def) => ({
id: def.id,
projectId: def.projectId,
// WebView state isn't statically typed, but `getWebViewDefinition` always stores
// `isReadOnly` as boolean here. Treat any other value as `false` for safety.
// eslint-disable-next-line no-type-assertion/no-type-assertion
isReadOnly: !!(def.state?.isReadOnly as boolean | undefined),
}));
const interfaceMode = await papi.settings.get('platform.interfaceMode');
const requestedIsReadOnly = !projectForWebView.isEditable;

const dispatch = resolveOpenEditorDispatch(
allScriptureEditors,
projectForWebView.projectId,
requestedIsReadOnly,
interfaceMode,
existingTabIdToReplace,
);

// Focus path: the requested project is already open. Bring the existing tab to the front
// without tearing down the editor or re-running its WebView provider.
if (dispatch.kind === 'focus-existing') {
return papi.webViews.openWebView(SCRIPTURE_EDITOR_WEBVIEW_TYPE, undefined, {
existingId: dispatch.existingId,
createNewIfNotFound: false,
bringToFront: true,
});
}

const openWebViewOptions: PlatformScriptureEditorOptions = {
projectId: projectForWebView.projectId,
isReadOnly: !projectForWebView.isEditable,
options,
};
// REVIEW: If an editor is already open for the selected project, we open another.
// This matches the current behavior in P9, though it might not be what we want long-term.
return papi.webViews.openWebView(
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
existingTabIdToReplace
? { type: 'replace-tab', targetTabId: existingTabIdToReplace }
dispatch.kind === 'replace-tab'
? { type: 'replace-tab', targetTabId: dispatch.targetTabId }
: undefined,
openWebViewOptions,
);
Expand Down Expand Up @@ -384,9 +418,16 @@ class ScriptureEditorWebViewFactory extends WebViewFactory<typeof SCRIPTURE_EDIT
* selection.
*/
let currentSelection: ScriptureRangeUsjVerseRefChapterLocation | undefined;
/** Variable we will use to wait to get the first selection reported from the editor */
const firstSelectionAsync: AsyncVariable<ScriptureRangeUsjVerseRefChapterLocation | undefined> =
new AsyncVariable(`platformScriptureEditor.selection.${currentWebViewDefinition.id}`);
/**
* Variable used to block `getSelection()` callers until the editor reports its first selection.
* Constructed lazily on the first `getSelection()` call so its internal 10s timeout timer never
* starts if nobody is waiting. If a selection arrives before any `getSelection()` call,
* `currentSelection` populates and this variable is never constructed at all — which is the
* common case in Platform.Bible when the editor is opened without a `projectId`.
*/
let firstSelectionAsync:
| AsyncVariable<ScriptureRangeUsjVerseRefChapterLocation | undefined>
| undefined;
return {
async selectRange(range) {
try {
Expand Down Expand Up @@ -639,11 +680,18 @@ class ScriptureEditorWebViewFactory extends WebViewFactory<typeof SCRIPTURE_EDIT
}
},
async getSelection() {
// If we haven't yet received the first selection, wait for it
if (!firstSelectionAsync.hasSettled) {
return firstSelectionAsync.promise;
// If we already have a selection, return it directly.
if (currentSelection !== undefined) return currentSelection;
// Otherwise lazy-construct the AsyncVariable so its 10s timer only starts when a caller
// is actually waiting. Subsequent callers reuse the same promise — including the cached
// rejection if the timeout already fired, so retries get the same rejection rather than
// a silent `undefined`.
if (!firstSelectionAsync) {
firstSelectionAsync = new AsyncVariable(
`platformScriptureEditor.selection.${currentWebViewDefinition.id}`,
);
}
return currentSelection;
return firstSelectionAsync.promise;
},
async updateSelectionInternal(selection) {
const webViewId = currentWebViewDefinition.id;
Expand All @@ -652,14 +700,21 @@ class ScriptureEditorWebViewFactory extends WebViewFactory<typeof SCRIPTURE_EDIT
if (deepEqual(currentSelection, selection)) return;

currentSelection = selection;
// Resolve the first selection async variable with the first selection we get
if (!firstSelectionAsync.hasSettled) firstSelectionAsync.resolveToValue(selection);
// Resolve the first selection async variable IF it was lazy-constructed by a caller.
// If no caller has called getSelection() yet, firstSelectionAsync is still undefined and
// there is nothing to resolve — `currentSelection` is the source of truth from here.
if (firstSelectionAsync && !firstSelectionAsync.hasSettled) {
firstSelectionAsync.resolveToValue(selection);
}
selectionChangedEventEmitter?.emit({ webViewId, selection });
},
async dispose() {
currentSelection = undefined;
// If we never got a selection, reject the first selection promise
firstSelectionAsync.rejectWithReason('Disposed before first selection received');
// If a caller lazy-constructed the AsyncVariable and it hasn't settled, reject it so
// any pending `getSelection()` callers fail fast rather than waiting for the 10s timeout.
if (firstSelectionAsync && !firstSelectionAsync.hasSettled) {
firstSelectionAsync.rejectWithReason('Disposed before first selection received');
}
return unsubFromWebViewUpdates();
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, it, expect, vi } from 'vitest';
import { ScriptureRange } from 'platform-scripture-editor';
import type PapiBackend from '@papi/backend';
import { UsjTextContentLocation } from 'platform-bible-utils';
import { convertScriptureRangeToEditorRange } from './platform-scripture-editor.utils';
import {
convertScriptureRangeToEditorRange,
resolveOpenEditorDispatch,
type OpenEditorDispatch,
} from './platform-scripture-editor.utils';

// Sample USJ chapter data for Genesis chapter 1 with multiple verses
const SAMPLE_USJ_CHAPTER = Object.freeze({
Expand Down Expand Up @@ -396,3 +400,158 @@ describe('convertScriptureRangeToEditorRange', () => {
});
});
});

// #region resolveOpenEditorDispatch

// Helper: build a minimal "Scripture editor" web view definition record for the dispatch helper.
// `resolveOpenEditorDispatch` only reads `id`, `projectId`, and `isReadOnly`, so a partial object
// is sufficient.
type ScriptureEditorDef = { id: string; projectId?: string; isReadOnly?: boolean };

describe('resolveOpenEditorDispatch', () => {
it('simple mode + caller override + different project: caller override is ignored, replaces first editor', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-other', projectId: 'PROJ_X', isReadOnly: false },
];
const result: OpenEditorDispatch = resolveOpenEditorDispatch(
editors,
'WEB',
false,
'simple',
'caller-supplied-tab-id',
);
// Simple-mode invariant: every open routes to the editor column. The caller's tab is not the
// editor, so we ignore it and replace the existing editor instead.
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'editor-other' });
});

it('simple mode + caller override + same project open: focuses the existing tab (caller override is ignored)', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(
editors,
'WEB',
false,
'simple',
'caller-supplied-tab-id',
);
// Simple-mode invariant: caller override is ignored. We always route to the editor column,
// and since the requested project is already in the editor column we focus that tab.
expect(result).toEqual({ kind: 'focus-existing', existingId: 'editor-web' });
});

it('power mode + caller override + same project open: caller override still wins (no focus rule)', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(
editors,
'WEB',
false,
'power',
'caller-supplied-tab-id',
);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'caller-supplied-tab-id' });
});

it('power mode + caller override + no editors: replace-tab on the caller-supplied target', () => {
const editors: ScriptureEditorDef[] = [];
const result = resolveOpenEditorDispatch(
editors,
'WEB',
false,
'power',
'caller-supplied-tab-id',
);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'caller-supplied-tab-id' });
});

it('simple mode: returns focus-existing when an editor for the same project (both editable) is already open', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'simple', undefined);
expect(result).toEqual({ kind: 'focus-existing', existingId: 'editor-web' });
});

it('simple mode: returns focus-existing when a read-only viewer for the same project is already open and a read-only viewer is requested', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'viewer-web', projectId: 'WEB', isReadOnly: true },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', true, 'simple', undefined);
expect(result).toEqual({ kind: 'focus-existing', existingId: 'viewer-web' });
});

it('simple mode: replaces an editable editor when a read-only viewer is requested for the same project', () => {
// Read-only Resource Viewers and editable Scripture Editors share the same webViewType but
// are different views. The single editor slot can only host one at a time, so requesting
// the opposite mode should replace the existing tab, not focus it.
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', true, 'simple', undefined);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'editor-web' });
});

it('simple mode: replaces a read-only viewer when an editable editor is requested for the same project', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'viewer-web', projectId: 'WEB', isReadOnly: true },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'simple', undefined);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'viewer-web' });
});

it('simple mode: returns replace-tab on the first existing editor when the requested project differs', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-a', projectId: 'PROJ_A', isReadOnly: false },
{ id: 'editor-b', projectId: 'PROJ_B', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'PROJ_C', false, 'simple', undefined);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'editor-a' });
});

it('simple mode: returns replace-tab on the empty placeholder editor when no project editors exist', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'empty-editor', projectId: undefined, isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'simple', undefined);
// Same-(project, readonly) lookup misses (no projectId on the empty one). Simple-mode then
// replaces the first existing editor — which happens to be the empty placeholder.
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'empty-editor' });
});

it('simple mode: returns open-new when no Scripture Editors are open at all', () => {
const editors: ScriptureEditorDef[] = [];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'simple', undefined);
expect(result).toEqual({ kind: 'open-new' });
});

it('power mode: only the empty-editor probe applies — same project clicked twice does not focus', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'power', undefined);
// No empty editor and no caller override → fall through to open-new (P9-style two tabs).
expect(result).toEqual({ kind: 'open-new' });
});

it('power mode: falls back to empty-editor probe when one exists', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'empty-editor', projectId: undefined, isReadOnly: false },
];
const result = resolveOpenEditorDispatch(editors, 'WEB', false, 'power', undefined);
expect(result).toEqual({ kind: 'replace-tab', targetTabId: 'empty-editor' });
});

it('power mode: isReadOnly is ignored — same project, opposite readonly does not focus', () => {
const editors: ScriptureEditorDef[] = [
{ id: 'editor-web', projectId: 'WEB', isReadOnly: false },
];
// Power mode never focuses, regardless of (project, readonly) alignment.
const result = resolveOpenEditorDispatch(editors, 'WEB', true, 'power', undefined);
expect(result).toEqual({ kind: 'open-new' });
});
});

// #endregion resolveOpenEditorDispatch
Loading
Loading