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).
This commit is contained in:
wkh
2026-10-24 00:38:01 +02:00
parent 0368e96e0c
commit 0fca61eb65
19 changed files with 1420 additions and 16 deletions
+16 -2
View File
@@ -651,8 +651,22 @@ Inspector unter **Objekt · Auswahl**: horizontale **Ausrichtung**
**Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit
der Locale unten — sowie **Datum (ISO)**, das immer `JJJJ-MM-TT` zeigt,
unabhängig von der Locale), **Zell-Textfarbe**,
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
**Zell-Füllfarbe**, **Fett**/**Kursiv**/**Unterstrichen**, „Formatierung
entfernen". Auch exakte **Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
Per Tastatur: `Strg`/`Cmd`+`Umschalt`+`B` (oder `F`) fett, `+I` kursiv, `+U`
unterstrichen; jeweils ein, wenn noch nicht jede Zelle der Auswahl es hat,
sonst aus.
### Hilfe (`?`, F1)
Das `?` in der Symbolleiste, **Datei › Hilfe …** oder `F1` öffnet die
eingebaute Hilfe ([`HelpModal.vue`](app/frontend/components/HelpModal.vue)):
Themen für Benutzer, alle Tastenkürzel und die Formelfunktionen samt
Rechenzeichen, mit einer Suche über alles. Themen und Kürzel stehen in
[`data/help.ts`](app/frontend/data/help.ts) und werden von Hand gepflegt, wer
ein Kürzel oder eine sichtbare Funktion ändert, passt sie dort mit an. Die
Funktionsliste kommt aus `data/codeReference.ts` und ist damit immer
vollständig.
### Einstellungen (⚙, links neben dem Titel)
+21
View File
@@ -4,6 +4,7 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import HelpModal from './HelpModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
@@ -120,6 +121,17 @@ const uploadOpen = ref(false);
const loginOpen = ref(false);
const loginReason = ref<string | undefined>();
const accountMenuOpen = ref(false);
const helpOpen = ref(false);
// F1 opens the help from anywhere (also out of a cell editor -- the browser's
// own F1 help is what preventDefault keeps away)
function onHelpKey(e: KeyboardEvent) {
if (e.key !== 'F1') return;
e.preventDefault();
helpOpen.value = true;
}
onMounted(() => window.addEventListener('keydown', onHelpKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onHelpKey));
function openLogin(reason?: string) {
loginReason.value = reason;
@@ -470,10 +482,13 @@ async function onSample() {
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" />
<li><button @click="helpOpen = true">Hilfe … (F1)</button></li>
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul>
</div>
<button class="ibtn help" title="Hilfe (F1)" aria-label="Hilfe" @click="helpOpen = true">?</button>
<button
class="ibtn"
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
@@ -588,6 +603,7 @@ async function onSample() {
/>
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
<HelpModal :open="helpOpen" @close="helpOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
@@ -866,6 +882,11 @@ async function onSample() {
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
/* the help button is a plain "?" rather than an icon */
.ibtn.help {
font-size: 15px;
font-weight: 700;
}
.tgroup.accent .ibtn {
color: var(--accent);
}
+5 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { cycleReferenceLocks } from '../engine';
import { cycleReferenceLocks, completeFormula } from '../engine';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import { useToasts } from '../composables/useToasts';
@@ -113,6 +113,10 @@ function backToGrid() {
function commit() {
const sel = store.doc.selection;
// a forgotten closing ")" is completed rather than stored as an error; in
// the draft too, since the sync watch above skips changes made while the
// field still has focus (Enter commits before it blurs)
draft.value = completeFormula(draft.value);
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
}
/** Lets the store insert a reference into this field when the user clicks a
+410
View File
@@ -0,0 +1,410 @@
<script setup lang="ts">
/**
* In-app help: topics and keyboard shortcuts from data/help.ts, plus the
* formula functions and operators from data/codeReference.ts (the same
* entries the macro/function editors' reference shows, so the list can't
* drift from the engine). One search box runs over all of it. Opened from
* the toolbar's "?" button, Datei › Hilfe or F1 (see AppToolbar.vue).
*/
import { computed, nextTick, ref, watch } from 'vue';
import BaseModal from './BaseModal.vue';
import { HELP_TOPICS, SHORTCUT_GROUPS, shortcutKeys, type KeyPlatform, type Shortcut } from '../data/help';
import { matchesQuery, referenceFor } from '../data/codeReference';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
type Page = string; // a topic id, 'shortcuts' or 'functions'
const page = ref<Page>(HELP_TOPICS[0].id);
const query = ref('');
const searchEl = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
(o) => {
if (!o) return;
query.value = '';
nextTick(() => searchEl.value?.focus());
},
);
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
/** This device's platform: its column in the shortcut tables is highlighted,
* and the topics' running text names its keys. */
const platform: KeyPlatform = isMac ? 'mac' : 'pc';
const keys = (k: string) => shortcutKeys(k, platform);
/** Both columns are always shown -- the help is also read to explain the
* app to someone on the other system. */
const COLUMNS: { platform: KeyPlatform; label: string }[] = [
{ platform: 'pc', label: 'Windows / Linux' },
{ platform: 'mac', label: 'Mac' },
];
/** `**fett**` and `` `Code` `` -> HTML; everything else escaped first, so
* nothing in the data can inject markup. */
function rich(text: string): string {
const esc = keys(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return esc.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
const FORMULA_REF = referenceFor('expression').filter(
(e) => e.category === 'Formelfunktionen' || e.category === 'Operatoren',
);
const topic = computed(() => HELP_TOPICS.find((t) => t.id === page.value) ?? null);
const plain = (s: string) => s.replace(/[`*]/g, '').toLowerCase();
const hit = (s: string, words: string[]) => words.every((w) => plain(keys(s)).includes(w));
/** Search across topics, shortcuts and functions -- every word must occur. */
const results = computed(() => {
const words = query.value.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return null;
const items = HELP_TOPICS.flatMap((t) =>
t.sections.flatMap((s) =>
s.items
.filter((i) => hit(`${t.title} ${s.heading ?? ''} ${i}`, words))
.map((text) => ({ where: s.heading ? `${t.title} › ${s.heading}` : t.title, text })),
),
);
const shortcuts = SHORTCUT_GROUPS.flatMap((g) =>
g.shortcuts.filter((s: Shortcut) =>
hit(`${g.title} ${shortcutKeys(s, 'pc')} ${shortcutKeys(s, 'mac')} ${s.description}`, words),
),
);
const functions = FORMULA_REF.filter((e) => matchesQuery(e, query.value));
return { items, shortcuts, functions, empty: !items.length && !shortcuts.length && !functions.length };
});
/** Escape in the search box first clears a search, only then closes (an
* empty box would otherwise leave it to BaseModal's own Escape handling). */
function onSearchEscape() {
if (query.value) query.value = '';
else emit('close');
}
function go(p: Page) {
page.value = p;
query.value = '';
}
</script>
<template>
<BaseModal :open="open" title="Hilfe" wide @close="emit('close')">
<div class="help-page">
<input
ref="searchEl"
v-model="query"
class="search"
type="search"
placeholder="Hilfe durchsuchen, z. B. „Bezug“, „fett“, „SUM“ …"
aria-label="Hilfe durchsuchen"
@keydown.esc.prevent.stop="onSearchEscape"
/>
<div class="cols">
<nav class="nav">
<button
v-for="t in HELP_TOPICS"
:key="t.id"
:class="{ on: !results && page === t.id }"
@click="go(t.id)"
>
{{ t.title }}
</button>
<span class="sep" />
<button :class="{ on: !results && page === 'shortcuts' }" @click="go('shortcuts')">Tastenkürzel</button>
<button :class="{ on: !results && page === 'functions' }" @click="go('functions')">Funktionen</button>
</nav>
<div class="content">
<!-- search results -->
<template v-if="results">
<p v-if="results.empty" class="dim">Nichts gefunden.</p>
<section v-if="results.items.length">
<h3>Themen</h3>
<ul class="items">
<li v-for="(r, i) in results.items" :key="i">
<span class="where">{{ r.where }}</span>
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<span v-html="rich(r.text)" />
</li>
</ul>
</section>
<section v-if="results.shortcuts.length">
<h3>Tastenkürzel</h3>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in results.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
<section v-if="results.functions.length">
<h3>Funktionen</h3>
<dl class="fns">
<template v-for="f in results.functions" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>{{ f.description }}</dd>
</template>
</dl>
</section>
</template>
<!-- a topic -->
<template v-else-if="topic">
<h3 class="title">{{ topic.title }}</h3>
<section v-for="(s, i) in topic.sections" :key="i">
<h4 v-if="s.heading">{{ s.heading }}</h4>
<ul class="items">
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<li v-for="(item, j) in s.items" :key="j" v-html="rich(item)" />
</ul>
</section>
</template>
<template v-else-if="page === 'shortcuts'">
<h3 class="title">Tastenkürzel</h3>
<p class="dim">
Windows und Linux verwenden dieselben Tasten. Auf dem Mac steht ⌘ für Strg und ⌥ für Alt. Viele
Mac-Tastaturen haben keine Tasten Pos1, Ende, Bild auf/ab und Entf, dort hilft fn. Die
Funktionstasten F1 bis F4 brauchen je nach Einstellung ebenfalls fn.
</p>
<section v-for="g in SHORTCUT_GROUPS" :key="g.title">
<h4>{{ g.title }}</h4>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in g.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
</template>
<template v-else-if="page === 'functions'">
<h3 class="title">Funktionen</h3>
<p class="dim">
Eingebaute Funktionen und Rechenzeichen für Formeln. Argumente werden mit <code>;</code> getrennt,
ein <code>?</code> kennzeichnet ein optionales Argument.
</p>
<dl class="fns">
<template v-for="f in FORMULA_REF" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>
{{ f.description }}
<span v-if="f.example" class="example">Beispiel: <code>{{ f.example }}</code></span>
</dd>
</template>
</dl>
</template>
</div>
</div>
</div>
<template #footer>
<button class="primary" @click="emit('close')">Schließen</button>
</template>
</BaseModal>
</template>
<style scoped>
.help-page {
display: flex;
flex-direction: column;
gap: 12px;
/* fixed height: switching pages or searching must not resize (and so
re-centre) the dialog; the content column scrolls instead */
height: min(600px, calc(100dvh - 190px));
}
.search {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
}
.cols {
flex: 1;
min-height: 0;
display: flex;
gap: 18px;
}
.nav {
flex: none;
width: 170px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav button {
text-align: left;
border: 0;
background: transparent;
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius-sm);
font: inherit;
cursor: pointer;
}
.nav button:hover {
background: var(--surface-2);
}
.nav button.on {
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
}
.nav .sep {
height: 1px;
background: var(--border);
margin: 6px 0;
}
.content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding-right: 4px;
line-height: 1.5;
}
.title {
margin: 0 0 8px;
font-size: 15px;
}
h3 {
font-size: 13px;
margin: 0 0 6px;
}
h4 {
font-size: 12.5px;
margin: 14px 0 4px;
color: var(--text-dim);
}
section + section h3 {
margin-top: 16px;
}
.items {
margin: 0;
padding-left: 18px;
}
.items li + li {
margin-top: 4px;
}
.where {
display: block;
font-size: 11px;
color: var(--text-dim);
}
.keys {
border-collapse: collapse;
width: 100%;
}
.keys th,
.keys td {
padding: 4px 8px 4px 0;
vertical-align: top;
text-align: left;
border-bottom: 1px solid var(--border);
}
.keys th {
font-size: 11.5px;
font-weight: 600;
color: var(--text-dim);
}
.keys th.here {
color: var(--accent);
}
.keys td:first-child {
width: 36%;
}
.keys td.here kbd {
border-color: var(--accent);
}
kbd {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
.fns {
margin: 0;
}
.fns dt {
margin-top: 10px;
}
.fns dd {
margin: 2px 0 0 0;
color: var(--text-dim);
}
.example {
display: block;
}
code {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border-radius: 3px;
padding: 0 3px;
}
.dim {
color: var(--text-dim);
}
/* phones: navigation above the content */
@media (max-width: 640px) {
.cols {
flex-direction: column;
}
.nav {
flex: none;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.nav .sep {
display: none;
}
/* no room for three columns: each shortcut becomes a small block, the
action on top, the two key columns below it with their label */
.keys thead {
display: none;
}
.keys tr {
display: block;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.keys td {
display: block;
width: auto !important;
padding: 1px 0;
border: 0;
}
.keys td[data-label]::before {
content: attr(data-label) ': ';
font-size: 11px;
color: var(--text-dim);
}
kbd {
overflow-wrap: anywhere;
}
}
</style>
+3 -2
View File
@@ -208,8 +208,9 @@ function resetDoc() {
</label>
<label class="f row-inline">
<button class="tgl" :class="{ on: selFmt.bold }" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
<button class="tgl" :class="{ on: selFmt.bold }" title="Fett (Strg+Umschalt+B)" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" title="Kursiv (Strg+Umschalt+I)" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
<button class="tgl" :class="{ on: selFmt.underline }" title="Unterstrichen (Strg+Umschalt+U)" @click="applyFmt({ underline: !selFmt.underline })"><u>U</u></button>
</label>
<label class="f">
+220 -4
View File
@@ -10,8 +10,8 @@
* Also handles cell selection (click / shift / drag), keyboard navigation,
* in-cell editing and per-column resize.
*/
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { numberToCol, cycleReferenceLocks, completeFormula } from '../engine';
import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
@@ -116,6 +116,7 @@ function formatStyle(c: number, r: number): Record<string, string> {
if (f.bg) s.background = f.bg;
if (f.bold) s.fontWeight = '600';
if (f.italic) s.fontStyle = 'italic';
if (f.underline) s.textDecoration = 'underline';
return s;
}
@@ -306,6 +307,21 @@ const clipMarqueeStyle = computed(() => {
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The cell/range just picked for the formula being edited (point mode, by
* mouse or arrow keys) -- the grid selection itself stays on the edited
* cell meanwhile, so without this the pick would be invisible. */
const pointRectStyle = computed(() => {
const k = store.pointRect.value;
if (!k || k.tableId !== props.tableId) return null;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(k.c1 + 1) - left + 'px',
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The copy/cut selection's *own* first cell, called out inside the marquee
* even once the live selection has moved on to pick a paste destination
* (which is when `is-active` no longer sits there). */
@@ -643,6 +659,81 @@ function onPointDragUp() {
window.removeEventListener('blur', onPointDragUp);
}
// ---- references of the formula being edited (coloured, draggable) -------
/** The edited formula's references that point into this table, outlined in
* their colour. Dragging an outline's edge moves the reference, dragging a
* corner resizes it -- the formula text follows live (store.moveFormulaRef),
* same as Excel. */
const refHighlights = computed(() =>
store.formulaRefs.value
.filter((h) => h.tableId === props.tableId)
.map((h) => {
const left = colLeft(h.c0);
const top = rowTop(h.r0);
return {
...h,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(h.c1 + 1) - left + 'px',
height: rowTop(h.r1 + 1) - top + 'px',
'--ref': h.color,
},
};
}),
);
type RefCorner = 'nw' | 'ne' | 'sw' | 'se';
let refDrag: {
index: number;
orig: { c0: number; r0: number; c1: number; r1: number };
/** null = move by the edge, else the corner being dragged */
corner: RefCorner | null;
start: { c: number; r: number };
} | null = null;
function onRefHandleDown(e: MouseEvent, h: { index: number; c0: number; r0: number; c1: number; r1: number }, corner: RefCorner | null) {
if (e.button !== 0) return;
e.preventDefault(); // keep focus (and the caret) in the formula editor
e.stopPropagation(); // no cell/canvas-object mousedown underneath
const start = cellFromPoint(e.clientX, e.clientY);
if (!start) return;
refDrag = { index: h.index, orig: { c0: h.c0, r0: h.r0, c1: h.c1, r1: h.r1 }, corner, start };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onRefDragMove, true);
window.addEventListener('mouseup', onRefDragUp, true);
window.addEventListener('blur', onRefDragUp);
}
function onRefDragMove(e: MouseEvent) {
const d = refDrag;
const t = table.value;
if (!d || !t) return;
if (e.buttons === 0) {
onRefDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const o = d.orig;
if (!d.corner) {
// shift the whole rectangle, but never past the table's edges
const dc = Math.max(-o.c0, Math.min(t.cols - 1 - o.c1, hit.c - d.start.c));
const dr = Math.max(-o.r0, Math.min(t.rows - 1 - o.r1, hit.r - d.start.r));
store.moveFormulaRef(d.index, o.c0 + dc, o.r0 + dr, o.c1 + dc, o.r1 + dr);
return;
}
// the opposite corner stays put; the dragged one follows the pointer
const fixedC = d.corner === 'nw' || d.corner === 'sw' ? o.c1 : o.c0;
const fixedR = d.corner === 'nw' || d.corner === 'ne' ? o.r1 : o.r0;
store.moveFormulaRef(d.index, fixedC, fixedR, hit.c, hit.r);
}
function onRefDragUp() {
if (!refDrag) return;
refDrag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onRefDragMove, true);
window.removeEventListener('mouseup', onRefDragUp, true);
window.removeEventListener('blur', onRefDragUp);
}
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
@@ -879,7 +970,7 @@ const editStyle = computed(() => {
const s = editing.value;
if (!s) return {};
// look like the cell being edited: its own text/fill colour (else the
// table's), bold/italic -- a white-on-dark cell must not turn into dark
// table's), bold/italic/underline -- a white-on-dark cell must not turn into dark
// text on a dark fill while editing
const f = store.cellFormat(props.tableId, s.col, s.row);
const style: Record<string, string> = {
@@ -892,6 +983,7 @@ const editStyle = computed(() => {
if (f.bg) style['--surface'] = f.bg;
if (f.bold) style.fontWeight = '600';
if (f.italic) style.fontStyle = 'italic';
if (f.underline) style.textDecoration = 'underline';
return style;
});
const editMirrorStyle = computed(() => {
@@ -965,7 +1057,8 @@ function commitEdit() {
if (!editing.value) return;
committing = true;
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
// a forgotten closing ")" is completed rather than stored as an error
store.setCellInput(props.tableId, col, row, completeFormula(editValue.value));
editing.value = null;
store.registerFormulaEditor(null);
autocomplete.close();
@@ -1126,6 +1219,17 @@ function onKeydown(e: KeyboardEvent) {
if (mod) {
const k = e.key.toLowerCase();
// Ctrl/Cmd+Shift+B (or F, "fett") / I / U: toggle bold / italic /
// underline for the whole selection -- on when not every selected cell
// has it yet, else off, same as the toggle buttons in the side panel.
// With Shift, so plain Cmd+B/I/U stay free for the browser/OS.
const toggles: Record<string, 'bold' | 'italic' | 'underline'> = { b: 'bold', f: 'bold', i: 'italic', u: 'underline' };
const toggle = e.shiftKey ? toggles[k] : undefined;
if (toggle) {
e.preventDefault();
e.stopPropagation();
return store.applySelectionFormat({ [toggle]: !store.selectionFormat.value[toggle] });
}
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
@@ -1347,10 +1451,25 @@ function onFocusGridRequest(e: Event) {
}
window.addEventListener('numolus:focus-grid', onFocusGridRequest);
// After a reload (or opening a document / switching sheets) the restored
// selection is visible but nothing had the keyboard, so arrow keys and typing
// went nowhere until the user clicked. Take it whenever the selection sits in
// this table and the keyboard is otherwise unclaimed -- never away from an
// input, the formula bar or another table someone is working in.
function claimIdleKeyboard() {
if (!mine.value || !root.value) return;
const active = document.activeElement;
if (active && active !== document.body) return;
root.value.focus({ preventScroll: true });
}
onMounted(claimIdleKeyboard);
watch(() => mine.value?.tableId, claimIdleKeyboard, { flush: 'post' });
onBeforeUnmount(() => {
window.removeEventListener('numolus:focus-grid', onFocusGridRequest);
onDragUp();
onPointDragUp();
onRefDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
@@ -1456,6 +1575,25 @@ defineExpose({
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-for="h in refHighlights" :key="'ref' + h.index" class="ref-hl" :style="h.style">
<template v-if="!h.whole">
<span
v-for="edge in ['top', 'right', 'bottom', 'left']"
:key="edge"
class="ref-edge"
:class="'ref-edge-' + edge"
@mousedown="onRefHandleDown($event, h, null)"
/>
<span
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
:key="corner"
class="ref-corner"
:class="'ref-corner-' + corner"
@mousedown="onRefHandleDown($event, h, corner)"
/>
</template>
</div>
<div v-if="pointRectStyle" class="point-rect" :style="pointRectStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
@@ -1666,6 +1804,84 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8;
}
/* the reference being picked for a formula (point mode) */
.point-rect {
position: absolute;
pointer-events: none;
border: 2px dashed var(--accent);
background: var(--accent-weak);
box-sizing: border-box;
z-index: 8;
}
/* a reference of the formula being edited, in its colour (Excel-style) */
.ref-hl {
position: absolute;
pointer-events: none; /* the inside stays clickable for picking cells */
border: 2px solid var(--ref);
background: color-mix(in srgb, var(--ref) 10%, transparent);
box-sizing: border-box;
z-index: 9;
}
/* the edges move the reference, the corners resize it */
.ref-edge {
position: absolute;
pointer-events: auto;
cursor: move;
}
.ref-edge-top,
.ref-edge-bottom {
left: 0;
right: 0;
height: 6px;
}
.ref-edge-top {
top: -4px;
}
.ref-edge-bottom {
bottom: -4px;
}
.ref-edge-left,
.ref-edge-right {
top: 0;
bottom: 0;
width: 6px;
}
.ref-edge-left {
left: -4px;
}
.ref-edge-right {
right: -4px;
}
.ref-corner {
position: absolute;
pointer-events: auto;
width: 7px;
height: 7px;
background: var(--ref);
border: 1px solid var(--surface);
border-radius: 1px;
z-index: 1;
}
.ref-corner-nw {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.ref-corner-ne {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.ref-corner-sw {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.ref-corner-se {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
/* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute;
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { HELP_TOPICS, SHORTCUT_GROUPS, shortcutKeys } from '../help';
describe('in-app help content', () => {
it('topics have unique ids that never clash with the fixed pages', () => {
const ids = HELP_TOPICS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).not.toContain('shortcuts');
expect(ids).not.toContain('functions');
});
it('no empty topics, sections, items or shortcuts', () => {
for (const t of HELP_TOPICS) {
expect(t.title.trim()).not.toBe('');
expect(t.sections.length).toBeGreaterThan(0);
for (const s of t.sections) {
expect(s.items.length).toBeGreaterThan(0);
for (const i of s.items) expect(i.trim()).not.toBe('');
}
}
for (const g of SHORTCUT_GROUPS) {
expect(g.shortcuts.length).toBeGreaterThan(0);
for (const s of g.shortcuts) {
expect(s.keys.trim()).not.toBe('');
expect(s.description.trim()).not.toBe('');
}
}
});
it('inline markup is balanced, so nothing renders half-bold', () => {
const all = HELP_TOPICS.flatMap((t) => t.sections.flatMap((s) => s.items));
for (const text of all) {
expect((text.match(/`/g) ?? []).length % 2, text).toBe(0);
expect((text.match(/\*\*/g) ?? []).length % 2, text).toBe(0);
}
});
it('names the keys per platform: Strg on Windows/Linux, ⌘/⌥ and the Mac overrides on a Mac', () => {
expect(shortcutKeys({ keys: 'Mod+Umschalt+U' }, 'pc')).toBe('Strg+Umschalt+U');
expect(shortcutKeys({ keys: 'Mod+Umschalt+U' }, 'mac')).toBe('⌘+Umschalt+U');
expect(shortcutKeys({ keys: 'Alt+Enter oder Mod+Enter' }, 'mac')).toBe('⌥+Enter oder ⌘+Enter');
expect(shortcutKeys({ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→' }, 'pc')).toBe('Pos1 / Ende');
expect(shortcutKeys({ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→' }, 'mac')).toBe('fn+← / fn+→');
// running text: only whole words are key names
expect(shortcutKeys('Alter mit Mod', 'mac')).toBe('Alter mit ⌘');
});
it('no Windows/Linux key text is left with a Mac-only symbol, and vice versa', () => {
for (const g of SHORTCUT_GROUPS) {
for (const s of g.shortcuts) {
expect(shortcutKeys(s, 'pc'), s.keys).not.toMatch(/[⌘⌥⌫]|\bfn\b/);
expect(shortcutKeys(s, 'mac'), s.keys).not.toMatch(/\b(Strg|Mod|Alt)\b/);
}
}
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* Content of the in-app help (components/HelpModal.vue): user-facing topics
* and the keyboard shortcuts. Kept as plain data so the dialog's search can
* run over it; the formula functions are not listed here but taken from
* data/codeReference.ts, so a new built-in shows up in the help by itself.
*
* Text may use `**fett**` and `` `Code` `` -- see HelpModal's `rich()`.
* Hand-maintained: whoever adds or changes a shortcut or a visible feature
* updates this file too (the shortcut test only checks its shape).
*/
export interface HelpSection {
heading?: string;
items: string[];
}
export interface HelpTopic {
id: string;
title: string;
sections: HelpSection[];
}
export interface Shortcut {
/** As on Windows and Linux, with `Mod` for the platform's command key --
* see {@link shortcutKeys}. */
keys: string;
/** Mac keys where they differ by more than ⌘/⌥ (no Pos1/Bild/Entf keys on
* most Mac keyboards, F-keys behind fn, trackpad gestures). */
mac?: string;
description: string;
}
export type KeyPlatform = 'pc' | 'mac';
/** The keys of a shortcut as written on the given platform: `Mod` is Strg on
* Windows/Linux and ⌘ on a Mac, `Alt` is ⌥ on a Mac. Text that is not a key
* name (e.g. "Mod" in a help topic) is converted the same way. */
export function shortcutKeys(s: Pick<Shortcut, 'keys' | 'mac'> | string, platform: KeyPlatform): string {
const text = typeof s === 'string' ? s : platform === 'mac' ? (s.mac ?? s.keys) : s.keys;
if (platform === 'pc') return text.replace(/\bMod\b/g, 'Strg');
return text.replace(/\bMod\b/g, '⌘').replace(/\bAlt\b/g, '⌥');
}
export interface ShortcutGroup {
title: string;
shortcuts: Shortcut[];
}
export const HELP_TOPICS: HelpTopic[] = [
{
id: 'start',
title: 'Erste Schritte',
sections: [
{
items: [
'Ein **Blatt** ist eine freie Fläche, auf der **Objekte** liegen: Tabellen, Diagramme, Textfelder und Buttons. Sie werden über die Symbole in der Werkzeugleiste eingefügt.',
'Objekte an ihrer Kopfleiste verschieben, an der Ecke unten rechts in der Größe ändern. Ein Doppelklick auf den Namen benennt sie um, das `×` in der Kopfleiste löscht sie.',
'Mehrere Blätter über die Reiter unten: hinzufügen mit `+`, umbenennen per Doppelklick, Reihenfolge durch Ziehen.',
'Das Zahnrad neben dem Titel öffnet die **Einstellungen** für das gewählte Objekt, das Blatt und das Dokument.',
'Zoomen mit `Mod` + Mausrad oder über die Zoom-Leiste unten rechts.',
],
},
],
},
{
id: 'tables',
title: 'Tabellen & Auswahl',
sections: [
{
heading: 'Auswählen',
items: [
'Klick wählt eine Zelle, **Umschalt+Klick** oder Ziehen einen Bereich.',
'Klick auf einen Spalten- oder Zeilenkopf wählt die ganze Spalte oder Zeile, die Ecke oben links die ganze Tabelle.',
'**Mod+Klick** fügt eine weitere Zelle hinzu, **Mod+Ziehen** einen weiteren Bereich.',
'**Mod+A** wählt den zusammenhängenden Bereich um die aktive Zelle, ein zweites Mal alle befüllten Zellen.',
'Die Statusleiste zeigt Summe, Mittelwert und Anzahl der Auswahl.',
],
},
{
heading: 'Eingeben',
items: [
'Einfach lostippen oder **F2** bzw. Doppelklick, um den Inhalt zu bearbeiten. **Enter** übernimmt, **Escape** bricht ab.',
'Zeilenumbruch in einer Zelle mit **Alt+Enter** oder **Mod+Enter**.',
'Der Inhalt der aktiven Zelle steht auch in der **Formelleiste** oben und kann dort bearbeitet werden. Links daneben zeigt das Namensfeld, wo man sich befindet. Dort kann auch ein Ziel eingegeben werden (`B5`, `Umsatz::A1:C4`, ein Tabellen- oder Blattname).',
],
},
{
heading: 'Zeilen & Spalten',
items: [
'Rechtsklick auf einen Zeilen- oder Spaltenkopf: einfügen oder löschen.',
'Rechtsklick in eine Zelle: kopieren, einfügen, Zeile oder Spalte am Ende hinzufügen, Abhängigkeiten anzeigen.',
'Spaltenbreite und Zeilenhöhe durch Ziehen am Rand des Kopfs.',
'In den Einstellungen kann eine Tabelle eine **feste Größe** mit eigenem Scrollbereich und fixierten Kopfzeilen und Spalten bekommen.',
],
},
],
},
{
id: 'formulas',
title: 'Formeln',
sections: [
{
heading: 'Grundlagen',
items: [
'Eine Formel beginnt mit `=`, z. B. `=SUM(B2:B6)*1.1` oder `=B2+C2`. Argumente werden mit `;` getrennt.',
'Bezüge: `A1` eine Zelle, `A1:C4` ein Bereich, `B:B` eine ganze Spalte, `2:2` eine ganze Zeile.',
'Andere Tabelle: `Umsatz!B2` oder `Umsatz::B2`. Gibt es den Tabellennamen auf mehreren Blättern: `Analyse!Umsatz::B2`. Namen mit Leerzeichen in einfachen Anführungszeichen: `\'Kosten Q3\'!A1`.',
'Text in doppelten Anführungszeichen: `="Summe: " & B7`.',
'Fehlt beim Übernehmen mit Enter eine schließende Klammer, wird sie ergänzt.',
],
},
{
heading: 'Bezüge zeigen statt tippen',
items: [
'Während der Eingabe einer Formel fügt ein Klick in eine Zelle ihren Bezug ein, Ziehen einen Bereich. Das geht auch in anderen Tabellen.',
'Mit der Tastatur: Direkt nach `=`, `(`, `;` oder einem Rechenzeichen wählen die **Pfeiltasten** eine Zelle, mit **Umschalt** einen Bereich.',
'Alle Bezüge der Formel werden farbig umrahmt. **Am Rand ziehen** verschiebt einen Bezug, **an einer Ecke ziehen** ändert seine Größe. Die Formel passt sich sofort an.',
'**F4** schaltet den Bezug am Cursor durch: `A1` → `$A$1` → `A$1` → `$A1`. Ein `$` hält Spalte oder Zeile beim Kopieren fest.',
],
},
{
heading: 'Funktionen',
items: [
'Beim Tippen eines Funktionsnamens erscheint eine Vorschlagsliste. **Pfeil auf/ab** wählt, **Tab** oder **Enter** übernimmt.',
'Alle eingebauten Funktionen stehen unter **Funktionen** in dieser Hilfe.',
'**Eigene Funktionen** legt man über `fx` in der Werkzeugleiste an. Sie werden danach wie eingebaute aufgerufen.',
'**Benannte Bereiche** (Etikett-Symbol) geben einem Bereich einen Namen, z. B. `=SUM(Umsatz_Q1)`.',
'Rechtsklick in eine Zelle › **Abhängigkeiten anzeigen** zeigt, woraus eine Formel rechnet und wer sie verwendet.',
],
},
],
},
{
id: 'format',
title: 'Formatierung',
sections: [
{
items: [
'Zellen auswählen, dann in den Einstellungen unter **Auswahl**: Ausrichtung, Zahlenformat, **F**ett, **K**ursiv, **U**nterstrichen, Text- und Füllfarbe.',
'Mit der Tastatur: **Mod+Umschalt+B** (oder **F**) fett, **Mod+Umschalt+I** kursiv, **Mod+Umschalt+U** unterstrichen. Erneut drücken schaltet wieder aus.',
'Die Formatierung gilt für die ganze Auswahl, auch für mehrere Bereiche.',
'Wie Zahlen dargestellt werden (Dezimal- und Tausendertrennzeichen), richtet sich nach der Ländereinstellung von Objekt, Blatt oder Dokument.',
],
},
],
},
{
id: 'macros',
title: 'Makros & Buttons',
sections: [
{
items: [
'**Makros** (Symbol in der Werkzeugleiste) ändern per Skript Zellen, Zeilen, Spalten, Tabellen und Blätter, auch dokumentweit.',
'Ein Makro läuft nur, wenn man es startet: mit **Ausführen** im Makro-Dialog oder per Klick auf ein **Button**-Objekt auf der Fläche.',
'Welches Makro ein Button startet, wird in den Einstellungen oder per Rechtsklick auf den Button gewählt.',
'Neben dem Editor steht eine durchsuchbare Befehlsreferenz. Ein Klick fügt den Befehl ein.',
],
},
],
},
{
id: 'files',
title: 'Speichern & Teilen',
sections: [
{
heading: 'Speichern',
items: [
'Ohne Anmeldung liegen Dokumente **in diesem Browser** und werden automatisch gespeichert. In einem anderen Browser oder nach dem Löschen der Browserdaten sind sie dort nicht vorhanden.',
'Mit **Anmelden** (Link oder Code per E-Mail) liegen Dokumente im Konto und sind überall verfügbar.',
'**Datei › Dokumente …** listet alle Dokumente: öffnen, umbenennen, duplizieren, löschen, ins Konto hochladen.',
'**Datei** öffnet und importiert `.num`, `.json`, `.csv` und `.xlsx` und exportiert in diese Formate.',
],
},
{
heading: 'Teilen & gemeinsam bearbeiten',
items: [
'**Teilen** erstellt einen Link zum Ansehen oder Bearbeiten oder lädt per E-Mail ein. Ein lokales Dokument wird dafür zuerst hochgeladen.',
'Änderungen der anderen erscheinen sofort. Ihre Auswahl ist als farbiger Rahmen mit Namen zu sehen, oben rechts stehen ihre Avatare.',
'Rückgängig nimmt nur die eigenen Änderungen zurück.',
'Wer nur ansehen darf, sieht „Nur Lesen“ und kann nichts ändern.',
],
},
],
},
];
export const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'In der Tabelle',
shortcuts: [
{ keys: 'Pfeiltasten', description: 'Zelle wechseln' },
{ keys: 'Umschalt+Pfeiltasten', description: 'Auswahl erweitern' },
{ keys: 'Mod+Pfeiltasten', description: 'An den Rand des Datenblocks springen (mit Umschalt: Auswahl bis dorthin)' },
{ keys: 'Tab / Umschalt+Tab', description: 'Nach rechts / links, innerhalb einer Auswahl der Reihe nach' },
{ keys: 'Enter / Umschalt+Enter', description: 'Nach unten / oben, innerhalb einer Auswahl der Reihe nach' },
{ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→', description: 'Anfang / Ende der Zeile' },
{ keys: 'Mod+Pos1 / Mod+Ende', mac: 'fn+⌘+← / fn+⌘+→', description: 'Erste / letzte Zelle der Tabelle' },
{ keys: 'Bild auf / Bild ab', mac: 'fn+↑ / fn+↓', description: 'Seitenweise blättern' },
{ keys: 'Mod+A', description: 'Zusammenhängenden Bereich wählen, zweimal: alle befüllten Zellen' },
{ keys: 'F2', mac: 'F2 (ggf. fn+F2)', description: 'Aktive Zelle bearbeiten (oder einfach lostippen)' },
{ keys: 'Entf / Rücktaste', mac: '⌫ / fn+⌫', description: 'Inhalt der Auswahl leeren' },
{ keys: 'Mod+C / Mod+X / Mod+V', description: 'Kopieren / Ausschneiden / Einfügen' },
{ keys: 'Escape', description: 'Markierung der kopierten Zellen aufheben' },
{ keys: 'Mod+Umschalt+B oder F', description: 'Fett ein/aus' },
{ keys: 'Mod+Umschalt+I', description: 'Kursiv ein/aus' },
{ keys: 'Mod+Umschalt+U', description: 'Unterstrichen ein/aus' },
],
},
{
title: 'Beim Bearbeiten einer Zelle',
shortcuts: [
{ keys: 'Enter / Tab', description: 'Übernehmen und nach unten / rechts' },
{ keys: 'Umschalt+Enter / Umschalt+Tab', description: 'Übernehmen und nach oben / links' },
{ keys: 'Escape', description: 'Abbrechen' },
{ keys: 'Alt+Enter oder Mod+Enter', description: 'Zeilenumbruch in der Zelle' },
{ keys: 'Pfeiltasten nach = ( ; oder Rechenzeichen', description: 'Zellbezug wählen, mit Umschalt einen Bereich' },
{ keys: 'F4', mac: 'F4 (ggf. fn+F4)', description: 'Bezug sperren: A1 → $A$1 → A$1 → $A1' },
{ keys: 'Pfeil auf/ab, Tab oder Enter', description: 'In der Vorschlagsliste wählen und übernehmen' },
],
},
{
title: 'Allgemein',
shortcuts: [
{ keys: 'Mod+Z', description: 'Rückgängig' },
{ keys: 'Mod+Y oder Mod+Umschalt+Z', description: 'Wiederholen' },
{ keys: 'Mod+Mausrad', mac: '⌘+Mausrad oder Trackpad mit zwei Fingern auseinander/zusammen', description: 'Zoomen' },
{ keys: 'Entf / Rücktaste', mac: '⌫', description: 'Gewähltes Diagramm löschen' },
{ keys: 'F1', mac: 'F1 (ggf. fn+F1)', description: 'Diese Hilfe öffnen' },
],
},
];
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
parseFormula,
completeFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
@@ -489,3 +490,21 @@ describe('collectRefsInBlock -- reference walk used by the macro save-time quali
expect(refs).toHaveLength(2);
});
});
describe('completeFormula -- closing what Enter left open', () => {
it('appends missing closing parentheses', () => {
expect(completeFormula('=SUMME(A1:A5')).toBe('=SUMME(A1:A5)');
expect(completeFormula('=RUNDEN(SUMME(A1;B1)*(2+3')).toBe('=RUNDEN(SUMME(A1;B1)*(2+3))');
expect(completeFormula('=SUMME(A1 ')).toBe('=SUMME(A1 )');
});
it('closes an open text literal before the parentheses', () => {
expect(completeFormula('=WENN(A1>0;"ja')).toBe('=WENN(A1>0;"ja")');
});
it('leaves valid formulas, plain text and unfixable input alone', () => {
expect(completeFormula('=SUMME(A1:A5)')).toBe('=SUMME(A1:A5)');
expect(completeFormula('Hallo (Welt')).toBe('Hallo (Welt');
expect(completeFormula('=SUMME(A1+')).toBe('=SUMME(A1+');
expect(completeFormula('=A1)')).toBe('=A1)');
expect(completeFormula('=(')).toBe('=(');
});
});
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret, formulaRefSpans } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
@@ -121,3 +121,30 @@ describe('currentIdentifierAtCaret -- function-name autocomplete detection', ()
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
describe('formulaRefSpans -- references with their position in the text', () => {
const texts = (f: string) => formulaRefSpans(f).map((s) => f.slice(s.start, s.end));
it('finds cells, ranges and whole lines in text order', () => {
expect(texts('=SUMME(A1:B3;$C$2)*D4+B:B')).toEqual(['A1:B3', '$C$2', 'D4', 'B:B']);
const [r] = formulaRefSpans('=A1:$B$3');
expect(r.a).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(r.b).toEqual({ col: 1, row: 2, colAbs: true, rowAbs: true });
expect(formulaRefSpans('=2:5')[0]).toMatchObject({ whole: 'row', a: { row: 1 }, b: { row: 4 } });
});
it('includes the qualifier in the span and reports it separately', () => {
const f = "='Kosten Q3'!A1+Analyse!Umsatz::B2:C4";
expect(texts(f)).toEqual(["'Kosten Q3'!A1", 'Analyse!Umsatz::B2:C4']);
const [q, s] = formulaRefSpans(f);
expect(q).toMatchObject({ qualifier: 'Kosten Q3', refStart: f.indexOf('A1') });
expect(s).toMatchObject({ qualifier: 'Umsatz', sheetQualifier: 'Analyse' });
});
it('skips function names, strings and named ranges; tolerates unfinished input', () => {
expect(texts('=LOG10(A1)&"B2"&Umsatz_Q1')).toEqual(['A1']);
expect(texts('=SUMME(A1:A5;B')).toEqual(['A1:A5']);
expect(texts('=WENN(A1;"offen')).toEqual([]);
expect(texts('A1')).toEqual([]);
});
});
+3
View File
@@ -8,6 +8,7 @@
export { tokenize, LexError, type Token, type TokenType } from './lexer';
export {
parseFormula,
completeFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
@@ -37,6 +38,8 @@ export {
WHOLE_LINE_END,
cycleReferenceLocks,
currentIdentifierAtCaret,
formulaRefSpans,
type RefSpan,
} from './refs';
export {
Workbook,
+47
View File
@@ -763,6 +763,53 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
return new Parser(toks, paramNames, paramNames === undefined).parse();
}
/**
* Repair the one mistake that is almost always just forgotten typing: a
* formula committed with Enter while parentheses (or a "..." text literal)
* are still open, e.g. `=SUMME(A1:A5` or `=WENN(A1>0;"ja`. Same as Excel and
* Numbers, the missing closers are appended at the end.
*
* Deliberately conservative: anything that already parses, isn't a formula,
* or still doesn't parse after closing (`=SUMME(A1;`, a stray `)` too many)
* comes back unchanged, so the cell shows its usual parse error instead of a
* "repair" that guessed something the user never meant.
*/
export function completeFormula(input: string): string {
if (!input.startsWith('=')) return input;
const parses = (src: string) => {
try {
parseFormula(src);
return true;
} catch {
return false;
}
};
if (parses(input)) return input;
let fixed = input;
let toks: Token[];
try {
toks = tokenize(fixed.slice(1));
} catch (e) {
// an unterminated "..." literal swallows everything up to the end, so
// closing it first is the only way to count the parentheses before it
if (!(e instanceof LexError) || !e.message.startsWith('Unterminated string')) return input;
fixed += '"';
try {
toks = tokenize(fixed.slice(1));
} catch {
return input;
}
}
let depth = 0;
for (const t of toks) {
if (t.type === 'LPAREN') depth++;
else if (t.type === 'RPAREN' && --depth < 0) return input;
}
fixed += ')'.repeat(depth);
return fixed !== input && parses(fixed) ? fixed : input;
}
/**
* Parse a "block" function body -- the statement grammar (assignments,
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
+96
View File
@@ -174,6 +174,102 @@ export function currentIdentifierAtCaret(
return { text: last.value, start: last.pos + 1 };
}
/** One cell/range reference found in a formula's text -- see {@link formulaRefSpans}. */
export interface RefSpan {
/** offsets into the formula (including its leading "="): `start` is where
* the qualifier (if any) begins, `refStart` where the A1 part begins */
start: number;
refStart: number;
end: number;
qualifier: string | null;
sheetQualifier?: string;
/** both corners as written (`end` equals `start` for a single cell) */
a: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
b: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
/** a whole-column/whole-row range (`B:B`, `2:5`); its open axis runs to
* {@link WHOLE_LINE_END} */
whole?: LineAxis;
}
/**
* Every cell/range reference in a formula, in text order, with where it sits
* in the text -- what the grid colours while a formula is edited (and lets
* the user drag to change it, Excel-style). Same token-level reading of the
* `reference` grammar rule the parser applies (optional `Blatt!Tabelle::` /
* `Tabelle!` qualifier, `A1`, `A1:B2`, `B:B`), but tolerant: an incomplete
* formula still yields the references written so far. Named ranges are left
* out (they have no position of their own to drag); a formula that doesn't
* even tokenize (open "..." literal) yields nothing.
*/
export function formulaRefSpans(formula: string): RefSpan[] {
if (!formula.startsWith('=')) return [];
let toks: Token[];
try {
toks = tokenize(formula.slice(1));
} catch {
return [];
}
const isName = (t: Token | undefined) => !!t && (t.type === 'IDENT' || t.type === 'QUOTED');
const out: RefSpan[] = [];
let i = 0;
while (i < toks.length) {
const first = toks[i];
let j = i;
let qualifier: string | null = null;
let sheetQualifier: string | undefined;
if (isName(toks[j]) && toks[j + 1]?.type === 'BANG' && isName(toks[j + 2]) && toks[j + 3]?.type === 'DBLCOLON') {
sheetQualifier = toks[j].value;
qualifier = toks[j + 2].value;
j += 4;
} else if (isName(toks[j]) && (toks[j + 1]?.type === 'BANG' || toks[j + 1]?.type === 'DBLCOLON')) {
qualifier = toks[j].value;
j += 2;
}
const line = wholeLineAt(toks, j);
const cell = toks[j];
if (line) {
const max = WHOLE_LINE_END;
const isCol = line.axis === 'col';
out.push({
start: first.pos + 1,
refStart: line.a.pos + 1,
end: line.b.pos + line.b.value.length + 1,
qualifier,
sheetQualifier,
a: { col: isCol ? line.start : 0, row: isCol ? 0 : line.start, colAbs: isCol && line.startAbs, rowAbs: !isCol && line.startAbs },
b: { col: isCol ? line.end : max, row: isCol ? max : line.end, colAbs: isCol && line.endAbs, rowAbs: !isCol && line.endAbs },
whole: line.axis,
});
i = j + 3;
continue;
}
if (cell?.type === 'IDENT' && looksLikeCell(cell.value) && toks[j + 1]?.type !== 'LPAREN') {
const a = parseA1(cell.value);
let last = cell;
let b = a;
const other = toks[j + 2];
if (toks[j + 1]?.type === 'COLON' && other?.type === 'IDENT' && looksLikeCell(other.value)) {
b = parseA1(other.value);
last = other;
j += 2;
}
out.push({
start: first.pos + 1,
refStart: cell.pos + 1,
end: last.pos + last.value.length + 1,
qualifier,
sheetQualifier,
a,
b,
});
i = j + 1;
continue;
}
i++;
}
return out;
}
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
/** Stand-in for "the last row/column" in a whole-column/whole-row range's
+1
View File
@@ -171,6 +171,7 @@ function normaliseCellFormat(x: unknown): CellFormat | undefined {
if (colorOrUndef(f.bg)) out.bg = f.bg;
if (f.bold === true) out.bold = true;
if (f.italic === true) out.italic = true;
if (f.underline === true) out.underline = true;
return Object.keys(out).length ? out : undefined;
}
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useDocument, type DocumentStore } from '../document';
import { isError, toA1 } from '../../engine';
import { serializeDocument, parseDocument } from '../../io/json';
@@ -489,6 +490,16 @@ describe('cell formatting, number formats, row height', () => {
expect(s.tableById(id)!.rowHeights).toBeUndefined();
});
it('underline is a cell format like bold and survives a JSON round-trip', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: true, italic: true });
s.replaceDocument(parseDocument(serializeDocument(s.snapshot())));
expect(s.cellFormat(id, 1, 1)).toEqual({ underline: true, italic: true });
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: false });
expect(s.cellFormat(id, 0, 0)).toEqual({ italic: true });
});
it('formatting + row heights survive a JSON round-trip', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
@@ -2397,6 +2408,27 @@ describe('formula "point mode" (click/drag a cell to insert a reference)', () =>
expect(ed.text).toBe('=B2');
});
it('pointRect outlines the picked cell/range until typing or closing the editor', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
expect(s.pointRect.value).toBeNull();
s.pointClick(id, 1, 2);
expect(s.pointRect.value).toEqual({ tableId: id, c0: 1, r0: 2, c1: 1, r1: 2 });
s.pointDrag(id, 2, 3, 0, 1); // dragged up-left: normalised
expect(s.pointRect.value).toEqual({ tableId: id, c0: 0, r0: 1, c1: 2, r1: 3 });
s.noteFormulaTyped();
expect(s.pointRect.value).toBeNull();
s.pointClick(id, 0, 0);
s.registerFormulaEditor(null);
expect(s.pointRect.value).toBeNull();
// no formula in the buffer -> nothing picked, nothing outlined
s.registerFormulaEditor(mockEditor(id, 'text').handle);
s.pointClick(id, 0, 0);
expect(s.pointRect.value).toBeNull();
});
it('typing between two clicks makes the second an insert, not a replace', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
@@ -3016,3 +3048,69 @@ describe('whole-column / whole-row references', () => {
expect(val(s, name, 'A1')).toBe(6);
});
});
describe('references of the formula being edited (coloured, draggable)', () => {
afterEach(() => useDocument().registerFormulaEditor(null));
/** Like mockEditor(), but its text is a ref -- as in the real editors,
* which is what keeps store.formulaRefs live. */
function reactiveEditor(tableId: string, initialText: string) {
const text = ref(initialText);
return {
handle: {
tableId,
getState: () => ({ text: text.value, start: text.value.length, end: text.value.length }),
replace: (s: number, e: number, value: string) => {
text.value = text.value.slice(0, s) + value + text.value.slice(e);
},
},
text,
};
}
it('lists each reference with its table, rectangle and colour, following the text', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const other = s.addTable(s.doc.activeSheetId);
s.renameObject(other, 'Reisen');
const ed = reactiveEditor(id, '=SUMME(B2:C3)+A1+Reisen!A2+B2:C3');
s.registerFormulaEditor(ed.handle);
const refs = s.formulaRefs.value;
expect(refs.map((r) => [r.tableId, r.c0, r.r0, r.c1, r.r1])).toEqual([
[id, 1, 1, 2, 2],
[id, 0, 0, 0, 0],
[other, 0, 1, 0, 1],
[id, 1, 1, 2, 2],
]);
expect(refs[0].color).not.toBe(refs[1].color);
expect(refs[3].color).toBe(refs[0].color); // same reference, same colour
ed.text.value = '=D4';
expect(s.formulaRefs.value.map((r) => [r.c0, r.r0])).toEqual([[3, 3]]);
s.registerFormulaEditor(null);
expect(s.formulaRefs.value).toEqual([]);
});
it('whole columns are clamped to the table and marked as not draggable', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.registerFormulaEditor(reactiveEditor(id, '=SUMME(B:B)').handle);
const [r] = s.formulaRefs.value;
expect(r).toMatchObject({ c0: 1, r0: 0, c1: 1, r1: s.tableById(id)!.rows - 1, whole: true });
expect(s.moveFormulaRef(0, 2, 0, 2, 3)).toBe(false);
});
it('moveFormulaRef rewrites one reference, keeping qualifier and $ locks', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const other = s.addTable(s.doc.activeSheetId);
s.renameObject(other, 'Reisen');
const ed = reactiveEditor(id, '=SUMME($A$1:B2)*Reisen!C3');
s.registerFormulaEditor(ed.handle);
s.moveFormulaRef(0, 1, 1, 2, 3); // moved/resized
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!C3');
s.moveFormulaRef(1, 0, 0, 1, 1); // a single cell grown into a range
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!A1:B2');
s.moveFormulaRef(0, 3, 3, 3, 3); // shrunk to one cell
expect(ed.text.value).toBe('=SUMME($D$4)*Reisen!A1:B2');
});
});
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { dateToSerial, timeSerial } from '../../engine';
import { formatValue } from '../format';
import { formatValue, readableTextOn } from '../format';
describe('formatValue: date/time', () => {
const day = dateToSerial(2026, 9, 2); // a Wednesday
@@ -46,3 +46,23 @@ describe('formatValue: date/time', () => {
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
});
});
describe('readableTextOn', () => {
it('light text on dark fills, dark text on light fills', () => {
expect(readableTextOn('#000000')).toBe('#f5f5f7');
expect(readableTextOn('#000')).toBe('#f5f5f7');
expect(readableTextOn('#1b1b1b')).toBe('#f5f5f7');
expect(readableTextOn('#0057ff')).toBe('#f5f5f7');
expect(readableTextOn('#ffffff')).toBe('#1d1d1f');
expect(readableTextOn('#ffd400')).toBe('#1d1d1f');
expect(readableTextOn('#FFF')).toBe('#1d1d1f');
});
it('ignores what it cannot read, and mostly transparent fills', () => {
expect(readableTextOn(undefined)).toBeNull();
expect(readableTextOn('red')).toBeNull();
expect(readableTextOn('#12345')).toBeNull();
expect(readableTextOn('#00000020')).toBeNull();
expect(readableTextOn('#000000cc')).toBe('#f5f5f7');
});
});
+112 -5
View File
@@ -27,6 +27,7 @@ import {
parseA1,
wholeLineAt,
formatLinePart,
formulaRefSpans,
parseFormula,
parseFunctionBlock,
parseMacroBlock,
@@ -206,6 +207,25 @@ interface ClipboardData {
* the table the edited cell itself lives in (the "home" table for deciding
* whether an inserted reference needs a `Name!` qualifier).
*/
/** Colours for the references of the formula being edited, in text order
* (cycling), same idea as Excel: the n-th reference in the text and its
* outline in the grid share a colour. */
const REF_COLORS = ['#2f6fde', '#d6453d', '#8e44ad', '#1e9e5a', '#c77c02', '#0b9bb5', '#d6338a'];
/** One reference of the formula being edited, as the grid outlines it. */
export interface FormulaRefHighlight {
/** position among the formula's references -- what `moveFormulaRef` takes */
index: number;
tableId: string;
c0: number;
r0: number;
c1: number;
r1: number;
color: string;
/** whole column/row: outlined, but not draggable */
whole: boolean;
}
interface FormulaEditorHandle {
tableId: string;
getState(): { text: string; start: number; end: number };
@@ -670,6 +690,12 @@ function createStore() {
* until the user types something (a plain assignment, not a ref(), so it
* never itself triggers reactivity/undo history). */
let pointSpan: { start: number; end: number } | null = null;
/** The cell/range the last point-mode click, drag or arrow key picked, so
* the grid can outline it while it is still the reference being chosen
* (otherwise picking with the keyboard happens completely blind). Lives
* exactly as long as `pointSpan`: typing, a caret click or closing the
* editor ends it. View state only, never synced or saved. */
const pointRect = ref<{ tableId: string; c0: number; r0: number; c1: number; r1: number } | null>(null);
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() {
@@ -2335,7 +2361,7 @@ function createStore() {
markDirty();
}
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
// ---- cell formatting (alignment, number format, colours, bold/italic/underline) ---
const fmtKey = (c: number, r: number) => `${c},${r}`;
function cellFormat(tableId: string, col: number, row: number): CellFormat {
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
@@ -2719,6 +2745,7 @@ function createStore() {
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
formulaEditor.value = handle;
pointSpan = null;
pointRect.value = null;
}
/** The user typed (as opposed to a click inserting a reference for them):
* the next click should insert a fresh reference, not replace the last
@@ -2728,6 +2755,7 @@ function createStore() {
* and before picking the next). */
function noteFormulaTyped() {
pointSpan = null;
pointRect.value = null;
}
/** True while the registered editor's buffer is a formula -- point mode
* (click-to-insert-reference) only applies then. */
@@ -2893,21 +2921,97 @@ function createStore() {
pointSpan = { start: span.start, end: span.start + text.length };
return true;
}
/** The table a reference in the edited formula points at: its own table
* when unqualified, else by name -- preferring the editor's sheet when the
* name exists on several, as the engine does (Workbook.resolveTableName). */
function refTableId(homeTableId: string, qualifier: string | null, sheetQualifier?: string): string | null {
if (qualifier === null) return homeTableId;
const needle = qualifier.trim().toLowerCase();
const sheetNeedle = sheetQualifier?.trim().toLowerCase();
const candidates = allTables(doc).filter((t) => {
if (t.name.trim().toLowerCase() !== needle) return false;
if (sheetNeedle === undefined) return true;
const sid = sheetIdOfObject(t.id);
return (sid && sheetById(sid)?.name.trim().toLowerCase()) === sheetNeedle;
});
if (candidates.length <= 1) return candidates[0]?.id ?? null;
const homeSheet = sheetIdOfObject(homeTableId);
return (candidates.find((t) => sheetIdOfObject(t.id) === homeSheet) ?? candidates[0]).id;
}
/** Every cell/range reference of the formula being edited (in-cell editor
* or formula bar), resolved to its table and clamped to it, so each grid
* can outline the ones that point into it. Reactive on the editor's text:
* both editors' `getState()` read their own text ref. */
const formulaRefs = computed<FormulaRefHighlight[]>(() => {
const ed = formulaEditor.value;
if (!ed) return [];
const colorOf = new Map<string, string>();
const out: FormulaRefHighlight[] = [];
formulaRefSpans(ed.getState().text).forEach((span, index) => {
const tableId = refTableId(ed.tableId, span.qualifier, span.sheetQualifier);
const t = tableId ? tableById(tableId) : undefined;
if (!t) return;
const c0 = Math.min(span.a.col, span.b.col);
const r0 = Math.min(span.a.row, span.b.row);
if (c0 >= t.cols || r0 >= t.rows) return; // entirely outside the table
const rect = {
c0,
r0,
c1: Math.min(Math.max(span.a.col, span.b.col), t.cols - 1),
r1: Math.min(Math.max(span.a.row, span.b.row), t.rows - 1),
};
// the same reference written twice keeps one colour (as in Excel)
const key = `${t.id}|${rect.c0},${rect.r0},${rect.c1},${rect.r1}`;
if (!colorOf.has(key)) colorOf.set(key, REF_COLORS[colorOf.size % REF_COLORS.length]);
out.push({ index, tableId: t.id, ...rect, color: colorOf.get(key)!, whole: !!span.whole });
});
return out;
});
/** Rewrite the `index`-th reference of the formula being edited to cover
* (c0,r0)-(c1,r1) instead -- dragging a reference outline's edge (move)
* or corner (resize) in the grid, Excel-style. Its qualifier and `$`
* locks stay as written; a range shrunk to one cell becomes a plain cell
* reference. Whole-column/-row references are left alone. */
function moveFormulaRef(index: number, c0: number, r0: number, c1: number, r1: number): boolean {
const ed = formulaEditor.value;
if (!ed) return false;
const text = ed.getState().text;
const span = formulaRefSpans(text)[index];
if (!span || span.whole) return false;
const single = span.a.col === span.b.col && span.a.row === span.b.row;
const lockB = single ? span.a : span.b;
const lo = toA1(Math.min(c0, c1), Math.min(r0, r1), span.a.colAbs, span.a.rowAbs);
const hi = toA1(Math.max(c0, c1), Math.max(r0, r1), lockB.colAbs, lockB.rowAbs);
const body = c0 === c1 && r0 === r1 ? lo : `${lo}:${hi}`;
const next = text.slice(span.start, span.refStart) + body;
if (next === text.slice(span.start, span.end)) return true;
ed.replace(span.start, span.end, next);
// the text around the caret moved: a following grid click must insert a
// fresh reference, not overwrite a span that no longer exists
pointSpan = null;
pointRect.value = null;
return true;
}
/** A plain click on (tableId, col, row) while editing a formula: insert or
* (immediately after another click/drag, before any typing) replace a
* reference at the caret. Returns false when there's no formula editor
* active, so the caller can fall back to its normal click handling. */
function pointClick(tableId: string, col: number, row: number): boolean {
return applyPointRef(tableId, toA1(col, row));
if (!applyPointRef(tableId, toA1(col, row))) return false;
pointRect.value = { tableId, c0: col, r0: row, c1: col, r1: row };
return true;
}
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
* insert/replace a *range* reference, meant to be called live as the drag
* updates (each call replaces the same span, growing/shrinking the range
* as the user drags), not just once at the end. */
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
const rect = { tableId, c0: Math.min(c0, c1), r0: Math.min(r0, r1), c1: Math.max(c0, c1), r1: Math.max(r0, r1) };
const a = toA1(rect.c0, rect.r0);
const b = toA1(rect.c1, rect.r1);
if (!applyPointRef(tableId, a === b ? a : `${a}:${b}`)) return false;
pointRect.value = rect;
return true;
}
// The "active cell" is always the anchor -- the first cell of the current
@@ -3224,6 +3328,9 @@ function createStore() {
jumpToNameInput,
pointClick,
pointDrag,
pointRect,
formulaRefs,
moveFormulaRef,
// document
replaceDocument,
newDocument,
+32
View File
@@ -165,3 +165,35 @@ export function rawString(v: CellValue): string {
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;
}
+1
View File
@@ -42,6 +42,7 @@ export interface CellFormat {
bg?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
/** How a table object is sized on the canvas. */