+
{isDraftLoading ? (
// The store below waits for the draft: it seeds on mount alone, and the draft version
// that remounts it does not bump when the load completes. Nothing is lost by waiting —
// while the draft loads there is only ever a placeholder or an error panel to show.
- loadingOrErrorPanel
+
{loadingOrErrorPanel}
) : (
- // The store's lifetime is the draft's, not the loaded book's — it holds every book. Keyed
- // on the draft version because the seed is not reactive, so a wholesale replacement (New
- // / Open / Wipe) reseeds by remounting. Wrapping the loading and error branches too keeps
- // it alive across the gap while the next book's USJ is in flight.
+ // The store's lifetime is the draft's, not the loaded book's — it holds every book.
+ // Keyed on the draft version because the seed is not reactive, so a wholesale replacement
+ // (New / Open / Wipe) reseeds by remounting. Wrapping the loading and error branches too
+ // keeps it alive across the gap while the next book's USJ is in flight.
+ //
+ // Declared above the cross-book curtain, not inside it, so the catalog panel can read the
+ // store without being dimmed by it: a jump to a usage in another book fades the view it
+ // navigates, and fading the list the jump was made from along with it would blank the
+ // panel at precisely the moment it is being used.
- {bookArea}
+ {catalogOpen ? (
+
+
+ {bookArea}
+
+
+
+
+
+
+ ) : (
+ {bookArea}
+ )}
)}
diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts
index 75ef0b1a..19212efa 100644
--- a/src/hooks/useInterlinearizerBookData.ts
+++ b/src/hooks/useInterlinearizerBookData.ts
@@ -28,6 +28,12 @@ export interface UseInterlinearizerBookDataResult {
bookError: string | undefined;
/** Error thrown by {@link extractBookFromUsj} or {@link tokenizeBook}; `undefined` on success. */
tokenizeError: { message: string; raw: unknown } | undefined;
+ /**
+ * BCP 47 tag the book's text was tokenized under, `'und'` when the project declares none. Carries
+ * a tag whether or not `book` loaded, so source text can be collated or rendered before the text
+ * itself arrives.
+ */
+ writingSystem: string;
}
/**
@@ -103,5 +109,5 @@ export default function useInterlinearizerBookData({
bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`;
}
- return { book, isLoading, bookError, tokenizeError };
+ return { book, isLoading, bookError, tokenizeError, writingSystem: writingSystemTag };
}
diff --git a/src/hooks/usePanelResizeKeys.ts b/src/hooks/usePanelResizeKeys.ts
new file mode 100644
index 00000000..3e839f96
--- /dev/null
+++ b/src/hooks/usePanelResizeKeys.ts
@@ -0,0 +1,74 @@
+import { readDirection } from 'platform-bible-react/experimental';
+import { useCallback } from 'react';
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
+
+/**
+ * How far one arrow-key press resizes the panel, as a share of the group. Matches the step the
+ * platform handle takes, so an arrow moves the panel equally far whichever of the two answers it.
+ */
+const KEYBOARD_RESIZE_STEP = 0.05;
+
+/**
+ * Which way along the screen the handle travels to widen the panel: `-1` toward the screen's left,
+ * `1` toward its right. Read afresh on each press, so a panel that outlives a change of interface
+ * language resizes the way it is currently pointing.
+ */
+function widenTravel(): number {
+ return readDirection() === 'rtl' ? 1 : -1;
+}
+
+/** Which way along the screen a key moves the handle, `0` for a key that moves it nowhere. */
+function keyTravel(key: string): number {
+ if (key === 'ArrowLeft') return -1;
+ if (key === 'ArrowRight') return 1;
+ return 0;
+}
+
+/**
+ * Resizes a panel by key press: mirrored arrows for a right-to-left interface, and Home and End to
+ * either end of the range. Handles only what the platform resize handle leaves undone, and yields
+ * every other key to it, so the two together answer a full set.
+ *
+ * Sizes are shares of the group the panel is laid out in, `0.25` being a quarter of it.
+ *
+ * @param fraction - Share the panel currently holds, which a press resizes from.
+ * @param onFractionChange - Records a share a press asked for. Not called for a press that would
+ * leave the panel where it already is.
+ * @param bounds - Narrowest and widest shares a press may reach.
+ * @returns A `keydown` handler for the resize handle.
+ */
+export default function usePanelResizeKeys(
+ fraction: number,
+ onFractionChange: (fraction: number) => void,
+ bounds: { min: number; max: number },
+): (event: ReactKeyboardEvent) => void {
+ const { min, max } = bounds;
+
+ return useCallback(
+ (event: ReactKeyboardEvent) => {
+ // The keys below are recognized by name alone, so a modified press — Alt+Arrow, which some
+ // hosts navigate back on — would both resize the panel and swallow the host's shortcut.
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+
+ const travel = keyTravel(event.key);
+ // Left alone in a left-to-right interface, where the platform handle already reads these
+ // arrows the way the panel is pointing; stepping here as well would move it twice.
+ const mirrors = travel !== 0 && widenTravel() === 1;
+ const jumpTarget =
+ // eslint-disable-next-line no-nested-ternary
+ event.key === 'Home' ? min : event.key === 'End' ? max : undefined;
+ if (!mirrors && jumpTarget === undefined) return;
+
+ // Claims the press, which the platform handle honors by leaving a defaulted event alone.
+ event.preventDefault();
+
+ const next =
+ jumpTarget ??
+ Math.min(max, Math.max(min, fraction + travel * widenTravel() * KEYBOARD_RESIZE_STEP));
+ // An arrow held down at an end of the range repeats, and each repeat would otherwise put an
+ // unchanged layout through the store.
+ if (next !== fraction) onFractionChange(next);
+ },
+ [fraction, onFractionChange, min, max],
+ );
+}
diff --git a/src/main.ts b/src/main.ts
index 16fd3c8e..4a2f1e29 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -766,6 +766,19 @@ export async function activate(context: ExecutionActivationContext): Promise
{},
+ {
+ method: {
+ summary: 'Open the analysis catalog panel in the Interlinearizer WebView',
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
const saveCommandRegistration = await papi.commands.registerCommand(
'interlinearizer.save',
// Handled entirely in the WebView; backend registration makes the command known to the platform.
@@ -838,6 +851,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise;
+ /**
+ * Opens the analysis catalog panel in the Interlinearizer WebView, listing every analysis the
+ * draft records with its usage counts and locations. The backend registers this command to make
+ * it visible to the platform menu system; all logic executes in the WebView.
+ */
+ 'interlinearizer.openAnalysisCatalog': () => Promise;
+
/**
* Loads the interlinearizer project with the given UUID, including its full `TextAnalysis`. The
* WebView calls this when the active project changes to load the stored analysis.
diff --git a/src/utils/analysis-query.ts b/src/utils/analysis-query.ts
index e523611d..d80d3d82 100644
--- a/src/utils/analysis-query.ts
+++ b/src/utils/analysis-query.ts
@@ -6,6 +6,7 @@ import type {
TokenAnalysis,
TokenAnalysisLink,
} from 'interlinearizer';
+import type { Collator } from 'platform-bible-utils';
import { bookOfRef } from './analysis-book';
import { foldForSearch } from './search-fold';
import { firstVerseNumber } from './verse-ref';
@@ -84,9 +85,9 @@ export interface CatalogQuery {
sort: CatalogSort;
filters: CatalogFilters;
/** Collates surface forms, so ordering follows the source language rather than code points. */
- surfaceCollator: Intl.Collator;
+ surfaceCollator: Collator;
/** Collates glosses, so ordering follows the analysis language rather than code points. */
- glossCollator: Intl.Collator;
+ glossCollator: Collator;
}
/**
@@ -259,7 +260,7 @@ function compareFirstUsage(a: CatalogRow, b: CatalogRow): number {
* Orders two rows by gloss, an analysis with none in the scope's language coming after every one
* that has one.
*/
-function compareGloss(a: CatalogRow, b: CatalogRow, glossCollator: Intl.Collator): number {
+function compareGloss(a: CatalogRow, b: CatalogRow, glossCollator: Collator): number {
if (!a.gloss) return b.gloss ? 1 : 0;
if (!b.gloss) return -1;
return glossCollator.compare(a.gloss, b.gloss);
diff --git a/src/utils/language-tags.ts b/src/utils/language-tags.ts
index 8d929ea9..460e8e2e 100644
--- a/src/utils/language-tags.ts
+++ b/src/utils/language-tags.ts
@@ -1,3 +1,5 @@
+import { Collator } from 'platform-bible-utils';
+
/**
* Parses a comma-separated analysis-language field into BCP 47 tags. The single source of this
* parse, so no field can interpret the same input differently.
@@ -13,3 +15,18 @@ export function parseLanguageTags(input: string): string[] {
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
}
+
+/**
+ * A collator for `tag`, falling back to the host's default collation when the tag is unusable.
+ *
+ * Language tags reach this as free text — nothing checks them for BCP 47 structure on the way in —
+ * and constructing a collator for an unparsable tag throws, so it has to degrade to some ordering
+ * rather than take the view down.
+ */
+export function collatorForTag(tag: string): Collator {
+ try {
+ return new Collator(tag);
+ } catch {
+ return new Collator();
+ }
+}