261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { Eye, EyeOff } from 'lucide-react';
|
||
import { useDeferredValue, useMemo, useState } from 'react';
|
||
|
||
import { termKind } from '../api';
|
||
import type { Bank as BankData, BankTerm } from '../api';
|
||
import { useLanguage } from '../i18n/language';
|
||
import { text, useText, type MessageKey } from '../i18n/text';
|
||
import { Chips, type Chip } from '../ui/Chips';
|
||
import { FilterField } from '../ui/FilterField';
|
||
import { Toggle } from '../ui/Toggle';
|
||
import { icon } from '../ui/icon';
|
||
import { measures } from '../tokens/measures';
|
||
import { Table, type TableColumn } from '../ui/Table';
|
||
import tableStyles from '../ui/Table.module.css';
|
||
import { counted, number } from './format';
|
||
import { matchesTerm } from './terms';
|
||
import styles from './Bank.module.css';
|
||
|
||
interface Props {
|
||
bank: BankData;
|
||
/** Source-side language of the book: without it the hanzi render with Japanese glyphs. */
|
||
sourceLang?: string;
|
||
/** The run stopped and waits for the bank to be signed — a state of the WHOLE bank. */
|
||
awaitingSignature?: boolean;
|
||
/**
|
||
* A request from the palette to show one term. NOT the value of the search box: the box owns its
|
||
* own text, and lifting it made every keystroke re-render the whole screen (measured: 150 ms per
|
||
* character on a book with 2284 chapters, because the tree re-rendered with it). The number is
|
||
* what makes asking for the SAME term twice a second request.
|
||
*/
|
||
request: { term: string; seq: number };
|
||
}
|
||
|
||
type KindFilter = 'all' | NonNullable<BankTerm['kind']> | 'undecided';
|
||
|
||
/** Plural forms for the bank counter; Intl picks the category. */
|
||
const terms: Record<Intl.LDMLPluralRule, MessageKey> = {
|
||
zero: 'bank.termsZero',
|
||
one: 'bank.termsOne',
|
||
two: 'bank.termsTwo',
|
||
few: 'bank.termsFew',
|
||
many: 'bank.termsMany',
|
||
other: 'bank.termsOther',
|
||
};
|
||
|
||
/** How far a column may grow to fit what it holds. */
|
||
const fitMax = measures['--column-fit-max'];
|
||
|
||
const kindFilters: { id: KindFilter; label: MessageKey }[] = [
|
||
{ id: 'all', label: 'bank.kindAll' },
|
||
...termKind.values.map((value) => ({ id: value, label: termKind.describe(value).label })),
|
||
{ id: 'undecided', label: termKind.describe(null).label },
|
||
];
|
||
|
||
/**
|
||
* The bank as a table: one row per term, columns that can be compared down the page, and the two
|
||
* ways through hundreds of rows — search and the kind filter. A book's names are a different
|
||
* table from its terms, and that is what the filter is for.
|
||
*
|
||
* ⚠ No per-term signing state on screen, deliberately. The engine keeps one per row and the
|
||
* contract carries it (`TermStatus`), but that is the MECHANISM under the stop: the product act is
|
||
* ONE signature over the whole bank, so a "signed" column made the screen argue with the product.
|
||
*/
|
||
export function Bank({ bank, sourceLang, awaitingSignature, request }: Props) {
|
||
const text = useText();
|
||
// The cells are drawn with words from the catalogue, and the collection cache is dropped only on
|
||
// the table's `dependencies` — so the interface language belongs in that list beside the rest.
|
||
const language = useLanguage((state) => state.language);
|
||
const [kind, setKind] = useState<KindFilter>('all');
|
||
// Descriptions are covered whole — they are the spoiler. Hover reveals one cell to the EYES;
|
||
// this toggle is the only way for the keyboard and for a screen reader, which a blur hides
|
||
// nothing from (while covered, the text is out of the accessibility tree).
|
||
const [senseShown, setSenseShown] = useState(false);
|
||
const [filter, setFilter] = useState(request.term);
|
||
// A request from the palette overrides both the typed text and the kind filter: the person just
|
||
// picked a term and expects to see it, not an empty table because another kind tab was on.
|
||
const [seen, setSeen] = useState(request.seq);
|
||
if (request.seq !== seen) {
|
||
setSeen(request.seq);
|
||
setFilter(request.term);
|
||
setKind('all');
|
||
}
|
||
|
||
// Typing stays responsive while the table lags a frame behind: rebuilding the collection over a
|
||
// thousand rows costs ~150 ms, and every keystroke would pay it.
|
||
const query = useDeferredValue(filter);
|
||
// Counted over what the SEARCH found rather than over the whole bank: otherwise a chip promises
|
||
// rows the current search does not have, and clicking it opens an empty table.
|
||
const found = useMemo(() => bank.terms.filter(matchesTerm(query)), [bank.terms, query]);
|
||
const chips: Chip[] = useMemo(() => {
|
||
const counts = countByKind(found);
|
||
return kindFilters.map((item) => ({
|
||
...item,
|
||
label: text(item.label),
|
||
count: counts[item.id] ?? 0,
|
||
}));
|
||
}, [found, text]);
|
||
const rows = useMemo(() => found.filter(matchesKind(kind)), [found, kind]);
|
||
|
||
// The kind column stands only while the filter shows every kind: on the `kind.name` tab a column
|
||
// that says `kind.name` in every row is noise.
|
||
const columns: TableColumn<BankTerm>[] = [
|
||
{
|
||
id: 'src',
|
||
name: text('bank.columnTerm'),
|
||
isRowHeader: true,
|
||
// The window is drawn as a SECOND span with a gap before it. Measured as one run of text it
|
||
// came up exactly that gap short, and the window clipped in every row that had one — so the
|
||
// gap is measured too, as the space it is.
|
||
fit: {
|
||
text: (term) => {
|
||
const window = windowOf(term);
|
||
return window === null ? term.src : `${term.src} ${window}`;
|
||
},
|
||
max: fitMax,
|
||
},
|
||
render: (term) => {
|
||
// The window travels with the term instead of taking a column: it is empty for almost
|
||
// every row, and an almost-empty column is width spent on nothing.
|
||
const window = windowOf(term);
|
||
return (
|
||
<>
|
||
<span className={styles.src} lang={sourceLang} title={term.src}>
|
||
{term.src}
|
||
</span>
|
||
{window !== null && (
|
||
<span className={styles.window} title={window}>
|
||
{window}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'dst',
|
||
name: text('bank.columnTranslation'),
|
||
fit: {
|
||
text: (term) => (term.dst === '' ? text('bank.noTranslation') : term.dst),
|
||
max: fitMax,
|
||
},
|
||
// The table's only vertical rule is the language boundary: the book's side on the left.
|
||
className: tableStyles.divided,
|
||
render: (term) =>
|
||
term.dst === '' ? (
|
||
<span className={styles.absent}>{text('bank.noTranslation')}</span>
|
||
) : (
|
||
// Full text as a tooltip: a clipped cell is a dead end, and the translation is what is
|
||
// being checked here.
|
||
<span className={styles.dst} title={term.dst}>
|
||
{term.dst}
|
||
</span>
|
||
),
|
||
},
|
||
...(kind === 'all'
|
||
? [
|
||
{
|
||
id: 'kind',
|
||
name: text('bank.columnKind'),
|
||
// The widest label the bank actually uses, not the widest the vocabulary has: a book
|
||
// with no nicknames should not pay for the word.
|
||
fit: {
|
||
text: (term: BankTerm) => text(termKind.describe(term.kind).label),
|
||
max: fitMax,
|
||
},
|
||
render: (term: BankTerm) => (
|
||
<span className={styles.quiet}>{text(termKind.describe(term.kind).label)}</span>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: 'sense',
|
||
// The spoiler is a property of the COLUMN, not of single rows: a term's sense gives away how
|
||
// things end, so the whole column is covered by default.
|
||
name: text('bank.columnSense'),
|
||
// No fit: a description is a sentence, and no width fits one — so this is the column with
|
||
// room to grow, and everything the others do not need lands here.
|
||
minWidth: measures['--column-min'],
|
||
className: senseShown ? undefined : styles.senseCell,
|
||
// Empty means "no disambiguator" — the contract says so since 0.2.2, so there is nothing to
|
||
// guess at here and no difference between an absent field and an empty one.
|
||
render: (term) =>
|
||
term.sense === '' ? (
|
||
<span className={styles.absent}>—</span>
|
||
) : (
|
||
<span className={styles.sense} data-sense="" aria-hidden={senseShown ? undefined : true}>
|
||
{term.sense}
|
||
</span>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className={styles.bank}>
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.search}>
|
||
<FilterField label={text('bank.search')} value={filter} onChange={setFilter} />
|
||
<Toggle
|
||
label={senseShown ? text('bank.hideSenses') : text('bank.showSenses')}
|
||
isSelected={senseShown}
|
||
onChange={setSenseShown}
|
||
>
|
||
{senseShown ? <Eye {...icon} /> : <EyeOff {...icon} />}
|
||
</Toggle>
|
||
</div>
|
||
<Chips
|
||
label={text('bank.kindFilter')}
|
||
items={chips}
|
||
selectedId={kind}
|
||
onSelect={(id) => setKind(id as KindFilter)}
|
||
/>
|
||
</div>
|
||
|
||
{/* ⚠ Everything the render closures take from outside the row belongs in `dependencies`, or
|
||
the collection cache serves cells drawn with the old value. */}
|
||
<Table
|
||
label={text('bank.tableLabel')}
|
||
columns={columns}
|
||
items={rows}
|
||
sample={bank.terms}
|
||
dependencies={[sourceLang, senseShown, language]}
|
||
empty={text('bank.empty')}
|
||
/>
|
||
|
||
{/* Untouched — the size of the bank; narrowed — how much of it is left. Otherwise the screen
|
||
shows two numbers about the same thing and explains neither. */}
|
||
<p className={styles.state}>
|
||
<span>
|
||
{rows.length === bank.total
|
||
? counted(bank.total, terms)
|
||
: text('bank.shown', { shown: number(rows.length), total: number(bank.total) })}
|
||
</span>
|
||
{awaitingSignature === true && <span>{text('bank.awaitingSignature')}</span>}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** `0` means no boundary on that side, so a term without a window applies to the whole book. */
|
||
function windowOf({ since_chapter: since, until_chapter: until }: BankTerm): string | null {
|
||
if (since === 0 && until === 0) return null;
|
||
if (until === 0) return text('bank.windowSince', { chapter: number(since) });
|
||
if (since === 0) return text('bank.windowUntil', { chapter: number(until) });
|
||
return `${number(since)}–${number(until)}`;
|
||
}
|
||
|
||
const matchesKind = (kind: KindFilter) => (term: BankTerm) => {
|
||
if (kind === 'all') return true;
|
||
if (kind === 'undecided') return term.kind === null;
|
||
return term.kind === kind;
|
||
};
|
||
|
||
function countByKind(terms: BankTerm[]): Record<string, number> {
|
||
const counts: Record<string, number> = { all: terms.length };
|
||
for (const term of terms) {
|
||
const key = term.kind ?? 'undecided';
|
||
counts[key] = (counts[key] ?? 0) + 1;
|
||
}
|
||
return counts;
|
||
}
|