Files
numolus/app/frontend/store/format.ts
T
wkh 0fca61eb65 Add in-app help, formula reference outlines, underline and auto-closing
- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
2026-10-24 00:38:01 +02:00

200 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isError, serialToUtcDate, type CellValue } from '../engine';
import type { CellNumFmt } from './types';
/**
* Locale used to render numeric values. `'plain'` is not a real locale -- it
* renders the bare JS number (`.` decimal, no grouping).
*
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
* locale-neutral so round-trips are lossless.
*/
export type NumberLocale =
| 'plain'
| 'de-DE'
| 'de-CH'
| 'en-US'
| 'en-GB'
| 'fr-FR'
| 'it-IT';
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
{ id: 'en-US', label: 'English (US) · 1,234.5' },
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
];
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
export function isNumberLocale(x: unknown): x is NumberLocale {
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
}
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
/** Cell number-format options offered in the UI. */
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
{ id: 'auto', label: 'Automatisch' },
{ id: 'plain', label: 'Ohne Formatierung' },
{ id: 'int', label: 'Ganzzahl · 1.235' },
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
{ id: 'pct0', label: 'Prozent · 12 %' },
{ id: 'pct2', label: 'Prozent · 12,34 %' },
{ id: 'date', label: 'Datum · 02.09.2026' },
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
{ id: 'time', label: 'Zeit · 14:30' },
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
{ id: 'duration', label: 'Dauer · 37:30' },
];
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
export function isCellNumFmt(x: unknown): x is CellNumFmt {
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
}
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
useGrouping: locale !== 'plain',
...opts,
});
}
const pad2 = (n: number) => String(n).padStart(2, '0');
/** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale
* that reliably reproduces ISO 8601, and this is also what the `'plain'`
* pseudo-locale uses for a date (an `Intl` call with an `undefined` locale
* would silently fall back to the *browser's* locale instead of staying
* neutral). Shared by the locale-independent `'dateiso'` cell format and
* by `formatDatePart`'s `'plain'` branch below. */
function isoDateString(d: Date): string {
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
function formatDatePart(d: Date, locale: NumberLocale): string {
if (locale === 'plain') return isoDateString(d);
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
}).format(d);
}
function formatTimePart(d: Date, locale: NumberLocale): string {
if (locale === 'plain') return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
}).format(d);
}
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
* way a sum of working times should read; seconds only when there are any.
* Locale-independent: `:` is the time separator in every supported locale. */
function formatDuration(n: number): string {
let sec = Math.round(Math.abs(n) * 86400);
const h = Math.floor(sec / 3600);
sec -= h * 3600;
const m = Math.floor(sec / 60);
const s = sec - m * 60;
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
}
/** Render a computed value for display (grid cell / status bar / chart). */
export function formatValue(
v: CellValue,
locale: NumberLocale = DEFAULT_LOCALE,
numFmt: CellNumFmt = 'auto',
): string {
if (v === null) return '';
if (isError(v)) return v.code;
if (typeof v !== 'number') return v;
if (!Number.isFinite(v)) return '#NUM!';
const n = round10(v);
switch (numFmt) {
case 'plain':
return String(n);
case 'int':
return nf(locale, { maximumFractionDigits: 0 }).format(n);
case 'dec1':
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
case 'dec2':
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
case 'pct0':
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
case 'pct2':
return nf(locale, {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n);
case 'date':
return formatDatePart(serialToUtcDate(n), locale);
case 'dateiso':
return isoDateString(serialToUtcDate(n)); // same regardless of locale
case 'time':
return formatTimePart(serialToUtcDate(n), locale);
case 'datetime':
// a bare time of day (< 1 day) has no calendar date -- showing the
// epoch "30.12.1899" in front of it would be noise
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
case 'duration':
return formatDuration(n);
default: // 'auto'
return locale === 'plain'
? String(n)
: nf(locale, { maximumFractionDigits: 10 }).format(n);
}
}
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
export function rawString(v: CellValue): string {
if (v === null) return '';
if (isError(v)) return v.code;
if (typeof v === 'number') return String(round10(v));
return v;
}
export function isNumeric(v: CellValue): boolean {
return typeof v === 'number' && Number.isFinite(v);
}
// ---- readable text on a fill ---------------------------------------------
const DARK_TEXT = '#1d1d1f';
const LIGHT_TEXT = '#f5f5f7';
/** sRGB channel (0–255) → linear light, per WCAG 2.x */
const linear = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
/**
* Text colour that stays readable on the fill `bg` (#rgb, #rgba, #rrggbb,
* #rrggbbaa): dark or light, whichever contrasts more (WCAG luminance).
* Used when a cell or table has a fill but no text colour of its own -- the
* theme's text colour would otherwise land on it regardless (black text on
* a black fill). `null` when `bg` can't be read or is mostly transparent
* (the theme's surface shows through, its text colour already fits).
*/
export function readableTextOn(bg: string | undefined | null): string | null {
const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec((bg ?? '').trim());
if (!m) return null;
let hex = m[1];
if (hex.length <= 4) hex = [...hex].map((ch) => ch + ch).join('');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
const alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
if (alpha < 0.5) return null;
const lum = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
// contrast with black vs. with white: (L + 0.05) / 0.05 vs 1.05 / (L + 0.05)
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? DARK_TEXT : LIGHT_TEXT;
}