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:
@@ -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>
|
||||
Reference in New Issue
Block a user