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
+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>