- 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).
411 lines
12 KiB
Vue
411 lines
12 KiB
Vue
<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>
|