diff --git a/REVIEW.md b/REVIEW.md index 2db5e0f7..d23a8f87 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -62,7 +62,7 @@ The merge control's **tooltip** runs its localized strings through `resolvedOrEm ## A `title` in a test assertion does not mean the code renders a `title` attribute -Every tooltip in this extension goes through the platform `Tooltip`/`TooltipContent`; none sets an HTML `title` attribute. Tests nevertheless assert `toHaveAttribute('title', …)` because the `Tooltip` stub in [\_\_mocks\_\_/platform-bible-react.tsx](__mocks__/platform-bible-react.tsx) reads its `TooltipContent` child's text and clones the trigger with that text as a `title`, which keeps the tooltip assertable without simulating hover in jsdom. The `title` **prop** some components take (the boundary button's, for one) is likewise just a prop name; it is rendered as `TooltipContent` children. +Every tooltip in this extension goes through the platform `Tooltip`/`TooltipContent`, with one documented exception: the usage counts in [src/components/CatalogRowView.tsx](src/components/CatalogRowView.tsx) sit inside the row's own button, where a tooltip trigger would nest one interactive element in another, so they carry a native `title` and repeat their label in screen-reader-only text. Outside that exception, no component sets an HTML `title` attribute. Tests nevertheless assert `toHaveAttribute('title', …)` because the `Tooltip` stub in [\_\_mocks\_\_/platform-bible-react.tsx](__mocks__/platform-bible-react.tsx) reads its `TooltipContent` child's text and clones the trigger with that text as a `title`, which keeps the tooltip assertable without simulating hover in jsdom. The `title` **prop** some components take (the boundary button's, for one) is likewise just a prop name; it is rendered as `TooltipContent` children. So do not conclude from either signal that a control is limited to plain text — for instance, that it cannot hold a `Kbd` or any other element. Read the component's own JSX before claiming a render path is text-only. diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index 12bd9903..38b2544b 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -84,3 +84,17 @@ export function Merge(props: Readonly<{ className?: string }>): ReactElement { export function Split(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; } + +/** + * Stub for the ChevronRight icon, marking a collapsed catalog row. + */ +export function ChevronRight(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * Stub for the ChevronDown icon, marking an expanded catalog row. + */ +export function ChevronDown(props: Readonly<{ className?: string }>): ReactElement { + return ; +} diff --git a/__mocks__/platform-bible-react-experimental.ts b/__mocks__/platform-bible-react-experimental.ts new file mode 100644 index 00000000..b5509820 --- /dev/null +++ b/__mocks__/platform-bible-react-experimental.ts @@ -0,0 +1,16 @@ +/** + * @file Jest mock for platform-bible-react/experimental. The real package ships ESM which Jest + * cannot parse without extra transform configuration. This stub provides the subset used by the + * extension. + */ + +/** Text and layout direction. */ +export type Direction = 'rtl' | 'ltr'; + +/** + * Layout direction the interface runs in, read from the document rather than from localStorage as + * the real function does, so a test sets it the way it would for any other RTL assertion. + */ +export function readDirection(): Direction { + return document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr'; +} diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index f62ecdd6..31d15acf 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -24,6 +24,7 @@ import type { MouseEventHandler, ReactElement, ReactNode, + RefObject, } from 'react'; export interface MenuItemContainingCommand { @@ -112,6 +113,14 @@ export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = { localizeNotes: '', }; +/** Sentinel menu item passed by the mock toolbar when the analysis-catalog button is clicked. */ +export const MOCK_OPEN_ANALYSIS_CATALOG_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_openAnalysisCatalog%', + command: 'interlinearizer.openAnalysisCatalog', + group: 'interlinearizer.viewActions', + order: 1, + localizeNotes: '', +}; /** * Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests @@ -195,6 +204,15 @@ export function TabToolbar({ Wipe )} + {onSelectProjectMenuItem && ( + + )} {onSelectViewInfoMenuItem && ( + + + {rows.length === 0 ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index bc233e2a..eda99e79 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -18,6 +18,7 @@ import { selectAnalysisLanguage, selectApprovedGloss, selectApprovedMorphemes, + selectCatalogRows, selectMorphemeResetLosesGlosses, selectPhraseLinkByAnalysisId, selectPhraseLinkByTokenRef, @@ -33,6 +34,7 @@ import { writeSegmentFreeTranslation, } from '../store/analysisSlice'; import { emptyAnalysis } from '../types/empty-factories'; +import type { CatalogRow } from '../utils/analysis-query'; import { resolvedTokenAnalysisEqual, type ResolvedTokenAnalysis } from '../utils/suggestion-engine'; // #region Internal context @@ -364,6 +366,23 @@ export function useMorphemeResetLosesGlosses(tokenRef: string): boolean { ); } +/** + * Returns one row per distinct token analysis in the draft, each carrying the usage data the + * analysis catalog lists it by, in the analysis's own order. Narrowing and ordering are the + * caller's, so a keystroke re-runs only that pass. + * + * The result keeps its reference while the analyses and their links keep theirs, so an unrelated + * write — a free translation, a phrase link — leaves the list unrendered. It changes with + * `currentBook`, which the per-book usage count is taken against. + * + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useCatalogRows(currentBook: string): readonly CatalogRow[] { + useRequiredCallbacks('useCatalogRows'); + + return useSelector((state: AnalysisRootState) => selectCatalogRows(state.analysis, currentBook)); +} + /** * Returns the active BCP 47 analysis-language tag from the nearest {@link AnalysisStoreProvider}. * diff --git a/src/components/CatalogRowView.tsx b/src/components/CatalogRowView.tsx new file mode 100644 index 00000000..dea01c1f --- /dev/null +++ b/src/components/CatalogRowView.tsx @@ -0,0 +1,241 @@ +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, + useTruncationTooltip, +} from 'platform-bible-react'; +import { formatReplacementString, formatScrRef, type LanguageStrings } from 'platform-bible-utils'; +import { memo, useCallback, useState } from 'react'; +import type { CatalogRow, CatalogUsage } from '../utils/analysis-query'; + +/** + * Localized string keys a row renders. Every row asks for the same strings, and subscribing per row + * would be a subscription per analysis in the draft, so they are resolved above the list and handed + * down. + */ +export const ROW_STRING_KEYS = [ + '%interlinearizer_analysisCatalog_noGloss%', + '%interlinearizer_analysisCatalog_usageCount%', + '%interlinearizer_analysisCatalog_noUsages%', + '%interlinearizer_analysisCatalog_showAllUsages%', +] as const satisfies `%${string}%`[]; + +/** + * How many usages an expanded row lists before the rest go behind an expander. An analysis applied + * across a whole book has hundreds; listing them all would bury every row beneath it. + */ +const INLINE_USAGE_LIMIT = 12; + +/** Props for {@link CatalogRowView}. */ +type CatalogRowViewProps = Readonly<{ + /** The analysis this row lists. */ + row: CatalogRow; + /** Finished label for `row.usageCountInBook`, naming the book that count was taken against. */ + usageCountInBookLabel: string; + /** Whether this is the row the view was last jumped from. */ + isSelected: boolean; + /** Jumps the interlinear view to one of this analysis's usages. */ + onUsageSelect: (analysisId: string, usage: CatalogUsage) => void; + /** Resolved localizations covering at least {@link ROW_STRING_KEYS}, shared by the whole list. */ + localizedStrings: LanguageStrings; + /** BCP 47 tag the morpheme glosses are read under. */ + analysisLanguage: string; +}>; + +/** Renders a usage's location the way scripture references are written, e.g. `GEN 1:1`. */ +function usageLabel(usage: CatalogUsage): string { + return formatScrRef({ + book: usage.book, + chapterNum: usage.chapter, + verseNum: usage.verse, + }); +} + +/** + * One analysis in the catalog: its surface form and gloss, and how much of the draft it accounts + * for — the whole draft's usage count beside the current book's. Expanding it reveals the morpheme + * breakdown and the places the analysis is applied. + * + * Each row owns its own layout so that its detail can be nested inside it. + */ +function CatalogRowView({ + row, + usageCountInBookLabel, + isSelected, + onUsageSelect, + localizedStrings, + analysisLanguage, +}: CatalogRowViewProps) { + const [isExpanded, setIsExpanded] = useState(false); + + /** Whether the usage list is showing every usage rather than the first {@link INLINE_USAGE_LIMIT}. */ + const [showsAllUsages, setShowsAllUsages] = useState(false); + + // Collapsing returns the row to the inline cap: without it a row once expanded to hundreds of + // usages has no way back, since the expander it was opened from is gone. + const handleToggle = useCallback(() => { + setIsExpanded((expanded) => !expanded); + setShowsAllUsages(false); + }, []); + + const visibleUsages = showsAllUsages ? row.usages : row.usages.slice(0, INLINE_USAGE_LIMIT); + const hiddenUsageCount = row.usages.length - visibleUsages.length; + + const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%']; + + /** The row's gloss, or the placeholder standing in for an analysis that carries none. */ + const glossLabel = row.gloss || localizedStrings['%interlinearizer_analysisCatalog_noGloss%']; + + // One tooltip each rather than one for the row: either column may be the clipped one, and a + // tooltip is worth opening only over the text that is actually cut off. + const surfaceTooltip = useTruncationTooltip(); + const glossTooltip = useTruncationTooltip(); + + return ( +
  • + {/* + Carries no `aria-label`: a name on a button overrides its content, so one here would + announce every row alike and suppress the analysis each lists. + */} + + + {isExpanded && ( +
    + {row.morphemes.length > 0 && ( +
    + {row.morphemes.map((morpheme) => ( + // Form above gloss, as the interlinear view arranges them, so a breakdown reads the + // same in both places. +
    + {morpheme.form} + + {morpheme.gloss?.[analysisLanguage] ?? ''} + +
    + ))} +
    + )} + + {row.usages.length === 0 ? ( +

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

    + ) : ( +
    + {visibleUsages.map((usage) => ( + + ))} + {hiddenUsageCount > 0 && ( + + )} +
    + )} +
    + )} +
  • + ); +} + +/** Memoized version of {@link CatalogRowView}; use in render-stable row lists. */ +const MemoizedCatalogRowView = memo(CatalogRowView); +export default MemoizedCatalogRowView; diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 4a1de720..2fd93cb8 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -5,10 +5,16 @@ import type { } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { useData, useLocalizedStrings, useSetting } from '@papi/frontend/react'; -import { TabToolbar } from 'platform-bible-react'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + TabToolbar, +} from 'platform-bible-react'; import type { SelectMenuItemHandler } from 'platform-bible-react'; import { isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; @@ -24,15 +30,17 @@ import type { SegmentationDispatch } from './SegmentationStore'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import Interlinearizer from './Interlinearizer'; import { AnalysisStoreProvider } from './AnalysisStore'; +import AnalysisCatalogPanel from './AnalysisCatalogPanel'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; import type { PhraseMode } from '../types/phrase-mode'; import ProjectModals, { type ModalState } from './modals/ProjectModals'; import { WipeModal, type WipeScope } from './modals/WipeModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; -import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext'; +import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; import { firstVerseNumber, segmentContainsVerse } from '../utils/verse-ref'; import { resolvedOrEmpty } from '../utils/localized-strings'; +import usePanelResizeKeys from '../hooks/usePanelResizeKeys'; /** Host-injected callback to update this WebView's definition (used to toggle the tab title). */ type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition']; @@ -53,9 +61,77 @@ const DEFAULT_WEB_VIEW_MENU = { */ const BASE_TAB_TITLE = 'Interlinearizer'; +/** Props for {@link BookFadeWrapper}. */ +type BookFadeWrapperProps = Readonly<{ + /** How far through a cross-book fade the view is. */ + fadePhase: FadePhase; + /** The interlinear view, or the placeholder standing in for it. */ + children: ReactNode; +}>; + +/** + * The cross-book curtain: the column holding whatever the view is showing of the book, dimmed while + * a jump to another book is in flight. + * + * @returns An element carrying `data-testid="book-fade-wrapper"`. + */ +function BookFadeWrapper({ fadePhase, children }: BookFadeWrapperProps) { + return ( +
    + {children} +
    + ); +} + /** Glyph appended to the tab title while the draft has unsaved changes. */ const UNSAVED_TAB_MARKER = ' ●'; +/** Identifies the interlinear view within the catalog group, in a {@link PanelLayout} and the DOM. */ +const VIEW_PANEL_ID = 'interlinearView'; + +/** Identifies the catalog within its group, in a {@link PanelLayout} and the DOM. */ +const CATALOG_PANEL_ID = 'analysisCatalog'; + +/** + * How much of the container the interlinear view keeps whatever the catalog is resized to. The + * panel sits beside the text rather than over it, so a container too narrow for both narrows the + * catalog rather than pushing the text off the screen. + */ +const MIN_VIEW_WIDTH = '240px'; + +/** Narrowest the catalog may be resized to, below which its usage counts stop fitting. */ +const MIN_CATALOG_WIDTH = '220px'; + +/** Widest the catalog may be resized to, past which no gloss needs the room. */ +const MAX_CATALOG_WIDTH = '800px'; + +/** A resizable group's layout: the share of the group each of its panels holds, by panel id. */ +type PanelLayout = Readonly>; + +/** + * How the catalog group is laid out before the user has ever resized it: enough of the container + * for a gloss to be read beside the text without crowding it. + */ +const DEFAULT_CATALOG_LAYOUT: PanelLayout = { [VIEW_PANEL_ID]: 0.75, [CATALOG_PANEL_ID]: 0.25 }; + +/** + * How much of the group Home and End aim the catalog at. What it settles on is whatever + * {@link MIN_CATALOG_WIDTH} and {@link MAX_CATALOG_WIDTH} allow, those being the real limits. + */ +const CATALOG_FRACTION_BOUNDS = { min: 0.15, max: 0.5 }; + /** * Localized string keys the load/error placeholder needs. Hoisted to module scope so the reference * passed to `useLocalizedStrings` is stable across renders; a fresh array literal each render makes @@ -65,6 +141,7 @@ const STRING_KEYS = [ '%interlinearizer_error_load_book_heading%', '%interlinearizer_error_process_book_heading%', '%interlinearizer_loading%', + '%interlinearizer_analysisCatalog_resize%', ] as const satisfies `%${string}%`[]; /** @@ -246,6 +323,7 @@ function InterlinearizerLoaderInner({ isLoading, bookError, tokenizeError, + writingSystem, } = useInterlinearizerBookData({ projectId, scrRef, @@ -378,6 +456,22 @@ function InterlinearizerLoaderInner({ if (hasError) cancelFade(); }, [hasError, cancelFade]); + /** + * Whether the analysis catalog panel is showing. Tab-scoped rather than a project setting: two + * tabs on one project are routinely opened to look at different things, and a panel one of them + * opened has no business appearing in the other. + */ + const [catalogOpen, setCatalogOpen] = useWebViewState('analysisCatalogOpen', false); + + /** + * How the interlinear view and the catalog beside it divide the room between them, tab-scoped for + * the same reason the catalog's open flag is. + */ + const [catalogLayout, setCatalogLayout] = useWebViewState( + 'analysisCatalogLayout', + DEFAULT_CATALOG_LAYOUT, + ); + const [modal, setModal] = useState('none'); /** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */ @@ -466,6 +560,23 @@ function InterlinearizerLoaderInner({ /** Dismisses the wipe dialog, leaving the draft untouched. */ const handleWipeCancel = useCallback(() => setWipeModalOpen(false), []); + /** Dismisses the analysis catalog panel. */ + const handleCatalogClose = useCallback(() => setCatalogOpen(false), [setCatalogOpen]); + + /** Records a share of the group a key press asked the catalog be given, the view taking the rest. */ + const handleCatalogFractionChange = useCallback( + (fraction: number) => + setCatalogLayout({ [VIEW_PANEL_ID]: 1 - fraction, [CATALOG_PANEL_ID]: fraction }), + [setCatalogLayout], + ); + + const handleCatalogResizeKeyDown = usePanelResizeKeys( + /* v8 ignore next -- every layout names both panels, the default's and each write's alike */ + catalogLayout[CATALOG_PANEL_ID] ?? DEFAULT_CATALOG_LAYOUT[CATALOG_PANEL_ID], + handleCatalogFractionChange, + CATALOG_FRACTION_BOUNDS, + ); + /** * Routes top-menu commands to the appropriate action. The project commands open their modals; the * file commands save (or open Save As); the draft command opens the wipe dialog. @@ -486,9 +597,11 @@ function InterlinearizerLoaderInner({ setModal('saveAs'); } else if (item.command === 'interlinearizer.wipe') { setWipeModalOpen(true); + } else if (item.command === 'interlinearizer.openAnalysisCatalog') { + setCatalogOpen(true); } }, - [activeProject, handleSave], + [activeProject, handleSave, setCatalogOpen], ); /** @@ -613,29 +726,22 @@ function InterlinearizerLoaderInner({ }} /> -
    +
    {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(); + } +}