The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
9.2 KiB
Vue
329 lines
9.2 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Searchable command reference next to the macro editor (MacrosModal.vue)
|
||
* and the custom-function editor (CustomFunctionsModal.vue). Static entries
|
||
* come from data/codeReference.ts, filtered to `context`; the "Im Dokument"
|
||
* category is built live from the current document (tables and sheets for
|
||
* macros, named ranges, custom functions) so their exact, correctly quoted
|
||
* names can be inserted with one click, and a function's own parameters get
|
||
* a "Parameter" category. Emits `insert` with the text to put at the
|
||
* editor's caret.
|
||
*/
|
||
import { computed, ref, watch } from 'vue';
|
||
import { useDocument } from '../store/document';
|
||
import {
|
||
REF_CATEGORIES,
|
||
matchesQuery,
|
||
referenceFor,
|
||
type RefContext,
|
||
type RefEntry,
|
||
} from '../data/codeReference';
|
||
|
||
/** A listed entry -- static ones are already filtered to `context`. */
|
||
type Entry = Omit<RefEntry, 'contexts'>;
|
||
|
||
const props = defineProps<{ context: RefContext; params?: string[] }>();
|
||
const emit = defineEmits<{ (e: 'insert', text: string): void }>();
|
||
|
||
const store = useDocument();
|
||
|
||
const DOC_CATEGORY = 'Im Dokument';
|
||
const PARAM_CATEGORY = 'Parameter';
|
||
type Category = (typeof REF_CATEGORIES)[number] | typeof DOC_CATEGORY | typeof PARAM_CATEGORY;
|
||
|
||
const query = ref('');
|
||
const category = ref<Category | null>(null);
|
||
const expanded = ref<string | null>(null);
|
||
|
||
/** Quote a table/sheet name for a reference qualifier when it isn't a bare word. */
|
||
const qualifier = (name: string) => (/^[\p{L}_][\p{L}\d_]*$/u.test(name) ? name : `'${name.replace(/'/g, "''")}'`);
|
||
|
||
const docEntries = computed<Entry[]>(() => {
|
||
const d = store.doc;
|
||
const out: Entry[] = [];
|
||
const tableCount = new Map<string, number>();
|
||
for (const s of d.sheets)
|
||
for (const o of s.objects)
|
||
if (o.kind === 'table') tableCount.set(o.name.toLowerCase(), (tableCount.get(o.name.toLowerCase()) ?? 0) + 1);
|
||
// sheets/tables are only ever named (as text or qualifier) by a macro
|
||
for (const s of props.context === 'macro' ? d.sheets : []) {
|
||
out.push({
|
||
name: s.name,
|
||
category: 'Blätter',
|
||
syntax: `"${s.name}"`,
|
||
description: 'Blatt in diesem Dokument — als Text für ADDTABLE, RENAMESHEET, DELETESHEET.',
|
||
snippet: `"${s.name}"`,
|
||
keywords: 'blatt sheet',
|
||
});
|
||
for (const o of s.objects) {
|
||
if (o.kind !== 'table') continue;
|
||
// a table name used on several sheets must carry its sheet
|
||
const ambiguous = (tableCount.get(o.name.toLowerCase()) ?? 0) > 1;
|
||
const textRef = ambiguous ? `${s.name}!${o.name}` : o.name;
|
||
const cellRef = (ambiguous ? `${qualifier(s.name)}!` : '') + `${qualifier(o.name)}::A1`;
|
||
out.push({
|
||
name: o.name,
|
||
category: 'Tabellen',
|
||
syntax: `"${textRef}" ${cellRef}`,
|
||
description: `Tabelle auf Blatt „${s.name}" (${o.rows} × ${o.cols}). Als Text für Zeilen-/Spalten-/Tabellen-Aktionen, mit ::A1 als Zellbezug.`,
|
||
snippet: `"${textRef}"`,
|
||
example: `INSERTROW("${textRef}"; 0)\nSETVALUE(${cellRef}; "Neu")`,
|
||
keywords: 'tabelle table',
|
||
});
|
||
}
|
||
}
|
||
for (const nr of d.namedRanges) {
|
||
out.push({
|
||
name: nr.name,
|
||
category: 'Zellen',
|
||
syntax: nr.name,
|
||
description: 'Benannter Bereich — überall als Bezug nutzbar, auch als SETVALUE-Ziel.',
|
||
snippet: nr.name,
|
||
keywords: 'name bereich benannt',
|
||
});
|
||
}
|
||
for (const fn of d.customFunctions) {
|
||
out.push({
|
||
name: fn.name,
|
||
category: 'Formelfunktionen',
|
||
syntax: `${fn.name}(${fn.params.join('; ')})`,
|
||
description: 'Eigene Funktion dieses Dokuments — berechnet einen Wert, schreibt selbst nie.',
|
||
snippet: `${fn.name}(${fn.params.join('; ')})`,
|
||
keywords: 'eigene funktion benutzerdefiniert',
|
||
});
|
||
}
|
||
return out;
|
||
});
|
||
|
||
const paramEntries = computed<Entry[]>(() =>
|
||
(props.params ?? [])
|
||
.map((p) => p.trim())
|
||
.filter(Boolean)
|
||
.map((p) => ({
|
||
name: p,
|
||
category: 'Operatoren',
|
||
syntax: p,
|
||
description: 'Parameter dieser Funktion — steht für den Wert (oder Bereich), mit dem sie aufgerufen wird.',
|
||
snippet: p,
|
||
keywords: 'parameter argument',
|
||
})),
|
||
);
|
||
|
||
const available = computed<Array<Entry & { group: Category }>>(() => [
|
||
...paramEntries.value.map((e) => ({ ...e, group: PARAM_CATEGORY as Category })),
|
||
...referenceFor(props.context).map((e) => ({ ...e, group: e.category as Category })),
|
||
...docEntries.value.map((e) => ({ ...e, group: DOC_CATEGORY as Category })),
|
||
]);
|
||
|
||
/** Only categories that have something in this context, in display order. */
|
||
const categories = computed<Category[]>(() =>
|
||
([PARAM_CATEGORY, ...REF_CATEGORIES, DOC_CATEGORY] as Category[]).filter((c) =>
|
||
available.value.some((e) => e.group === c),
|
||
),
|
||
);
|
||
|
||
// switching "Mit Schleife" -> "Einfache Formel" can remove the chosen category
|
||
watch(categories, (cs) => {
|
||
if (category.value && !cs.includes(category.value)) category.value = null;
|
||
});
|
||
|
||
const placeholder = computed(() =>
|
||
props.context === 'macro'
|
||
? 'Befehl suchen … (z. B. zeile, schleife, json)'
|
||
: 'Suchen … (z. B. summe, schleife, wenn)',
|
||
);
|
||
|
||
const entries = computed(() =>
|
||
available.value.filter((e) => (!category.value || e.group === category.value) && matchesQuery(e, query.value)),
|
||
);
|
||
|
||
const grouped = computed(() =>
|
||
categories.value
|
||
.map((c) => ({ category: c, items: entries.value.filter((e) => e.group === c) }))
|
||
.filter((g) => g.items.length),
|
||
);
|
||
|
||
const keyOf = (e: Entry & { group: Category }) => `${e.group}/${e.category}/${e.name}`;
|
||
|
||
function toggle(key: string) {
|
||
expanded.value = expanded.value === key ? null : key;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<aside class="ref" aria-label="Befehlsreferenz">
|
||
<input
|
||
v-model="query"
|
||
class="search"
|
||
type="search"
|
||
:placeholder="placeholder"
|
||
aria-label="Befehle durchsuchen"
|
||
/>
|
||
<div class="chips">
|
||
<button class="chip" :class="{ on: !category }" @click="category = null">Alle</button>
|
||
<button
|
||
v-for="c in categories"
|
||
:key="c"
|
||
class="chip"
|
||
:class="{ on: category === c }"
|
||
@click="category = category === c ? null : c"
|
||
>
|
||
{{ c }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="list">
|
||
<p v-if="!grouped.length" class="empty">Keine Treffer für „{{ query }}".</p>
|
||
<section v-for="g in grouped" :key="g.category">
|
||
<h4>{{ g.category }}</h4>
|
||
<div v-for="e in g.items" :key="keyOf(e)" class="entry" :class="{ open: expanded === keyOf(e) }">
|
||
<div class="head">
|
||
<button class="title" :aria-expanded="expanded === keyOf(e)" @click="toggle(keyOf(e))">
|
||
<code>{{ e.syntax }}</code>
|
||
</button>
|
||
<button class="ins" title="An der Cursorposition einfügen" @click="emit('insert', e.snippet)">
|
||
Einfügen
|
||
</button>
|
||
</div>
|
||
<div v-if="expanded === keyOf(e)" class="detail">
|
||
<p>{{ e.description }}</p>
|
||
<template v-if="e.example">
|
||
<pre>{{ e.example }}</pre>
|
||
<button class="ins" @click="emit('insert', e.example)">Beispiel einfügen</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ref {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-height: 0;
|
||
}
|
||
.search {
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 6px 8px;
|
||
font: inherit;
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
}
|
||
.search:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
background: var(--surface);
|
||
}
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
.chip {
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text-dim);
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
}
|
||
.chip:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
.chip.on {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.list {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
max-height: 60vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding-right: 2px;
|
||
}
|
||
h4 {
|
||
margin: 0 0 4px;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text-dim);
|
||
}
|
||
.entry {
|
||
border-radius: 6px;
|
||
background: var(--surface-2);
|
||
margin-bottom: 3px;
|
||
}
|
||
.head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 6px 4px 8px;
|
||
}
|
||
.title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
text-align: left;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--text);
|
||
padding: 2px 0;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
.title code {
|
||
font-size: 12px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.title:hover code {
|
||
color: var(--accent);
|
||
}
|
||
.ins {
|
||
flex: none;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
border-radius: 6px;
|
||
padding: 2px 8px;
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
}
|
||
.ins:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
.detail {
|
||
padding: 0 8px 8px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--text-dim);
|
||
}
|
||
.detail p {
|
||
margin: 0 0 6px;
|
||
}
|
||
.detail pre {
|
||
margin: 0 0 6px;
|
||
padding: 6px 8px;
|
||
border-radius: 4px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: 11.5px;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.empty {
|
||
margin: 0;
|
||
color: var(--text-dim);
|
||
font-size: 12.5px;
|
||
}
|
||
</style>
|