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

411 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>