Files
numolus/app/frontend/components/CodeReference.vue
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
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>
2026-09-28 20:09:06 +02:00

329 lines
9.2 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">
/**
* 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>