Skip to content

Commit 02a8b02

Browse files
Merge pull request #133 from sillsdev/se2
Minor suggested cleanup (#133)
2 parents f227daf + 165c915 commit 02a8b02

8 files changed

Lines changed: 63 additions & 93 deletions

File tree

src/__tests__/store/analysisSlice.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import type {
1111
TokenSnapshot,
1212
} from 'interlinearizer';
1313
import { createAnalysisStore } from '../../store';
14-
import { makePhraseLink } from '../test-helpers';
15-
import { emptyAnalysis } from '../../types/empty-factories';
1614
import {
1715
approveAnalysisForToken,
1816
createPhrase,
@@ -35,6 +33,8 @@ import {
3533
writeSegmentFreeTranslation,
3634
type AnalysisState,
3735
} from '../../store/analysisSlice';
36+
import { emptyAnalysis } from '../../types/empty-factories';
37+
import { makePhraseLink } from '../test-helpers';
3838

3939
/**
4040
* Builds an approved `TokenAnalysisLink` for `tok-1` pointing at the given `TokenAnalysis`.
@@ -65,10 +65,8 @@ function makeAnalysis(ta: TokenAnalysis): TextAnalysis {
6565
}
6666

6767
/**
68-
* Counts how many approved `TokenAnalysisLink`s point at the same payload that `tokenRef`'s own
69-
* approved link points at — the shared count of the payload a token sits on. A local test helper
70-
* standing in for the former production selector, used to assert sharing without reaching into the
71-
* link arrays at each call site.
68+
* Counts approved links on the same payload as `tokenRef`'s own approved link, used to assert
69+
* sharing without reaching into the link arrays at each call site.
7270
*
7371
* @param state - The analysis slice state.
7472
* @param tokenRef - The `Token.ref` whose payload's shared count is wanted.

src/components/AnalysisStore.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
99
import type { ReactNode } from 'react';
1010
import { Provider as ReduxProvider, useDispatch, useSelector, useStore } from 'react-redux';
11+
import { createAnalysisStore, type AnalysisDispatch, type AnalysisRootState } from '../store';
1112
import {
1213
approveAnalysisForToken,
1314
createPhrase,
@@ -30,7 +31,6 @@ import {
3031
writePhraseGloss,
3132
writeSegmentFreeTranslation,
3233
} from '../store/analysisSlice';
33-
import { createAnalysisStore, type AnalysisDispatch, type AnalysisRootState } from '../store';
3434
import { emptyAnalysis } from '../types/empty-factories';
3535
import { resolvedTokenAnalysisEqual, type ResolvedTokenAnalysis } from '../utils/suggestion-engine';
3636

@@ -166,14 +166,11 @@ export function AnalysisStoreProvider({
166166
}, []);
167167

168168
/**
169-
* The gloss-write entry point exposed to inputs. Dispatches {@link writeGloss} so the reducer
170-
* applies a per-token edit — it forks a shared payload before writing (blank clears, non-blank
171-
* edits), so editing one token never rewrites its co-linked siblings — then persists the updated
172-
* analysis via `onSave` and notifies the `onGlossChange` spy with the edited token and value.
169+
* Dispatches {@link writeGloss} (forking any shared payload before writing), then persists via
170+
* `onSave` and notifies the `onGlossChange` spy.
173171
*
174172
* @param tokenRef - `Token.ref` of the token being glossed.
175-
* @param surfaceText - Surface text of the token, recorded so the analysis can detect baseline
176-
* drift.
173+
* @param surfaceText - Surface text, recorded so the analysis can detect baseline drift.
177174
* @param value - New gloss string; blank (empty or whitespace) clears the active language's
178175
* gloss.
179176
*/
@@ -286,14 +283,13 @@ export function useGloss(tokenRef: string): string {
286283
/**
287284
* Returns the merged analysis the renderer shows for a token — its approved decision, else the
288285
* engine's derived suggestion, else `undefined` ({@link selectResolvedTokenAnalysis}). Subscribes
289-
* through {@link resolvedTokenAnalysisEqual} so the freshly-allocated result stays referentially
290-
* stable across unrelated store changes: the token re-renders only when its approved decision or
291-
* suggestion actually changes, never on every pool rebuild.
286+
* through {@link resolvedTokenAnalysisEqual} so the result stays referentially stable across
287+
* unrelated store changes, re-rendering only when the decision or suggestion actually changes.
292288
*
293289
* When `enabled` is `false` the selector short-circuits to `undefined` without consulting the pool,
294290
* so a chip that is not currently showing suggestions does no per-token pool lookup or
295-
* normalization on each store change. The only consumer ({@link TokenChip}) reads `resolved` solely
296-
* to render suggestions, so it passes its `showSuggestions` flag here.
291+
* normalization on each store change. The only consumer ({@link TokenChip}) passes its
292+
* `showSuggestions` flag here.
297293
*
298294
* @param tokenRef - The `Token.ref` to resolve.
299295
* @param surfaceText - The token's current surface text, matched against the pool when the token is

src/components/SuggestionDropdown.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88
import { useLayoutEffect, useRef, useState } from 'react';
99
import { createPortal } from 'react-dom';
10-
import type { GlossedSuggestionEntry } from '../utils/suggestion-engine';
1110
import { statusTextColorClass } from '../utils/status-colors';
11+
import type { GlossedSuggestionEntry } from '../utils/suggestion-engine';
1212

1313
/** Props for {@link SuggestionDropdown}. */
1414
type SuggestionDropdownProps = Readonly<{
@@ -41,13 +41,9 @@ type SuggestionDropdownProps = Readonly<{
4141
/**
4242
* Renders the portaled suggestion listbox for a token's gloss combobox. Each row is colored and
4343
* labeled by its own `status` — `'suggested'` (green, "accept") or `'candidate'` (blue, "promote")
44-
* — carried on the entry rather than inferred from row position, so a dropped blank-in-language
45-
* pick can never leave a candidate masquerading as the accept row. The keyboard-active row gets the
46-
* same `bg-accent` background hovering applies, and hovering a row sets the active index so only
47-
* one row is ever highlighted. Each row suppresses its mouse-down default so clicking it never
48-
* blurs the input — focus stays in the input and the click selects instead. The panel closes itself
49-
* on outer scrolling (the anchor would drift); scrolling the panel's own overflow is ignored so a
50-
* long list can be scrolled without dismissing it.
44+
* — carried on the entry rather than inferred from position, so a dropped blank-in-language pick
45+
* can never leave a candidate masquerading as the accept row. Each row suppresses its mouse-down
46+
* default so clicking it never blurs the input.
5147
*
5248
* @param props - Component props (see {@link SuggestionDropdownProps}).
5349
* @returns A `document.body` portal containing the listbox, positioned under the anchor input.
@@ -77,15 +73,12 @@ export default function SuggestionDropdown({
7773
listRef.current = el ?? undefined;
7874
};
7975

80-
// Position the panel under the anchor on open and keep it glued there across window resizes and
81-
// outer scrolling. The continuous view smooth-scrolls the token strip to center a phrase whenever
82-
// a token's gloss input is focused — the same focus that opens this dropdown — so closing on outer
83-
// scroll would dismiss the panel the instant it appeared. Instead we reposition under the anchor as
84-
// it moves, then close only once the anchor has scrolled out of the viewport (a far user scroll
85-
// that abandons this token). A capture listener catches scrolls of ancestor viewports; scrolls of
86-
// the panel's own overflow are ignored so a long list can be scrolled without moving or closing it.
87-
// Layout effect so the first measurement runs before paint — otherwise the portaled panel flashes
88-
// at the default top-left before snapping under the anchor.
76+
// Position the panel under the anchor and keep it glued there across resizes and outer scrolling.
77+
// The continuous view smooth-scrolls the token strip on focus — the same focus that opens this
78+
// dropdown — so we reposition as the anchor moves rather than close immediately; we close only
79+
// once the anchor leaves the viewport (a far scroll that abandons this token). Layout effect so
80+
// the first measurement runs before paint — otherwise the portaled panel flashes at the default
81+
// top-left before snapping under the anchor.
8982
useLayoutEffect(() => {
9083
const anchor = anchorRef.current;
9184
/* v8 ignore next -- the chip only mounts this while the input (the anchor) is rendered */

src/components/TokenChip.tsx

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
useRef,
1414
useState,
1515
} from 'react';
16+
import { resolvedOrEmpty } from '../utils/localized-strings';
17+
import { glossedSuggestionEntries } from '../utils/suggestion-engine';
1618
import {
1719
useAnalysisLanguage,
1820
useApproveAnalysisDispatch,
@@ -27,8 +29,6 @@ import {
2729
} from './AnalysisStore';
2830
import { MorphemeBreakdownPopover, MorphemeGlossInput } from './MorphemeEditor';
2931
import SuggestionDropdown from './SuggestionDropdown';
30-
import { resolvedOrEmpty } from '../utils/localized-strings';
31-
import { glossedSuggestionEntries } from '../utils/suggestion-engine';
3232

3333
const STRING_KEYS = [
3434
'%interlinearizer_tokenChip_editMorphemes%',
@@ -174,58 +174,50 @@ export function TokenChip({
174174

175175
const hasMorphemes = morphemes.length > 0;
176176

177-
// The pool entries to offer via the suggestion dropdown, with their accept/promote status. The
178-
// engine flattens the resolved read into ranked, status-tagged rows: for a suggested token its
179-
// pick (green "accept") plus candidates (blue "promote"); for an approved token the pool
180-
// alternatives only (all blue "promote", the already-approved payload excluded). Blank-in-active-
181-
// language entries are dropped individually rather than shown as empty rows (see
182-
// `user-questions.md` display #3). Memoized on the (reference-stable) resolved read and active
183-
// language so typing a gloss — which only changes local draft state — never re-runs the flatten/
184-
// filter; it recomputes only when the resolved read or language actually changes.
177+
// Pool entries for the suggestion dropdown: for a suggested token, the top pick (green "accept")
178+
// plus candidates (blue "promote"); for an approved token, the pool alternatives only (the
179+
// already-approved payload excluded). Blank-in-active-language entries are dropped rather than
180+
// shown as empty rows. Memoized on the (reference-stable) resolved read and active language so
181+
// typing a gloss — which only changes local draft state — never re-runs the flatten/filter.
185182
const glossedRanked = useMemo(
186183
() => glossedSuggestionEntries(resolved, analysisLanguage),
187184
[resolved, analysisLanguage],
188185
);
189186
// Whether this token has anything to suggest: gated on the demo toggle (via the resolve short-
190187
// circuit) and editability. The chevron and dropdown only ever appear when this is true.
191188
const hasSuggestions = showSuggestions && !disabled && glossedRanked.length > 0;
192-
// The engine's top pick (row 0, the green "suggested"), shown as ghost placeholder text in the
193-
// empty input so the row reveals at a glance which tokens already have a suggestion — without
194-
// needing focus or hover. Only meaningful while the draft is empty; once the user types, the
195-
// typed value replaces it. Undefined when this token has no suggestion to offer.
189+
// Top pick (row 0, the green "suggested") shown as ghost placeholder text so the row reveals
190+
// which tokens have a suggestion at a glance — without focus or hover. Once the user types, the
191+
// typed value replaces it.
196192
const suggestedGloss = hasSuggestions ? glossedRanked[0].gloss : undefined;
197193
const showSuggestedPlaceholder = suggestedGloss !== undefined && draft === '';
198194

199195
/**
200-
* Builds the listbox option element id for a row, kept in sync with the input's
201-
* `aria-activedescendant` so assistive tech can follow the keyboard-highlighted row.
196+
* Returns the listbox option id for `index`; kept in sync with `aria-activedescendant` so
197+
* assistive tech follows the keyboard-highlighted row.
202198
*
203199
* @param index - The row's index in {@link glossedRanked}.
204200
* @returns The option element id.
205201
*/
206-
const optionId = (index: number) => `${listboxId}-opt-${index}`;
202+
const optionId = useCallback((index: number) => `${listboxId}-opt-${index}`, [listboxId]);
207203

208204
/** Closes the suggestion dropdown and clears the keyboard highlight, leaving focus untouched. */
209205
const closeSuggestions = useCallback(() => {
210206
setSuggestionsOpen(false);
211207
setActiveIndex(-1);
212208
}, []);
213209

214-
/**
215-
* Commits the current draft gloss the same way blur does: only when it differs from the committed
216-
* value. Only called from the (disabled-gated) blur and key-down handlers, so it needs no
217-
* disabled check.
218-
*/
210+
/** Commits the draft gloss only when it differs from the committed value. */
219211
const commitDraft = () => {
220212
if (draft !== committedGloss) {
221213
onGlossChange(token.ref, token.surfaceText, draft);
222214
}
223215
};
224216

225217
/**
226-
* Approves the chosen suggestion payload for this token and closes the dropdown. The typed draft
227-
* (if any) is discarded: approving updates committedGloss, which the sync effect mirrors back
228-
* into the input, so the selection wins over the abandoned draft.
218+
* Approves the chosen suggestion payload for this token and closes the dropdown. Any typed draft
219+
* is discarded: the approval updates `committedGloss`, which the sync effect mirrors back into
220+
* the input so the selection wins.
229221
*
230222
* @param id - The chosen payload's id (the suggested pick or a promoted candidate).
231223
*/
@@ -236,12 +228,10 @@ export function TokenChip({
236228

237229
/**
238230
* Drives the gloss input as a combobox. While the dropdown is open, arrow keys move the highlight
239-
* (stopping at the ends, with Up returning to the no-highlight state), Enter commits the
240-
* highlight or the top row, and Escape closes without committing. While it is closed, ArrowDown
241-
* opens the dropdown (the keyboard equivalent of the chevron) when this token has suggestions —
242-
* so an approved token whose dropdown does not auto-open on focus can still summon its pool
243-
* alternatives from the keyboard — and Enter commits the typed draft. Other keys fall through to
244-
* the input.
231+
* (stopping at the ends; Up returns to the no-highlight state), Enter commits the highlighted row
232+
* or the top row, and Escape closes without committing. While closed, ArrowDown opens the
233+
* dropdown (keyboard equivalent of the chevron — lets an approved token summon pool alternatives
234+
* without focusing away) and Enter commits the typed draft.
245235
*
246236
* @param e - The gloss input's key-down event.
247237
*/

src/components/__mocks__/AnalysisStore.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,13 @@ export function useMorphemeGlossDispatch(): (
151151
export function useReportGlossEditing(_isEditing: boolean): void {}
152152

153153
/**
154-
* Returns the merged token analysis in mock context. The mock pool is empty and tracks only
155-
* approved glosses, so it never derives a suggestion — always `undefined`. Suggestion behavior is
156-
* covered separately against the real store.
154+
* Returns the merged token analysis in mock context. The mock pool is empty, so it never derives
155+
* a suggestion — always `undefined`. Suggestion behavior is covered against the real store.
157156
*
158-
* @param _tokenRef - The token reference key (unused in mock).
159-
* @param _surfaceText - The token's surface text (unused in mock).
160-
* @param _enabled - Whether to resolve (unused in mock; it never surfaces suggestions anyway).
161-
* @returns `undefined` — the mock surfaces no suggestions.
157+
* @param _tokenRef - Token ref (unused in mock).
158+
* @param _surfaceText - Surface text (unused in mock).
159+
* @param _enabled - Whether to resolve (unused in mock).
160+
* @returns `undefined`.
162161
*/
163162
export function useResolvedTokenAnalysis(
164163
_tokenRef: string,
@@ -169,8 +168,7 @@ export function useResolvedTokenAnalysis(
169168
}
170169

171170
/**
172-
* Returns whether suggestions should render in mock context. The mock never surfaces suggestions,
173-
* so this is always `false`.
171+
* Returns whether suggestions should render in mock context — always `false`.
174172
*
175173
* @returns `false`.
176174
*/

src/store/analysisSlice.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import type {
1111
TokenSnapshot,
1212
} from 'interlinearizer';
1313
import { emptyAnalysis } from '../types/empty-factories';
14-
import { isEmptyMultiString } from '../utils/multi-string';
1514
import { analysesAreIdentical } from '../utils/analysis-identity';
15+
import { isEmptyMultiString } from '../utils/multi-string';
1616
import {
1717
buildPoolIndex,
1818
deriveTokenSuggestion,
@@ -303,7 +303,9 @@ function isPayloadSharedByOtherLinks(
303303
link: TokenAnalysisLink,
304304
analysisId: string,
305305
): boolean {
306-
return state.analysis.tokenAnalysisLinks.some((l) => l !== link && l.analysisId === analysisId);
306+
return state.analysis.tokenAnalysisLinks.some(
307+
(l) => l !== link && l.status === 'approved' && l.analysisId === analysisId,
308+
);
307309
}
308310

309311
/**
@@ -446,8 +448,8 @@ const analysisSlice = createSlice({
446448
// onto a private clone first and mutate that, so the co-linked tokens keep the shared gloss
447449
// instead of being rewritten or stranded by an edit aimed at this one. (Global "edit every
448450
// occurrence" is deferred; see user-questions.md "separating per-token edits from global
449-
// analysis edits".) Surface text is refreshed on the fork (not the shared original) so a
450-
// co-linked sibling's payload is never rewritten — mirroring writeMorphemes.
451+
// analysis edits".) Surface text is refreshed on the fork (not the shared original)
452+
// so a co-linked sibling's payload is never rewritten.
451453
const target = isPayloadSharedByOtherLinks(state, link, analysis.id)
452454
? forkSharedAnalysis(state, link, analysis, id)
453455
: analysis;
@@ -705,10 +707,7 @@ const analysisSlice = createSlice({
705707
}
706708
// A morpheme gloss is part of analysis identity (see analysesAreIdentical), so editing or
707709
// clearing one can make this payload identical to an existing one (e.g. a homograph whose
708-
// only difference was this morpheme's gloss); re-converge so the dedupe the create path
709-
// guarantees on first write also holds after morpheme gloss edits (mirrors writeGloss).
710-
// Symmetric across write and clear, so a clear back to a sibling's state never leaves a
711-
// duplicate the suggestion pool would double-count.
710+
// only difference was this morpheme's gloss); re-converge so dedupe holds after edits too.
712711
mergeIntoIdenticalPayload(state, target);
713712
},
714713
},

src/utils/analysis-identity.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import type { MorphemeAnalysis, TokenAnalysis } from 'interlinearizer';
1212
const NORMALIZED_FORM_CACHE_MAX = 50_000;
1313

1414
/**
15-
* Memoizes {@link normalizeSurfaceForm} by raw input string. The same surface forms are normalized
16-
* over and over — once per visible un-approved token on every store dispatch (the suggestion derive
17-
* path) plus the dedupe scans on every write — yet the result is a pure function of the input, so
18-
* caching is safe and entries never go stale (normalization is deterministic). The cache is module-
15+
* Memoizes {@link normalizeSurfaceForm} by raw input string — called once per visible un-approved
16+
* token on every store dispatch, so the same forms are normalized repeatedly. The cache is module-
1917
* global rather than project-scoped, so a bound is needed: when it reaches
2018
* {@link NORMALIZED_FORM_CACHE_MAX} the oldest entry is evicted (insertion-order FIFO, free from
2119
* `Map`), keeping memory flat across a session that opens many projects rather than retaining every

0 commit comments

Comments
 (0)