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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('=(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CellFormat {
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
|
||||
Reference in New Issue
Block a user