- 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).
200 lines
7.6 KiB
TypeScript
200 lines
7.6 KiB
TypeScript
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;
|
||
}
|