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>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+944
View File
@@ -0,0 +1,944 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
import DocumentsModal from './DocumentsModal.vue';
import LoginModal from './LoginModal.vue';
import ShareModal from './ShareModal.vue';
import UploadModal from './UploadModal.vue';
import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { parseDocument } from '../io/json';
import TheLogo from './TheLogo.vue';
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
import {
exportJson,
exportNum,
readNumFile,
exportActiveTableCsv,
exportXlsx,
importJsonReplace,
importJsonAppend,
importCsv,
importXlsxFile,
readAsText,
readAsArrayBuffer,
extensionOf,
type CsvImportConfig,
} from '../io/operations';
import type { Delimiter } from '../io/csv';
const store = useDocument();
const library = useLibrary();
const session = useSession();
const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts();
const fileMenuOpen = ref(false);
// ---- wordmark only when there's room ----------------------------------
// "numolus" + "wicom" next to the logo are shown only while the whole
// toolbar still fits on one line with them; otherwise just the logo. Measured
// rather than a fixed breakpoint, since what fits depends on the toolbar's
// actual contents. The wordmark's own width is remembered from the last time
// it was visible, so hiding it can't make it look like it would fit again
// (no flip-flopping at the threshold).
const toolbarEl = ref<HTMLElement | null>(null);
const brandExtra = ref<HTMLElement | null>(null);
const showWordmark = ref(true);
let wordmarkWidth = 0;
function fitWordmark() {
const bar = toolbarEl.value;
if (!bar) return;
const cs = getComputedStyle(bar);
const avail = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const gap = parseFloat(cs.columnGap) || 0;
let need = 0;
let items = 0;
for (const child of Array.from(bar.children) as HTMLElement[]) {
if (getComputedStyle(child).display === 'none') continue;
items++;
if (!child.classList.contains('spacer')) need += child.getBoundingClientRect().width;
}
need += gap * Math.max(0, items - 1);
if (showWordmark.value && brandExtra.value) {
// plus the gap to the logo, which goes away with it
const brandGap = parseFloat(getComputedStyle(brandExtra.value.parentElement!).columnGap) || 0;
wordmarkWidth = brandExtra.value.getBoundingClientRect().width + brandGap;
} else {
need += wordmarkWidth;
}
showWordmark.value = need <= avail + 0.5;
}
let resizeObs: ResizeObserver | null = null;
onMounted(() => {
resizeObs = new ResizeObserver(() => fitWordmark());
if (toolbarEl.value) resizeObs.observe(toolbarEl.value);
fitWordmark();
// web fonts arriving late change the measured widths
void document.fonts?.ready.then(fitWordmark);
});
onBeforeUnmount(() => resizeObs?.disconnect());
const fileMenuWrap = ref<HTMLElement | null>(null);
// Close the Datei menu on Escape or on a press anywhere outside it. Capture
// phase, so canvas/grid handlers that stop propagation can't swallow it.
function onOutsidePointer(e: PointerEvent) {
if (!fileMenuWrap.value?.contains(e.target as Node)) fileMenuOpen.value = false;
}
function onMenuKey(e: KeyboardEvent) {
if (e.key === 'Escape') fileMenuOpen.value = false;
}
function detachMenuListeners() {
document.removeEventListener('pointerdown', onOutsidePointer, true);
document.removeEventListener('keydown', onMenuKey, true);
}
watch(fileMenuOpen, (open) => {
if (open) {
document.addEventListener('pointerdown', onOutsidePointer, true);
document.addEventListener('keydown', onMenuKey, true);
} else {
detachMenuListeners();
}
});
onBeforeUnmount(detachMenuListeners);
const aboutOpen = ref(false);
const functionsOpen = ref(false);
const namedRangesOpen = ref(false);
const macrosOpen = ref(false);
const documentsOpen = ref(false);
const shareOpen = ref(false);
const uploadOpen = ref(false);
const loginOpen = ref(false);
const loginReason = ref<string | undefined>();
const accountMenuOpen = ref(false);
function openLogin(reason?: string) {
loginReason.value = reason;
documentsOpen.value = false;
shareOpen.value = false;
loginOpen.value = true;
}
// after signing in: offer once (per account, per browser) to move this
// browser's documents into the account
onMounted(() => {
const user = session.state.user;
if (!user || !library.state.docs.length) return;
const key = `numolus:upload-asked:${user.id}`;
try {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, '1');
} catch {
return;
}
uploadOpen.value = true;
});
const statusNote = computed(() => {
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
if (library.state.syncState === 'saving') return 'Speichert …';
return '';
});
// ---- document title (inline rename) ------------------------------------
const titleDraft = ref(library.currentTitle.value);
watch(library.currentTitle, (t) => (titleDraft.value = t));
function commitTitle() {
const ref = library.state.current;
const t = titleDraft.value.trim();
if (!ref || !t) titleDraft.value = library.currentTitle.value;
else if (t !== library.currentTitle.value) void library.rename(ref, t);
}
const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
const themeLabel = computed(
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
);
const themeIcon = computed(
() => ({ system: 'auto', light: 'sun', dark: 'moon' })[themeMode.value] as ToolbarIconName,
);
// ---- insert objects ------------------------------------------------
function insertTable() {
store.addTable(store.doc.activeSheetId);
toasts.info('Tabelle eingefügt.');
}
function insertChart() {
const id = store.addChart(store.doc.activeSheetId);
if (!id) toasts.error('Diagramm konnte nicht eingefügt werden.');
}
function insertText() {
store.addText(store.doc.activeSheetId);
toasts.info('Textfeld eingefügt.');
}
function insertButton() {
store.addButton(store.doc.activeSheetId);
toasts.info('Button eingefügt.');
}
// ---- CSV export dialog -------------------------------------------
const csvExportOpen = ref(false);
const csvExport = ref<{ delimiter: Delimiter; bom: boolean; crlf: boolean }>({
delimiter: ',',
bom: false,
crlf: true,
});
function openCsvExport() {
csvExport.value.delimiter = store.doc.csvDelimiter; // start from the document default
csvExportOpen.value = true;
}
function runCsvExport() {
try {
exportActiveTableCsv(store, csvExport.value);
csvExportOpen.value = false;
toasts.success('CSV exportiert.');
} catch (err) {
toasts.error((err as Error).message);
}
}
async function runNumExport() {
try {
await exportNum(store, library.currentTitle.value);
toasts.success('Dokument als .num gespeichert.');
} catch (err) {
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
}
}
function runJsonExport() {
exportJson(store, library.currentTitle.value);
toasts.success('Dokument als JSON exportiert.');
}
async function runXlsxExport() {
busy.value = true;
try {
await exportXlsx(store, library.currentTitle.value);
toasts.success('Arbeitsmappe als XLSX exportiert.');
} catch (err) {
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
} finally {
busy.value = false;
}
}
function exportSelectedChartPng() {
const o = store.activeObject.value;
if (o?.kind !== 'chart') {
toasts.error('Kein Diagramm ausgewählt — Diagramm anklicken, dann exportieren.');
return;
}
// ChartView listens for this via a window event keyed by object id
window.dispatchEvent(new CustomEvent('export-chart-png', { detail: o.id }));
}
// ---- import -----------------------------------------------------
type ImportCtx =
| { kind: 'json'; text: string; fileName: string }
| ({ kind: 'csv'; text: string; fileName: string } & CsvImportConfig)
| { kind: 'xlsx'; buffer: ArrayBuffer; fileName: string };
const importCtx = ref<ImportCtx | null>(null);
const importOpen = ref(false);
function pickFile() {
fileMenuOpen.value = false;
fileInput.value?.click();
}
async function onFileChosen(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) await handleFile(file);
}
/** Also called from the canvas drag & drop. */
async function handleFile(file: File) {
const ext = extensionOf(file.name);
try {
if (ext === 'json') {
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
} else if (ext === 'num') {
// a zipped JSON document -- unpacked, then the same replace/append choice
importCtx.value = { kind: 'json', text: await readNumFile(await readAsArrayBuffer(file)), fileName: file.name };
} else if (ext === 'csv') {
importCtx.value = {
kind: 'csv',
text: await readAsText(file),
fileName: file.name,
mode: 'new-table',
delimiter: 'auto',
detectNumbers: true,
name: file.name.replace(/\.csv$/i, ''),
};
} else if (ext === 'xlsx') {
importCtx.value = {
kind: 'xlsx',
buffer: await readAsArrayBuffer(file),
fileName: file.name,
};
} else {
toasts.error(`Nicht unterstützter Dateityp: .${ext}`);
return;
}
importOpen.value = true;
} catch (err) {
toasts.error((err as Error).message);
}
}
defineExpose({ handleFile });
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
const ctx = importCtx.value;
if (!ctx) return;
busy.value = true;
try {
if (ctx.kind === 'json') {
if (action === 'new') {
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
} else if (action === 'replace') {
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
importJsonReplace(store, ctx.text);
} else {
importJsonAppend(store, ctx.text);
}
} else if (ctx.kind === 'csv') {
await importCsv(store, ctx.text, {
mode: ctx.mode,
delimiter: ctx.delimiter,
detectNumbers: ctx.detectNumbers,
name: ctx.name,
});
} else {
await importXlsxFile(store, ctx.buffer);
}
toasts.success('Import erfolgreich.');
importOpen.value = false;
importCtx.value = null;
} catch (err) {
toasts.error(`Import fehlgeschlagen: ${(err as Error).message}`);
} finally {
busy.value = false;
}
}
async function onNew() {
await library.createBlank();
toasts.info('Neues Dokument angelegt.');
}
async function onSample() {
await library.createSample();
toasts.info('Musterdokument als neues Dokument angelegt.');
}
</script>
<template>
<header ref="toolbarEl" class="toolbar">
<div class="brand" title="Numolus — wicom, Vaduz (FL)">
<TheLogo class="brand-mark" />
<span v-show="showWordmark" ref="brandExtra" class="brand-extra">
<span class="wordmark">numolus</span>
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
</span>
</div>
<input
v-model="titleDraft"
class="doc-title"
type="text"
title="Dokumentname (klicken zum Umbenennen)"
aria-label="Dokumentname"
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
:readonly="library.currentRole.value === 'viewer'"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
@blur="commitTitle()"
/>
<button
class="gear"
:class="{ on: store.ui.settingsOpen }"
title="Einstellungen"
aria-label="Einstellungen"
@click="store.ui.settingsOpen = !store.ui.settingsOpen"
>
<ToolbarIcon name="settings" />
</button>
<div class="tgroup">
<button
class="ibtn"
title="Rückgängig (Strg/Cmd+Z)"
aria-label="Rückgängig"
:disabled="!store.canUndo.value"
@click="store.undo()"
>
<ToolbarIcon name="undo" />
</button>
<button
class="ibtn"
title="Wiederholen (Strg/Cmd+Y)"
aria-label="Wiederholen"
:disabled="!store.canRedo.value"
@click="store.redo()"
>
<ToolbarIcon name="redo" />
</button>
</div>
<div class="tgroup accent">
<button class="ibtn" title="Tabelle einfügen" aria-label="Tabelle einfügen" @click="insertTable">
<ToolbarIcon name="table" />
</button>
<button class="ibtn" title="Diagramm einfügen" aria-label="Diagramm einfügen" @click="insertChart">
<ToolbarIcon name="chart" />
</button>
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
<ToolbarIcon name="text" />
</button>
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
<ToolbarIcon name="button" />
</button>
</div>
<div class="tgroup accent">
<button
class="ibtn fx"
title="Benutzerdefinierte Funktionen"
aria-label="Benutzerdefinierte Funktionen"
@click="functionsOpen = true"
>
<em>fx</em>
</button>
<button
class="ibtn"
title="Benannte Bereiche"
aria-label="Benannte Bereiche"
@click="namedRangesOpen = true"
>
<ToolbarIcon name="tag" />
</button>
<button
class="ibtn"
title="Makros"
aria-label="Makros"
@click="macrosOpen = true"
>
<ToolbarIcon name="macro" />
</button>
</div>
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
</span>
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
<div class="spacer" />
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
<div ref="fileMenuWrap" class="menu-wrap">
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
<li><button @click="documentsOpen = true">Dokumente …</button></li>
<li><button @click="onNew">Neues Dokument</button></li>
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
<li class="sep" />
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
<li><button @click="runNumExport">Speichern · Dokument (.num)</button></li>
<li class="sep" />
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" />
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul>
</div>
<button
class="ibtn"
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
:aria-label="`Theme: ${themeLabel}`"
@click="cycleTheme"
>
<ToolbarIcon :name="themeIcon" />
</button>
<div class="menu-wrap">
<button
v-if="session.state.user"
class="btn account"
:title="session.state.user.email"
@click="accountMenuOpen = !accountMenuOpen"
@blur="accountMenuOpen = false"
>
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
{{ session.state.user.name }} ▾
</button>
<button v-else class="btn" @click="openLogin()">Anmelden</button>
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
<li class="menu-note">{{ session.state.user?.email }}</li>
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
<li class="sep" />
<li><button @click="session.logout()">Abmelden</button></li>
</ul>
</div>
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
<span v-if="busy" class="busy">Verarbeite …</span>
</header>
<!-- CSV export options -->
<BaseModal :open="csvExportOpen" title="CSV exportieren" @close="csvExportOpen = false">
<label class="row">
Trennzeichen
<select v-model="csvExport.delimiter">
<option value=",">Komma ,</option>
<option value=";">Semikolon ;</option>
</select>
</label>
<label class="row"><input v-model="csvExport.crlf" type="checkbox" /> Zeilenende CRLF (\r\n)</label>
<label class="row"><input v-model="csvExport.bom" type="checkbox" /> UTF-8 BOM (für Excel)</label>
<template #footer>
<button @click="csvExportOpen = false">Abbrechen</button>
<button class="primary" @click="runCsvExport">Exportieren</button>
</template>
</BaseModal>
<!-- Import dialog -->
<BaseModal
:open="importOpen"
:title="`Import · ${importCtx?.fileName ?? ''}`"
@close="importOpen = false"
>
<template v-if="importCtx?.kind === 'json'">
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
</template>
<template v-else-if="importCtx?.kind === 'csv'">
<label class="row">
Ziel
<select v-model="importCtx.mode">
<option value="new-table">Als neue Tabelle (aktuelles Blatt)</option>
<option value="replace-active">Aktive Tabelle ab A1 ersetzen</option>
</select>
</label>
<label v-if="importCtx.mode === 'new-table'" class="row">
Name <input v-model="importCtx.name" type="text" />
</label>
<label class="row">
Trennzeichen
<select v-model="importCtx.delimiter">
<option value="auto">Automatisch erkennen</option>
<option value=",">Komma ,</option>
<option value=";">Semikolon ;</option>
<option :value="'\t'">Tab</option>
</select>
</label>
<label class="row">
<input v-model="importCtx.detectNumbers" type="checkbox" />
Zahlen-/Locale-Erkennung (1.234,56 ↔ 1,234.56)
</label>
<p class="hint">Werte mit führendem „=" werden als Formel übernommen.</p>
</template>
<template v-else-if="importCtx?.kind === 'xlsx'">
<p>Arbeitsmappe wird als neue Blätter angehängt – je Worksheet ein Blatt mit einer Tabelle.</p>
</template>
<template #footer>
<button @click="importOpen = false">Abbrechen</button>
<template v-if="importCtx?.kind === 'json'">
<button @click="doImport('append')">Anhängen</button>
<button @click="doImport('replace')">Ersetzen</button>
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
</template>
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
</template>
</BaseModal>
<!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
<ShareModal
:open="shareOpen"
@close="shareOpen = false"
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
/>
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
<p class="about-lead">
<strong>Numolus</strong> — browserbasierte Tabellenkalkulation nach dem Vorbild von
Apple&nbsp;Numbers, mit selbst geschriebener Formel-Engine.
</p>
<dl class="about">
<dt>Herausgeber</dt>
<dd>
<a :href="WICOM.url" target="_blank" rel="noopener noreferrer">{{ WICOM.name }}</a>
— {{ WICOM.descriptor }}<br />
<span class="dim">„{{ WICOM.tagline }}" · seit {{ WICOM.since }}</span>
</dd>
<dt>Anschrift</dt>
<dd>{{ addr }}</dd>
<dt>Kontakt</dt>
<dd>
<a :href="`mailto:${WICOM.email}`">{{ WICOM.email }}</a><br />
<a :href="WICOM.phoneHref">{{ WICOM.phone }}</a>
</dd>
<dt>Autor</dt>
<dd>{{ WICOM.author }}</dd>
<dt>Social Media</dt>
<dd>
<template v-if="WICOM_SOCIAL.length">
<a
v-for="s in WICOM_SOCIAL"
:key="s.url"
class="social"
:href="s.url"
target="_blank"
rel="noopener noreferrer"
>{{ s.label }}</a>
</template>
<span v-else class="dim">wicom.li führt keine Social-Media-Profile.</span>
</dd>
</dl>
<template #footer>
<a class="btn-link" :href="WICOM.url" target="_blank" rel="noopener noreferrer">wicom.li ↗</a>
<button class="primary" @click="aboutOpen = false">Schließen</button>
</template>
</BaseModal>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 9px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
@media (max-width: 640px) {
.spacer {
display: none;
}
.toolbar {
gap: 6px;
padding: 8px 10px;
}
}
.brand {
display: flex;
align-items: center;
gap: 9px;
margin-right: 2px;
user-select: none;
}
.brand-mark {
height: 24px;
flex: none;
}
.brand-extra {
display: flex;
align-items: center;
gap: 9px;
}
.wordmark {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text);
}
.by {
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.by span {
font-weight: 600;
}
.by:hover {
color: var(--accent);
}
/* --- Über-Dialog --- */
.about-lead {
margin: 0 0 14px;
line-height: 1.5;
}
.about {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 14px;
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.about dt {
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 2px;
}
.about dd {
margin: 0;
}
.about a {
color: var(--accent);
}
.about .dim {
color: var(--text-dim);
}
.about .social {
display: inline-block;
margin-right: 10px;
}
.btn-link {
margin-right: auto;
align-self: center;
font-size: 12.5px;
color: var(--text-dim);
text-decoration: none;
}
.btn-link:hover {
color: var(--accent);
}
.ro-badge,
.status-note {
font-size: 12px;
white-space: nowrap;
}
.ro-badge {
padding: 3px 9px;
border-radius: 999px;
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
cursor: help;
}
.status-note {
color: var(--text-dim);
}
.account {
display: inline-flex;
align-items: center;
gap: 6px;
}
.avatar {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: #fff;
font-size: 11px;
font-weight: 700;
}
.menu-note {
padding: 6px 12px;
font-size: 12px;
color: var(--text-dim);
}
.doc-title {
min-width: 0;
max-width: 240px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
padding: 4px 7px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
text-overflow: ellipsis;
}
.doc-title:hover {
border-color: var(--border);
}
.doc-title:focus {
outline: none;
border-color: var(--accent);
background: var(--surface-2);
}
.gear {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
border-radius: 9px;
width: 32px;
height: 32px;
cursor: pointer;
color: var(--text-dim);
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.gear:hover {
background: var(--surface-2);
color: var(--text);
}
.gear.on {
background: var(--accent-weak);
border-color: var(--accent);
color: var(--accent);
}
/* --- grouped icon-button clusters (segmented-control look) --- */
.tgroup {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
}
.ibtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.tgroup.accent .ibtn {
color: var(--accent);
}
.ibtn:hover {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.tgroup.accent .ibtn:hover {
color: var(--accent);
}
.ibtn:disabled {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
.ibtn.fx em {
font-style: italic;
font-weight: 700;
font-size: 14px;
font-family: Georgia, 'Times New Roman', serif;
}
.spacer {
flex: 1;
}
.btn {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
padding: 6px 12px;
font-size: 12.5px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 50;
min-width: 300px;
margin: 0;
padding: 6px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.menu li.sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.menu button {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.menu button:hover {
background: var(--accent);
color: #fff;
}
.busy {
font-size: 12px;
color: var(--accent);
}
.row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
justify-content: space-between;
}
.row select,
.row input[type='text'] {
flex: 1;
max-width: 220px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.hint {
color: var(--text-dim);
font-size: 12px;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { watch } from 'vue';
const props = defineProps<{ open: boolean; title: string; wide?: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(
() => props.open,
(o) => {
if (o) document.addEventListener('keydown', onKey);
else document.removeEventListener('keydown', onKey);
},
);
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click.self="emit('close')">
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
<header>
<h2>{{ title }}</h2>
<button class="x" @click="emit('close')">×</button>
</header>
<div class="body"><slot /></div>
<footer><slot name="footer" /></footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
}
.modal {
width: min(420px, calc(100vw - 32px));
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
overflow: hidden;
/* never taller than the viewport: header/footer stay put, the body scrolls */
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
}
.modal.wide {
width: min(920px, calc(100vw - 32px));
}
header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
h2 {
margin: 0;
font-size: 14px;
}
.x {
border: 0;
background: transparent;
font-size: 18px;
cursor: pointer;
color: var(--text-dim);
}
.body {
padding: 16px;
font-size: 13px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
/* wide dialogs lay out one full-height child (e.g. an editor + side panel)
that shrinks with the viewport and scrolls its own parts */
.modal.wide .body {
display: flex;
flex-direction: column;
}
footer {
flex: none;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
:slotted(button) {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
padding: 6px 14px;
font: inherit;
cursor: pointer;
}
:slotted(button.primary) {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
/**
* A clickable canvas button object -- one of the two ways to trigger a
* macro (the other is the "▶ Ausführen" row in MacrosModal.vue). Caption
* (`label`) and colours are edited in the inspector (SettingsPanel.vue);
* which macro it runs is assignable both there and from this component's
* own right-click menu, for a quicker "build a button, wire it up, done"
* loop without leaving the canvas.
*/
import { computed, reactive, ref } from 'vue';
import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts';
import ContextMenu from './ContextMenu.vue';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const toasts = useToasts();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'button' ? o : undefined;
});
// A macro can now involve a real network round-trip (GET/POST) and even a
// blocking confirm() prompt -- guard against a second click starting a
// second overlapping run while one is still in flight.
const running = ref(false);
async function run() {
const o = obj.value;
if (!o || running.value) return;
if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) {
toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.');
return;
}
running.value = true;
try {
const result = await store.runMacro(o.macroId);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
} finally {
running.value = false;
}
}
const boxStyle = computed(() => {
const o = obj.value;
if (!o) return {};
return {
color: o.fg || 'var(--accent)',
background: o.bg || 'var(--accent-weak)',
} as Record<string, string>;
});
// ---- right-click: run / (re)assign a macro without opening the inspector ----
const menu = reactive({ open: false, x: 0, y: 0 });
function onContextMenu(e: MouseEvent) {
e.preventDefault();
menu.x = e.clientX;
menu.y = e.clientY;
menu.open = true;
}
const menuItems = computed(() => {
const o = obj.value;
if (!o) return [];
const items: { label: string; action: () => void }[] = [];
if (o.macroId) {
items.push({ label: '▶ Makro ausführen', action: run });
}
for (const m of store.doc.macros) {
items.push({
label: `${m.id === o.macroId ? '✓ ' : ''}Makro: ${m.name}`,
action: () => store.setButtonMacroId(o.id, m.id),
});
}
if (store.doc.macros.length === 0) {
items.push({ label: 'Kein Makro vorhanden — erst in „▶ Makros" anlegen', action: () => {} });
}
if (o.macroId) {
items.push({ label: 'Zuweisung entfernen', action: () => store.setButtonMacroId(o.id, null) });
}
return items;
});
</script>
<template>
<div
v-if="obj"
class="btnbox"
:class="{ running }"
:style="boxStyle"
@click="run"
@contextmenu="onContextMenu"
>
<span class="label">{{ running ? '…' : obj.label || 'Button' }}</span>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
</div>
</template>
<style scoped>
.btnbox {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
font-weight: 600;
font-size: 14px;
text-align: center;
cursor: pointer;
user-select: none;
}
.btnbox.running {
opacity: 0.6;
cursor: default;
}
.btnbox:hover {
filter: brightness(1.06);
}
.btnbox:active {
filter: brightness(0.96);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+401
View File
@@ -0,0 +1,401 @@
<script setup lang="ts">
/**
* Draggable / resizable wrapper for a table, chart, text or button object on
* the sheet canvas.
*
* - the header strip and the frame border move the object;
* - the SE handle resizes a chart/text freely; for a table, what it does
* depends on the view mode (see `resizeKind` below):
* - "Automatisch" (`mode: 'auto'`, size always == content): dragging
* drives the row/col *count* directly -- growing adds empty rows/
* cols, shrinking removes them, never past real content;
* - "Feste Größe" (`mode: 'fixed'`, an explicit scrollable viewport):
* dragging only resizes that pixel viewport, independent of row/col
* count -- the original behaviour, since a fixed-size table's whole
* point is a box smaller than its (possibly much larger) content,
* scrolling internally.
* - name is editable via double-click, delete via the × button.
*
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
* zoom level. Moving and free/viewport resizing both snap to the object's own
* sheet's grid spacing (`Sheet.gridSize`, "Raster" in the Settings panel) --
* an "Automatisch" table's resize instead already snaps to row/col
* boundaries by construction, so it isn't additionally pixel-snapped.
*/
import { computed, nextTick, ref } from 'vue';
import type { SheetObject, TableObject } from '../store/types';
import { DEFAULT_ROW_H, DEFAULT_GRID_SIZE } from '../store/types';
import { useDocument } from '../store/document';
import TableGrid from './TableGrid.vue';
import ChartView from './ChartView.vue';
import TextBox from './TextBox.vue';
import ButtonView from './ButtonView.vue';
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
const emit = defineEmits<{ (e: 'select'): void }>();
const store = useDocument();
const renaming = ref(false);
const nameDraft = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
function startRename() {
renaming.value = true;
nameDraft.value = props.object.name;
nextTick(() => nameInput.value?.select());
}
function commitRename() {
store.renameObject(props.object.id, nameDraft.value);
renaming.value = false;
}
// ---- move/resize snap-to-grid --------------------------------------
/** This object's own sheet's grid spacing -- also the snap increment while
* dragging to move or resize it (SettingsPanel's "Raster" field). */
const gridSize = computed(() => {
const sheetId = store.sheetIdOfObject(props.object.id);
const sheet = sheetId ? store.sheetById(sheetId) : undefined;
return sheet?.gridSize ?? DEFAULT_GRID_SIZE;
});
const snap = (v: number) => Math.round(v / gridSize.value) * gridSize.value;
// ---- drag to move -------------------------------------------------
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
function onMoveDown(e: MouseEvent) {
if (e.button !== 0) return;
emit('select');
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMoveMove);
window.addEventListener('mouseup', onMoveUp);
}
function onMoveMove(e: MouseEvent) {
if (!drag) return;
const dx = (e.clientX - drag.px) / props.zoom;
const dy = (e.clientY - drag.py) / props.zoom;
store.moveObject(props.object.id, snap(drag.ox + dx), snap(drag.oy + dy));
}
function onMoveUp() {
drag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMoveMove);
window.removeEventListener('mouseup', onMoveUp);
}
// ---- SE resize --------------------------------------------------
let rs: { px: number; py: number; w: number; h: number } | null = null;
/** Ghost outline shown *only* while dragging an "Automatisch" table's
* handle (see onResizeMove) -- the real row/col-count change is expensive
* (a full syncWorkbook() per call) so it commits once, on release; this is
* purely a lightweight visual preview in the meantime, touching neither
* the store nor the table's actual DOM. */
const previewSize = ref<{ w: number; h: number } | null>(null);
// An "Automatisch" table's resize handle drags the row/col *count* directly
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
// duplicated there too).
const HEAD_W = 44;
const DEFAULT_W = 104;
/** How many columns fit in `targetPx`, at least 1. */
function colsForWidth(t: TableObject, targetPx: number): number {
let w = HEAD_W;
let c = 0;
while (true) {
const next = w + (t.colWidths[c] ?? DEFAULT_W);
if (next > targetPx && c > 0) break;
w = next;
c++;
}
return Math.max(1, c);
}
/** How many rows fit in `targetPx`, at least 1. */
function rowsForHeight(t: TableObject, targetPx: number): number {
let h = t.rowHeight ?? DEFAULT_ROW_H;
let r = 0;
while (true) {
const next = h + store.rowHeightOf(t.id, r);
if (next > targetPx && r > 0) break;
h = next;
r++;
}
return Math.max(1, r);
}
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
* "Automatisch" table has no stored viewport size of its own (it always
* renders at its natural content size), so this is the reference point a
* resize drag starts from; no explicit "resync" is needed afterwards
* either, since the DOM already re-renders at the new natural size the
* moment `setTableRows`/`setTableCols` changes `rows`/`cols`. */
function fittingSize(t: TableObject): { width: number; height: number } {
let width = HEAD_W;
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
let height = t.rowHeight ?? DEFAULT_ROW_H;
for (let r = 0; r < t.rows; r++) height += store.rowHeightOf(t.id, r);
return { width, height };
}
/** The last row/col index holding a real (non-empty) cell, or -1 -- a
* row/col-count shrink must never go below this, or it would silently
* delete data. `t.cells` never stores an empty-string entry (an emptied
* cell removes its key, see setCellInput), so every present key is real. */
function lastNonEmptyRow(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[1]));
return max;
}
function lastNonEmptyCol(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[0]));
return max;
}
/** How the SE handle behaves for this object -- see the module doc for the
* "Automatisch" vs. "Feste Größe" split. */
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
const o = props.object;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
});
function onResizeDown(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
emit('select');
const o = props.object;
let w: number;
let h: number;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
w = o.width;
h = o.height;
} else if (o.view.mode === 'fixed') {
w = o.view.width;
h = o.view.height;
} else {
const size = fittingSize(o);
w = size.width;
h = size.height;
}
rs = { px: e.clientX, py: e.clientY, w, h };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onResizeMove);
window.addEventListener('mouseup', onResizeUp);
}
function onResizeMove(e: MouseEvent) {
if (!rs) return;
const dx = (e.clientX - rs.px) / props.zoom;
const dy = (e.clientY - rs.py) / props.zoom;
const o = props.object;
if (resizeKind.value === 'free') {
if (o.kind === 'chart') store.resizeChart(o.id, snap(rs.w + dx), snap(rs.h + dy));
else if (o.kind === 'text') store.resizeText(o.id, snap(rs.w + dx), snap(rs.h + dy));
else if (o.kind === 'button') store.resizeButton(o.id, snap(rs.w + dx), snap(rs.h + dy));
} else if (resizeKind.value === 'viewport') {
// "Feste Größe": pure pixel-viewport resize, independent of row/col
// count -- cheap (setTableView never rebuilds the workbook), so a live
// update on every mousemove is fine, same as it always was.
store.setTableView(o.id, { width: snap(rs.w + dx), height: snap(rs.h + dy) });
} else {
// "Automatisch": dragging drives row/col *count* -- but that's a full
// syncWorkbook() per call, too expensive for every mousemove, so only a
// lightweight ghost-box preview updates live here (see the template);
// the real change commits once, on release, in onResizeUp.
previewSize.value = { w: Math.max(80, rs.w + dx), h: Math.max(52, rs.h + dy) };
}
}
function onResizeUp() {
const o = props.object;
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
// Never past non-empty content, so a hard shrink can't silently delete
// data -- it just settles at the smallest size that still shows
// everything, however far past that the drag actually went.
const targetCols = colsForWidth(o, previewSize.value.w);
const targetRows = rowsForHeight(o, previewSize.value.h);
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
}
previewSize.value = null;
rs = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove);
window.removeEventListener('mouseup', onResizeUp);
}
/** A table with `hideChromeUnfocused` set shows its title bar (and, via the
* `selected` prop passed to TableGrid below, its row/column headers) only
* while it's the selected object -- a "clean look" presentation option
* ("Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt" in Settings). Every
* other object kind is unaffected -- always shown, today's behaviour. */
const showChrome = computed(() => {
const o = props.object;
return props.selected || !(o.kind === 'table' && o.hideChromeUnfocused);
});
const icon = computed(() => {
switch (props.object.kind) {
case 'table':
return '▦';
case 'chart':
return '▤';
case 'button':
return '▶';
case 'text':
default:
return '¶';
}
});
const style = computed(() => {
const o = props.object;
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
base.width = o.width + 'px';
base.height = o.height + 'px';
}
return base;
});
</script>
<template>
<div
class="canvas-object"
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
:style="style"
@mousedown="emit('select')"
>
<div v-if="showChrome" class="chrome" @mousedown.stop="onMoveDown">
<input
v-if="renaming"
ref="nameInput"
v-model="nameDraft"
class="name-edit"
@mousedown.stop
@blur="commitRename"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="renaming = false"
/>
<span v-else class="name" @dblclick.stop="startRename">
{{ icon }} {{ object.name }}
</span>
<button
class="del"
title="Objekt löschen"
@mousedown.stop
@click.stop="store.deleteObject(object.id)"
>
×
</button>
</div>
<div class="content" :class="{ 'full-radius': !showChrome }">
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" :selected="selected" />
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
</div>
<div
v-if="previewSize"
class="resize-ghost"
:style="{ width: previewSize.w + 'px', height: previewSize.h + 'px' }"
/>
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
</div>
</template>
<style scoped>
.canvas-object {
position: absolute;
background: var(--surface);
border-radius: 10px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.canvas-object.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
}
.canvas-object.flex {
display: flex;
flex-direction: column;
}
.chrome {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 4px 0 10px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
border-radius: 10px 10px 0 0;
cursor: grab;
font-size: 11.5px;
color: var(--text-dim);
user-select: none;
}
.chrome:active {
cursor: grabbing;
}
.name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name-edit {
flex: 1;
font: inherit;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 1px 4px;
}
.del {
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 15px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
padding: 0 4px;
}
.del:hover {
background: var(--danger);
color: #fff;
}
.content {
overflow: hidden;
border-radius: 0 0 10px 10px;
}
/* the chrome bar is what normally rounds the top corners -- without it
(hideChromeUnfocused, currently unfocused), .content fills the whole box */
.content.full-radius {
border-radius: 10px;
}
.canvas-object.flex .content {
flex: 1;
min-height: 0;
}
.resize-ghost {
position: absolute;
top: 27px;
left: 0;
border: 2px dashed var(--accent);
border-radius: 0 0 8px 8px;
background: var(--accent-weak);
pointer-events: none;
z-index: 7;
}
.rs-handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--accent);
border: 2px solid var(--surface);
cursor: nwse-resize;
z-index: 8;
}
</style>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
/**
* One chart object. Chart.js (bar / line) over data derived live from its
* source table: categories = header row (from column B), one series per region
* (column A). Any grid edit that touches those cells re-renders immediately.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import Chart from 'chart.js/auto';
import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { downloadDataUrl } from '../io/download';
import { isNumeric, formatValue } from '../store/format';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const { isDarkNow, mode: themeMode } = useTheme();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'chart' ? o : undefined;
});
const canvas = ref<HTMLCanvasElement | null>(null);
let chart: Chart | null = null;
function cssVar(n: string) {
return getComputedStyle(document.documentElement).getPropertyValue(n).trim();
}
const palette = () => [
cssVar('--series-1'), cssVar('--series-2'), cssVar('--series-3'),
cssVar('--series-4'), cssVar('--series-5'),
];
const model = computed(() => {
void store.recalcTick.value;
const src = obj.value?.sourceTableId ? store.tableById(obj.value.sourceTableId) : undefined;
if (!src) return { quarters: [] as string[], regions: [] as string[], rows: [] as number[][] };
const quarters: string[] = [];
for (let c = 1; c < src.cols; c++) {
const v = store.cellValue(src.id, c, 0);
if (v === null || v === '') break;
quarters.push(String(v));
}
const regions: string[] = [];
const rows: number[][] = [];
for (let r = 1; r < src.rows; r++) {
const name = store.cellValue(src.id, 0, r);
if (name === null || name === '') break;
regions.push(String(name));
rows.push(
quarters.map((_, i) => {
const cv = store.cellValue(src.id, i + 1, r);
return isNumeric(cv) ? (cv as number) : 0;
}),
);
}
return { quarters, regions, rows };
});
const chartLocale = computed(() => store.effectiveLocale(props.objId));
function valueLabelPlugin(color: string): import('chart.js').Plugin {
return {
id: 'valueLabels',
afterDatasetsDraw(c) {
const { ctx } = c;
ctx.save();
ctx.font = '10px -apple-system, sans-serif';
ctx.fillStyle = color;
ctx.textAlign = 'center';
c.data.datasets.forEach((ds, di) => {
const meta = c.getDatasetMeta(di);
if (meta.hidden) return;
meta.data.forEach((pt, i) => {
const raw = ds.data[i];
if (typeof raw !== 'number') return;
ctx.fillText(formatValue(raw, chartLocale.value), pt.x, pt.y - 6);
});
});
ctx.restore();
},
};
}
function render() {
if (!canvas.value || !obj.value) return;
chart?.destroy();
const { quarters, regions, rows } = model.value;
const colors = palette();
const tick = obj.value.fg || (isDarkNow() ? '#a1a1a6' : '#6e6e73');
const grid = isDarkNow() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const view = obj.value.view;
chart = new Chart(canvas.value, {
type: view,
data: {
labels: quarters,
datasets: regions.map((region, i) => ({
label: region,
data: rows[i],
backgroundColor: view === 'bar' ? colors[i % colors.length] : colors[i % colors.length] + '33',
borderColor: colors[i % colors.length],
borderWidth: 2,
pointRadius: view === 'line' ? 3 : 0,
tension: 0.3,
fill: false,
})),
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
plugins: {
legend: { position: 'bottom', labels: { color: tick, boxWidth: 12 } },
tooltip: { enabled: true },
},
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid }, beginAtZero: true },
},
},
plugins: [valueLabelPlugin(tick)],
});
}
onMounted(render);
onBeforeUnmount(() => chart?.destroy());
watch(
[
model,
() => obj.value?.view,
() => obj.value?.width,
() => obj.value?.height,
() => obj.value?.fg,
themeMode,
chartLocale,
],
() => {
// resize needs a re-render because maintainAspectRatio is false
render();
},
{ deep: true },
);
const panelStyle = computed(() => (obj.value?.bg ? { background: obj.value.bg } : {}));
function exportPng() {
if (chart) downloadDataUrl(`${obj.value?.name ?? 'diagramm'}.png`, chart.toBase64Image());
}
defineExpose({ exportPng });
// allow the toolbar's "Export gewähltes Diagramm" to reach the active chart
function onExportEvent(e: Event) {
if ((e as CustomEvent).detail === props.objId) exportPng();
}
onMounted(() => window.addEventListener('export-chart-png', onExportEvent));
onBeforeUnmount(() => window.removeEventListener('export-chart-png', onExportEvent));
const tableOptions = computed(() =>
store.doc.sheets.flatMap((s) => s.objects)
.filter((o) => o.kind === 'table')
.map((o) => ({ id: o.id, name: o.name })),
);
</script>
<template>
<div v-if="obj" class="chart-view" :style="panelStyle">
<div class="bar">
<div class="seg">
<button :class="{ on: obj.view === 'bar' }" @click.stop="store.setChartView(objId, 'bar')">Balken</button>
<button :class="{ on: obj.view === 'line' }" @click.stop="store.setChartView(objId, 'line')">Linie</button>
</div>
<select
class="src"
:value="obj.sourceTableId ?? ''"
@change="store.setChartSource(objId, ($event.target as HTMLSelectElement).value)"
@mousedown.stop
>
<option v-for="o in tableOptions" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
<span class="sp" />
<button class="ghost" @click.stop="exportPng">PNG</button>
</div>
<div class="body">
<canvas ref="canvas" />
<p v-if="model.regions.length === 0" class="empty">Keine Regions-/Quartalsdaten in der Quelltabelle.</p>
</div>
</div>
</template>
<style scoped>
.chart-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--surface);
}
.bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
.sp {
flex: 1;
}
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.seg button {
border: 0;
background: var(--surface);
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
}
.seg button.on {
background: var(--accent);
color: #fff;
}
.src {
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
background: var(--surface);
color: var(--text);
font-size: 11px;
max-width: 130px;
}
.ghost {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 11px;
cursor: pointer;
}
.ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.body {
position: relative;
flex: 1;
min-height: 0;
padding: 10px;
}
.empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-dim);
font-size: 12px;
text-align: center;
padding: 12px;
}
</style>
+328
View File
@@ -0,0 +1,328 @@
<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>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
interface Item {
label: string;
action: () => void;
}
const props = defineProps<{ open: boolean; x: number; y: number; items: Item[] }>();
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>();
function close() {
emit('update:open', false);
}
function onDocClick() {
close();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
watch(
() => props.open,
(open) => {
if (open) {
// defer so the opening contextmenu event doesn't immediately close it
setTimeout(() => {
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onKey);
});
} else {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onKey);
}
},
);
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onKey);
});
function run(item: Item) {
item.action();
close();
}
</script>
<template>
<Teleport to="body">
<ul
v-if="open"
class="ctx"
:style="{ left: x + 'px', top: y + 'px' }"
@click.stop
>
<li v-for="item in items" :key="item.label">
<button type="button" @click="run(item)">{{ item.label }}</button>
</li>
</ul>
</Teleport>
</template>
<style scoped>
.ctx {
position: fixed;
z-index: 1000;
min-width: 200px;
margin: 0;
padding: 6px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.ctx button {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.ctx button:hover {
background: var(--accent);
color: #fff;
}
</style>
@@ -0,0 +1,410 @@
<script setup lang="ts">
/**
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
* section for the exact mockups this follows.
*/
import { nextTick, reactive, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import BaseModal from './BaseModal.vue';
import CodeReference from './CodeReference.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
type Kind = 'expression' | 'block';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
const formError = ref<string | null>(null);
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
// -progress never lingers into the next open.
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
form.name = '';
form.params = [''];
form.body = '';
form.kind = 'expression';
formError.value = null;
mode.value = 'edit';
}
function openEdit(id: string) {
const f = store.doc.customFunctions.find((x) => x.id === id);
if (!f) return;
editingId.value = id;
form.name = f.name;
form.params = f.params.length ? [...f.params] : [''];
form.body = f.body;
form.kind = f.kind;
formError.value = null;
mode.value = 'edit';
}
function addParamField() {
form.params.push('');
}
function removeParamField(i: number) {
form.params.splice(i, 1);
}
function save() {
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
const result = editingId.value
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
: store.addCustomFunction(form.name, params, form.body, form.kind);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeFunction(id: string, name: string) {
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
store.deleteCustomFunction(id);
}
}
const bodyInput = ref<HTMLTextAreaElement | null>(null);
/** Put a reference snippet at the caret (replacing any selection). */
async function insertSnippet(text: string) {
const el = bodyInput.value;
const start = el?.selectionStart ?? form.body.length;
const end = el?.selectionEnd ?? form.body.length;
form.body = form.body.slice(0, start) + text + form.body.slice(end);
await nextTick();
if (!el) return;
el.focus();
el.setSelectionRange(start + text.length, start + text.length);
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:wide="mode === 'edit'"
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.customFunctions.length" class="hint">
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
</p>
<ul v-else class="fn-list">
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
<code class="fn-sig">
<span v-if="f.kind === 'block'" class="fn-badge" title="Mit Schleife (Code)">↻</span>
{{ f.name }}({{ f.params.join('; ') }})
</code>
<span class="fn-actions">
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<div v-else class="edit-layout">
<div class="editor">
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
</label>
<div class="row">
<span class="params-label">Art</span>
<div class="kind-toggle">
<button
type="button"
:class="{ on: form.kind === 'expression' }"
@click="form.kind = 'expression'"
>
Einfache Formel
</button>
<button type="button" :class="{ on: form.kind === 'block' }" @click="form.kind = 'block'">
Mit Schleife
</button>
</div>
</div>
<div class="row params-row">
<span class="params-label">Parameter</span>
<div class="params-list">
<div v-for="(_, i) in form.params" :key="i" class="param-field">
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
<button
class="mini remove"
title="Parameter entfernen"
:disabled="form.params.length <= 1"
@click="removeParamField(i)"
>
×
</button>
</div>
<button class="mini" @click="addParamField">+ Parameter</button>
</div>
</div>
<label class="row body-row">
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
<textarea
ref="bodyInput"
v-model="form.body"
class="formula-input"
spellcheck="false"
:rows="form.kind === 'block' ? 12 : 5"
:placeholder="
form.kind === 'block'
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
: 'z. B. (revenue - cost) / revenue'
"
/>
</label>
<p class="hint">
Befehle, Funktionen und die eigenen Parameter rechts suchen, aufklappen für
Beschreibung und Beispiel, mit <strong>Einfügen</strong> an der Cursorposition
übernehmen.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</div>
<CodeReference
class="reference"
:context="form.kind"
:params="form.params"
@insert="insertSnippet"
/>
</div>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neue Funktion</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.fn-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.fn-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.fn-badge {
display: inline-block;
margin-right: 3px;
color: var(--accent);
}
.kind-toggle {
display: flex;
gap: 6px;
}
.kind-toggle button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 5px 10px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
}
.kind-toggle button:hover {
border-color: var(--accent);
color: var(--accent);
}
.kind-toggle button.on {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.fn-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini:disabled {
opacity: 0.4;
cursor: default;
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'] {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.params-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.param-field {
display: flex;
align-items: center;
gap: 2px;
}
.param-field input {
width: 92px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.remove {
padding: 2px 7px;
}
.body-row .formula-input {
min-height: 30px;
max-height: 40vh;
}
.formula-input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
.edit-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
/* side by side: fill the (viewport-capped) modal body and let the
reference list scroll inside it instead of the whole body */
grid-template-rows: minmax(0, 1fr);
gap: 16px;
flex: 1 1 auto;
min-height: 0;
}
@media (max-width: 720px) {
/* stacked: let the modal body scroll as a whole */
.edit-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
flex: none;
}
}
.editor {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow-y: auto;
}
.editor .body-row {
flex: 1 1 auto;
min-height: 0;
}
.editor .body-row .formula-input {
flex: 1 1 auto;
min-height: 70px;
max-height: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
@@ -0,0 +1,305 @@
<script setup lang="ts">
/**
* Interactive dependency-graph explorer, docked to the right (like
* SettingsPanel.vue docks left) -- NOT a full-screen modal: the canvas and
* sheet tabs stay visible and interactive the whole time, so navigating the
* graph never loses track of which sheet/table you're actually looking at.
*
* A single document-wide instance (controlled via `store.ui.depGraph`, the
* same pattern as `settingsOpen`), not owned by any one `TableGrid` -- opened
* from a cell's context menu ("🕸 Abhängigkeiten anzeigen"). Clicking any
* precedent/dependent node *really* navigates there (store.openDependencyGraph
* also calls selectCell, which switches the active sheet/object as a side
* effect) -- the drawer re-centers on it exactly like a fresh open would.
*/
import { computed, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import type { CellGraphNode } from '../store/types';
const store = useDocument();
interface Loc {
tableId: string;
col: number;
row: number;
}
const loc = computed(() => store.ui.depGraph);
const open = computed(() => loc.value !== null);
const history = ref<Loc[]>([]);
// a closed -> open transition always starts a fresh navigation history
watch(open, (isOpen, wasOpen) => {
if (isOpen && !wasOpen) history.value = [];
});
function goTo(node: CellGraphNode) {
if (loc.value) history.value.push(loc.value);
store.openDependencyGraph(node.tableId, node.col, node.row);
}
function goBack() {
const prev = history.value.pop();
if (prev) store.openDependencyGraph(prev.tableId, prev.col, prev.row);
}
function close() {
store.closeDependencyGraph();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
watch(open, (isOpen) => {
if (isOpen) document.addEventListener('keydown', onKey);
else document.removeEventListener('keydown', onKey);
});
const self = computed(() => (loc.value ? store.cellGraphSelf(loc.value.tableId, loc.value.col, loc.value.row) : null));
const neighbors = computed(() =>
loc.value
? store.cellGraphNeighbors(loc.value.tableId, loc.value.col, loc.value.row)
: { precedents: [], dependents: [] },
);
/** Always "Blatt · Tabelle!Zelle" -- the sheet name is never omitted here
* (unlike an in-formula reference, which only qualifies when needed): the
* whole point of this panel is never losing track of *where* something is. */
function label(node: CellGraphNode): string {
return `${node.sheetName ? node.sheetName + ' · ' : ''}${node.tableName}!${node.label}`;
}
function display(node: CellGraphNode): string {
return store.cellDisplay(node.tableId, node.col, node.row);
}
</script>
<template>
<aside class="dg-drawer" :class="{ open }" aria-label="Abhängigkeiten">
<header>
<button class="back" :disabled="!history.length" title="Zurück" @click="goBack">←</button>
<h2>🕸 Abhängigkeiten</h2>
<button class="x" title="Schließen" @click="close">×</button>
</header>
<div v-if="self" class="body">
<section class="self-card" :class="{ circular: self.isCircular }">
<p v-if="self.isCircular" class="circ-warn">⚠ Teil eines Zirkelbezugs</p>
<p class="cell-label">{{ label(self) }}</p>
<p class="cell-value">{{ display(self) }}</p>
<p v-if="self.raw" class="cell-raw">{{ self.raw }}</p>
</section>
<section>
<h3>Vorgänger — liest von ({{ neighbors.precedents.length }})</h3>
<p v-if="!neighbors.precedents.length" class="empty">keine</p>
<ul v-else class="node-list">
<li v-for="(n, i) in neighbors.precedents" :key="'p' + i">
<button
type="button"
class="node-btn"
:class="{ circular: n.isCircular }"
:title="n.raw || display(n)"
@click="goTo(n)"
>
<span v-if="n.isCircular" class="warn">⚠</span>
<span class="node-label">{{ label(n) }}</span>
<span class="node-value">{{ display(n) }}</span>
</button>
</li>
</ul>
</section>
<section>
<h3>Nachfolger — liest davon ({{ neighbors.dependents.length }})</h3>
<p v-if="!neighbors.dependents.length" class="empty">keine</p>
<ul v-else class="node-list">
<li v-for="(n, i) in neighbors.dependents" :key="'d' + i">
<button
type="button"
class="node-btn"
:class="{ circular: n.isCircular }"
:title="n.raw || display(n)"
@click="goTo(n)"
>
<span v-if="n.isCircular" class="warn">⚠</span>
<span class="node-label">{{ label(n) }}</span>
<span class="node-value">{{ display(n) }}</span>
</button>
</li>
</ul>
</section>
</div>
<p v-else class="body-empty">Zelle nicht gefunden.</p>
</aside>
</template>
<style scoped>
.dg-drawer {
position: absolute;
z-index: 60;
top: 0;
right: 0;
bottom: 0;
width: 380px;
max-width: 88vw;
display: flex;
flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: var(--shadow);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.dg-drawer.open {
transform: none;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex: none;
}
h2 {
flex: 1;
margin: 0;
font-size: 14px;
}
.back,
.x {
border: 0;
background: transparent;
cursor: pointer;
color: var(--text-dim);
flex: none;
}
.back {
font-size: 15px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
}
.back:disabled {
opacity: 0.35;
cursor: default;
}
.back:not(:disabled):hover,
.x:hover {
color: var(--accent);
border-color: var(--accent);
}
.x {
font-size: 18px;
}
.body {
overflow-y: auto;
padding: 4px 14px 20px;
}
.body-empty {
padding: 14px;
color: var(--text-dim);
font-size: 12.5px;
}
section {
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
section:last-child {
border-bottom: 0;
}
h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-dim);
}
.self-card {
border-radius: var(--radius-sm);
background: var(--accent);
color: #fff;
padding: 10px 12px;
}
.self-card.circular {
background: #dc2626;
}
.cell-label {
margin: 0;
font-family: ui-monospace, monospace;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.cell-value {
margin: 2px 0 0;
font-size: 12.5px;
opacity: 0.9;
}
.cell-raw {
margin: 4px 0 0;
font-size: 11px;
font-family: ui-monospace, monospace;
opacity: 0.8;
overflow-wrap: anywhere;
}
.circ-warn {
margin: 0 0 4px;
font-size: 11px;
font-weight: 600;
}
.empty {
margin: 0;
font-size: 12px;
font-style: italic;
color: var(--text-dim);
}
.node-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.node-btn {
width: 100%;
text-align: left;
display: flex;
align-items: baseline;
gap: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
padding: 6px 9px;
font: inherit;
cursor: pointer;
}
.node-btn:hover {
border-color: var(--accent);
}
.node-btn.circular {
border-color: #dc2626;
}
.node-label {
font-family: ui-monospace, monospace;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-value {
margin-left: auto;
flex: none;
font-size: 11.5px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.warn {
font-size: 10.5px;
color: #dc2626;
flex: none;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
/**
* "Dokumente" -- every document the user can open, in up to three groups:
* their account's documents (server), documents shared with them (server:
* invitations, links), and documents stored only in this browser
* (IndexedDB). Open, rename, duplicate, delete/leave, upload, create.
*/
import { computed, nextTick, ref, watch } from 'vue';
import { useLibrary, type DocRef } from '../store/library';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
const library = useLibrary();
const session = useSession();
interface Row {
ref: DocRef;
title: string;
updatedAt: number;
note: string;
canRename: boolean;
canDuplicate: boolean;
removeLabel: string;
canUpload: boolean;
}
const ROLE_NOTE = { owner: '', editor: 'bearbeiten', viewer: 'nur lesen' } as const;
const groups = computed(() => {
const own: Row[] = [];
const shared: Row[] = [];
for (const d of library.state.serverDocs) {
const row: Row = {
ref: { source: 'server', id: d.id },
title: d.title,
updatedAt: Date.parse(d.updated_at),
note: d.role === 'owner' ? '' : `von ${d.owner_name} · ${ROLE_NOTE[d.role]}`,
canRename: d.role !== 'viewer',
canDuplicate: true,
removeLabel: d.role === 'owner' ? 'Löschen' : 'Entfernen',
canUpload: false,
};
(d.role === 'owner' ? own : shared).push(row);
}
for (const l of library.state.linkDocs) {
shared.push({
ref: { source: 'server', id: l.id },
title: l.title,
updatedAt: 0,
note: `per Link · ${ROLE_NOTE[l.role]}`,
canRename: false,
canDuplicate: false,
removeLabel: 'Entfernen',
canUpload: false,
});
}
const local: Row[] = library.state.docs.map((d) => ({
ref: { source: 'local', id: d.id },
title: d.title,
updatedAt: d.updatedAt,
note: '',
canRename: true,
canDuplicate: true,
removeLabel: 'Löschen',
canUpload: session.signedIn.value,
}));
return [
{ key: 'own', title: 'Mein Konto', rows: own, show: session.signedIn.value },
{ key: 'shared', title: 'Mit mir geteilt', rows: shared, show: shared.length > 0 },
{
key: 'local',
title: session.signedIn.value ? 'Nur in diesem Browser' : 'In diesem Browser',
rows: local,
show: library.state.available && (local.length > 0 || !session.signedIn.value),
},
].filter((g) => g.show);
});
const isCurrent = (r: DocRef) =>
library.state.current?.source === r.source && library.state.current.id === r.id;
const keyOf = (r: DocRef) => `${r.source}:${r.id}`;
const renaming = ref<string | null>(null);
const renameValue = ref('');
const renameInput = ref<HTMLInputElement[] | null>(null);
watch(
() => props.open,
(o) => {
if (o) {
renaming.value = null;
void library.refreshServerList();
}
},
);
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
function when(ms: number): string {
if (!ms) return '';
const diff = Date.now() - ms;
if (diff < 60_000) return 'gerade eben';
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
return fmt.format(ms);
}
function subline(r: Row): string {
return [isCurrent(r.ref) ? 'geöffnet' : '', r.note, when(r.updatedAt)].filter(Boolean).join(' · ');
}
async function openDoc(r: Row) {
if (renaming.value) return;
if (await library.open(r.ref)) emit('close');
}
async function startRename(r: Row) {
renaming.value = keyOf(r.ref);
renameValue.value = r.title;
await nextTick();
renameInput.value?.[0]?.select();
}
async function commitRename(r: Row) {
if (renaming.value !== keyOf(r.ref)) return;
renaming.value = null;
if (renameValue.value.trim() && renameValue.value !== r.title) await library.rename(r.ref, renameValue.value);
}
async function remove(r: Row) {
const q =
r.removeLabel === 'Löschen'
? `„${r.title}" endgültig löschen?`
: `„${r.title}" aus deiner Liste entfernen? Du verlierst den Zugriff, bis es wieder mit dir geteilt wird.`;
if (confirm(q)) await library.remove(r.ref);
}
async function upload(r: Row) {
await library.upload([r.ref.id]);
}
async function createNew() {
await library.createBlank();
emit('close');
}
</script>
<template>
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
<p v-if="!library.state.available && !session.signedIn.value" class="hint">
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar, z. B. im
privaten Modus). Mit einem Konto werden Dokumente auf dem Server gespeichert.
</p>
<section v-for="g in groups" :key="g.key" class="group">
<h3>{{ g.title }}</h3>
<p v-if="!g.rows.length" class="empty">Noch keine Dokumente.</p>
<ul v-else class="doc-list">
<li v-for="r in g.rows" :key="keyOf(r.ref)" class="doc-row" :class="{ current: isCurrent(r.ref) }">
<input
v-if="renaming === keyOf(r.ref)"
ref="renameInput"
v-model="renameValue"
class="rename"
type="text"
@keydown.enter.prevent="commitRename(r)"
@keydown.esc.stop.prevent="renaming = null"
@blur="commitRename(r)"
/>
<button v-else class="doc-open" :title="`${r.title} öffnen`" @click="openDoc(r)">
<span class="doc-name">{{ r.title }}</span>
<span class="doc-when">{{ subline(r) }}</span>
</button>
<span class="doc-actions">
<button v-if="r.canUpload" class="mini" title="In dein Konto hochladen (danach teilbar)" @click="upload(r)">
Hochladen
</button>
<button v-if="r.canRename" class="mini" @click="startRename(r)">Umbenennen</button>
<button v-if="r.canDuplicate" class="mini" @click="library.duplicate(r.ref)">Duplizieren</button>
<button class="mini danger" @click="remove(r)">{{ r.removeLabel }}</button>
</span>
</li>
</ul>
</section>
<p v-if="!session.signedIn.value" class="hint">
Dokumente werden nur in diesem Browser gespeichert.
<button class="linkish" @click="emit('login')">Anmelden</button>, um sie auf allen Geräten zu haben und zu
teilen.
</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button class="primary" :disabled="!library.state.available && !session.signedIn.value" @click="createNew">
+ Neues Dokument
</button>
</template>
</BaseModal>
</template>
<style scoped>
.group + .group {
margin-top: 16px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.empty {
margin: 0;
font-size: 12.5px;
color: var(--text-dim);
}
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 14px 0 0;
}
.linkish {
border: 0;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.doc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(40vh, 360px);
overflow-y: auto;
}
.doc-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 4px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid transparent;
}
.doc-row.current {
border-color: var(--accent);
}
.doc-open {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
border: 0;
background: transparent;
padding: 4px 6px;
border-radius: 5px;
font: inherit;
color: var(--text);
text-align: left;
cursor: pointer;
}
.doc-open:hover .doc-name {
color: var(--accent);
}
.doc-name {
font-size: 13px;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-when {
font-size: 11.5px;
color: var(--text-dim);
}
.rename {
flex: 1;
min-width: 0;
border: 1px solid var(--accent);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface);
color: var(--text);
}
.doc-actions {
display: flex;
gap: 6px;
flex: none;
}
@media (max-width: 560px) {
.doc-row {
flex-wrap: wrap;
}
.doc-actions {
width: 100%;
justify-content: flex-end;
}
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { cycleReferenceLocks } from '../engine';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import { useToasts } from '../composables/useToasts';
import FormulaSuggest from './FormulaSuggest.vue';
const store = useDocument();
const toast = useToasts();
const draft = ref('');
const focused = ref(false);
const fieldEl = ref<HTMLTextAreaElement | null>(null);
const autocomplete = useFormulaAutocomplete();
const keyboardPoint = useKeyboardPointMode();
// keep the field in sync with the active cell unless the user is editing it
watch(
() => store.activeCellInput.value,
(v) => {
if (!focused.value) draft.value = v;
},
{ immediate: true },
);
// ---- Name Box (top-left) ---------------------------------------------
// Doubles as (1) a live "where am I" display (e.g. "Umsatz::B2:E6") and (2)
// a jump/select field: type a cell/range/table/sheet/named-range name and
// press Enter -- see document.ts's `jumpToNameInput` for exactly what it
// accepts. Autocompletes against named ranges, table names, and sheet
// names (`nameBoxSuggestions`), same dropdown component as the formula
// function-name autocomplete.
const nameDraft = ref('');
const nameFocused = ref(false);
const nameActiveIndex = ref(0);
const nameEl = ref<HTMLInputElement | null>(null);
const nameSuggestions = computed(() =>
nameFocused.value ? store.nameBoxSuggestions(nameDraft.value) : [],
);
watch(
() => store.selectionRangeName.value,
(v) => {
if (!nameFocused.value) nameDraft.value = v;
},
{ immediate: true },
);
function onNameFocus() {
nameFocused.value = true;
nameActiveIndex.value = 0;
nextTick(() => nameEl.value?.select());
}
function onNameBlur() {
nameFocused.value = false;
nameDraft.value = store.selectionRangeName.value; // discard an unconfirmed edit
}
function onNameInput() {
nameActiveIndex.value = 0;
}
/** Tries to navigate to `text`; on success blurs back to the grid (so the
* Name Box promptly shows the new selection), on failure keeps the field
* focused/selected (so the user can see and immediately correct it) and
* reports the problem via a toast, same as other invalid-input feedback
* in this app. */
function jumpTo(text: string) {
if (store.jumpToNameInput(text)) {
(document.activeElement as HTMLElement)?.blur();
} else {
toast.error(`„${text}" ist kein gültiger Bezug oder Name.`);
nextTick(() => nameEl.value?.select());
}
}
function onNamePick(i: number) {
const item = nameSuggestions.value[i];
if (item) jumpTo(item.name);
}
function onNameKeydown(e: KeyboardEvent) {
const hasDropdown = nameSuggestions.value.length > 0;
if (hasDropdown && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const n = nameSuggestions.value.length;
nameActiveIndex.value = (nameActiveIndex.value + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
return;
}
if (hasDropdown && e.key === 'Tab') {
e.preventDefault();
nameDraft.value = nameSuggestions.value[nameActiveIndex.value].name;
return;
}
if (e.key === 'Enter') {
e.preventDefault();
jumpTo(nameDraft.value);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
nameDraft.value = store.selectionRangeName.value;
(document.activeElement as HTMLElement)?.blur();
}
}
function commit() {
const sel = store.doc.selection;
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
}
/** Lets the store insert a reference into this field when the user clicks a
* grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
function registerAsFormulaEditor() {
const sel = store.doc.selection;
if (!sel) return;
store.registerFormulaEditor({
tableId: sel.tableId,
getState: () => ({
text: draft.value,
start: fieldEl.value?.selectionStart ?? draft.value.length,
end: fieldEl.value?.selectionEnd ?? draft.value.length,
}),
replace: (start, end, value) => {
draft.value = draft.value.slice(0, start) + value + draft.value.slice(end);
nextTick(() => {
fieldEl.value?.focus();
fieldEl.value?.setSelectionRange(start + value.length, start + value.length);
});
},
});
}
function onFocus() {
focused.value = true;
keyboardPoint.reset();
registerAsFormulaEditor();
}
function onEnter() {
commit();
(document.activeElement as HTMLElement)?.blur();
}
function onBlur() {
focused.value = false;
commit();
store.registerFormulaEditor(null);
autocomplete.close();
}
function onEscape() {
draft.value = store.activeCellInput.value;
(document.activeElement as HTMLElement)?.blur();
}
/** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */
function onInput(e: Event) {
store.noteFormulaTyped();
keyboardPoint.reset();
const el = e.target as HTMLTextAreaElement;
autocomplete.update(draft.value, el.selectionStart ?? draft.value.length);
}
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
* replace() mechanism point-mode uses. */
function applySuggestion(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? draft.value.length;
const rep = autocomplete.accept(draft.value, caret);
if (!rep) return false;
draft.value = draft.value.slice(0, rep.start) + rep.value + draft.value.slice(rep.end);
autocomplete.close();
nextTick(() => el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length));
return true;
}
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
* keeping focus in the textarea, so `fieldEl` is still the live editor). */
function onPickSuggestion(i: number) {
autocomplete.activeIndex.value = i;
const el = fieldEl.value;
if (el) applySuggestion(el);
}
/** Clicking inside the field to reposition the caret should also end that window. */
function onMousedown() {
store.noteFormulaTyped();
keyboardPoint.reset();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function onF4(el: HTMLTextAreaElement) {
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(draft.value, start, end);
if (!res) return;
draft.value = res.text;
nextTick(() => el.setSelectionRange(res.start, res.end));
}
function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLTextAreaElement;
// Function-name autocomplete takes priority over every other key below
// while its dropdown is open (see useFormulaAutocomplete.ts).
if (autocomplete.suggestions.value.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (applySuggestion(el)) return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
autocomplete.close();
return;
}
}
// Keyboard "point mode" -- see useKeyboardPointMode's own doc comment.
const sel = store.doc.selection;
if (sel) {
const caret = el.selectionStart ?? draft.value.length;
if (keyboardPoint.handleArrowKey(e, sel.tableId, sel.anchor, draft.value, caret)) {
e.preventDefault();
return;
}
}
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
// Option is a compose key there, so `altKey` alone isn't fully reliable;
// `e.code` sidesteps `key` being altered by that composition).
const isEnter = e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter';
if (isEnter && (e.altKey || e.metaKey)) {
e.preventDefault();
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
nextTick(() => el.setSelectionRange(start + 1, start + 1));
return;
}
if (isEnter) {
e.preventDefault();
onEnter();
} else if (e.key === 'Escape') {
e.preventDefault();
onEscape();
} else if (e.key === 'F4') {
e.preventDefault();
onF4(el);
}
}
</script>
<template>
<div class="formula-bar">
<div class="name-box">
<input
ref="nameEl"
v-model="nameDraft"
class="cell-name"
spellcheck="false"
title="Name/Bezug eingeben und Enter drücken, um dorthin zu springen"
@focus="onNameFocus"
@blur="onNameBlur"
@keydown="onNameKeydown"
@input="onNameInput"
/>
<FormulaSuggest
v-if="nameSuggestions.length"
:items="nameSuggestions"
:active-index="nameActiveIndex"
@pick="onNamePick"
/>
</div>
<span class="fx">fx</span>
<div class="input-wrap">
<textarea
ref="fieldEl"
v-model="draft"
class="formula-input"
spellcheck="false"
rows="1"
:placeholder="store.ui.readOnly ? 'Nur Lesen' : 'Wert oder =Formel'"
:readonly="!!store.ui.readOnly"
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeydown"
@input="onInput"
@mousedown="onMousedown"
/>
<FormulaSuggest
v-if="autocomplete.suggestions.value.length"
:items="autocomplete.suggestions.value"
:active-index="autocomplete.activeIndex.value"
@pick="onPickSuggestion"
/>
</div>
</div>
</template>
<style scoped>
.formula-bar {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
/* holds the FormulaSuggest dropdown, positioned relative to this -- same
role as .input-wrap below, for the Name Box instead of the formula field */
.name-box {
position: relative;
min-width: 128px;
max-width: 128px;
}
.cell-name {
width: 100%;
height: 30px;
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0 6px;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-name:focus {
outline: none;
border-color: var(--accent);
background: var(--surface-2);
color: var(--text);
}
@media (max-width: 480px) {
.name-box {
min-width: 64px;
max-width: 64px;
}
.formula-bar {
gap: 6px;
padding: 6px 8px;
}
}
.fx {
margin-top: 6px;
font-style: italic;
color: var(--text-dim);
font-size: 13px;
}
/* holds the FormulaSuggest dropdown, positioned relative to this */
.input-wrap {
position: relative;
flex: 1;
}
/* a plain <textarea> gives a free, native mouse-draggable resize handle
(bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus"
ask -- and, unlike the old <input>, actually wraps a long formula instead
of just scrolling it sideways, so growing the box has something to show. */
.formula-input {
width: 100%;
min-height: 30px;
max-height: 50vh;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* Generic name/function autocomplete dropdown -- used both for a formula
* editor's function-name autocomplete (see useFormulaAutocomplete.ts) and
* the Name Box's table/sheet/named-range autocomplete (see document.ts's
* `nameBoxSuggestions()`), anything shaped like {name, hint}. Purely
* presentational -- positions itself just under whatever it's rendered
* inside of (that parent needs `position: relative`); no caret-pixel
* tracking.
*/
defineProps<{ items: { name: string; hint: string }[]; activeIndex: number }>();
const emit = defineEmits<{ (e: 'pick', index: number): void }>();
</script>
<template>
<ul class="formula-suggest" role="listbox">
<li
v-for="(item, i) in items"
:key="item.name"
role="option"
:aria-selected="i === activeIndex"
:class="{ on: i === activeIndex }"
@mousedown.prevent="emit('pick', i)"
>
<span class="name">{{ item.name }}</span>
<span class="hint">{{ item.hint }}</span>
</li>
</ul>
</template>
<style scoped>
.formula-suggest {
position: absolute;
top: 100%;
left: 0;
z-index: 30;
margin: 2px 0 0;
padding: 4px;
min-width: 220px;
max-width: 420px;
max-height: 220px;
overflow-y: auto;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
font-size: 12px;
}
.formula-suggest li {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
white-space: nowrap;
}
.formula-suggest li.on {
background: var(--accent-weak);
}
.name {
font-weight: 600;
color: var(--text);
}
.hint {
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
* code. Clicking the link signs in (in whatever browser opens it); typing
* the code here signs in this browser. Both create the account on first use.
*/
import { nextTick, ref, watch } from 'vue';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean; reason?: string }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const session = useSession();
const step = ref<'email' | 'code'>('email');
const email = ref('');
const code = ref('');
const busy = ref(false);
const error = ref<string | null>(null);
const emailInput = ref<HTMLInputElement | null>(null);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
async (o) => {
if (!o) return;
step.value = 'email';
code.value = '';
error.value = null;
await nextTick();
emailInput.value?.focus();
},
);
async function send() {
if (!email.value.trim()) return;
busy.value = true;
error.value = null;
try {
await session.requestLink(email.value.trim());
step.value = 'code';
await nextTick();
codeInput.value?.focus();
} catch (err) {
error.value = (err as Error).message.includes('422')
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
: (err as Error).message;
} finally {
busy.value = false;
}
}
async function verify() {
busy.value = true;
error.value = null;
try {
await session.verifyCode(email.value.trim(), code.value);
} catch {
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
<template v-if="step === 'email'">
<p v-if="reason" class="reason">{{ reason }}</p>
<p class="lead">
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
</p>
<form @submit.prevent="send">
<label class="row">
E-Mail
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
</label>
</form>
</template>
<template v-else>
<p class="lead">
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
den 6-stelligen Code ein.
</p>
<form @submit.prevent="verify">
<label class="row">
Code
<input
ref="codeInput"
v-model="code"
class="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="7"
placeholder="123456"
/>
</label>
</form>
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
</template>
<p v-if="error" class="error">{{ error }}</p>
<template #footer>
<button @click="emit('close')">Abbrechen</button>
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
Link senden
</button>
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
Anmelden
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead,
.reason {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.5;
}
.reason {
font-weight: 600;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font: inherit;
font-size: 14px;
background: var(--surface-2);
color: var(--text);
}
.code {
letter-spacing: 0.25em;
font-variant-numeric: tabular-nums;
}
.linkish {
margin-top: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 12.5px;
color: var(--accent);
cursor: pointer;
}
.error {
margin: 10px 0 0;
font-size: 12.5px;
color: #dc2626;
}
</style>
+336
View File
@@ -0,0 +1,336 @@
<script setup lang="ts">
/**
* "▶ Makros" -- the exclusive UI for creating, viewing, editing, deleting
* and test-running the document's macros (store/document.ts's runMacro(),
* engine/parser.ts's parseMacroBlock()). Mirrors CustomFunctionsModal.vue's
* list/edit-form shape, minus the parameter list and expression/block
* toggle a macro doesn't have -- a macro is always the same write-capable
* statement language, parameterless (triggered by a click, no arguments).
*/
import { nextTick, reactive, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import CodeReference from './CodeReference.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
const toasts = useToasts();
type Mode = 'list' | 'edit';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', body: '' });
const formError = ref<string | null>(null);
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
form.name = '';
form.body = '';
formError.value = null;
mode.value = 'edit';
}
function openEdit(id: string) {
const m = store.doc.macros.find((x) => x.id === id);
if (!m) return;
editingId.value = id;
form.name = m.name;
form.body = m.body;
formError.value = null;
mode.value = 'edit';
}
function save() {
const result = editingId.value
? store.updateMacro(editingId.value, form.name, form.body)
: store.addMacro(form.name, form.body);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeMacro(id: string, name: string) {
if (confirm(`Makro "${name}" löschen? Buttons, die es zugewiesen haben, verlieren die Zuweisung.`)) {
store.deleteMacro(id);
}
}
// A macro can now involve a real network round-trip (GET/POST) and even a
// blocking confirm() prompt -- guard against re-clicking "▶ Ausführen" on
// the same row while its run is still in flight.
const runningId = ref<string | null>(null);
async function runMacro(id: string) {
if (runningId.value) return;
runningId.value = id;
try {
const result = await store.runMacro(id);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
} finally {
runningId.value = null;
}
}
const bodyInput = ref<HTMLTextAreaElement | null>(null);
/** Put a reference snippet at the caret (replacing any selection). */
async function insertSnippet(text: string) {
const el = bodyInput.value;
const start = el?.selectionStart ?? form.body.length;
const end = el?.selectionEnd ?? form.body.length;
form.body = form.body.slice(0, start) + text + form.body.slice(end);
await nextTick();
if (!el) return;
el.focus();
el.setSelectionRange(start + text.length, start + text.length);
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:wide="mode === 'edit'"
:title="mode === 'list' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.macros.length" class="hint">
Noch keine Makros definiert. Mit „+ Neues Makro" eines anlegen — danach über „▶" hier
oder über einen Button auf der Fläche auslösbar.
</p>
<ul v-else class="fn-list">
<li v-for="m in store.doc.macros" :key="m.id" class="fn-row">
<code class="fn-sig">{{ m.name }}</code>
<span class="fn-actions">
<button
class="mini run"
title="Ausführen"
:disabled="runningId === m.id"
@click="runMacro(m.id)"
>
{{ runningId === m.id ? '… läuft' : '▶ Ausführen' }}
</button>
<button class="mini" @click="openEdit(m.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<div v-else class="edit-layout">
<div class="editor">
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. Quartalsabschluss" />
</label>
<label class="row body-row">
Aktionen
<textarea
ref="bodyInput"
v-model="form.body"
class="formula-input"
rows="14"
spellcheck="false"
placeholder="INSERTROW(&quot;Umsatz&quot;; 1)&#10;SETVALUE(Umsatz::A2; &quot;Neu&quot;)"
/>
</label>
<p class="hint">
Befehle rechts suchen, aufklappen für Beschreibung und Beispiel, mit
<strong>Einfügen</strong> an der Cursorposition übernehmen. Wichtig: <strong>jeder
Zellbezug nennt die Tabelle</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code>.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</div>
<CodeReference class="reference" context="macro" @insert="insertSnippet" />
</div>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neues Makro</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.fn-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.fn-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.fn-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.run {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.mini.run:hover {
background: var(--accent);
color: #fff;
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'] {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.body-row .formula-input {
min-height: 30px;
max-height: 40vh;
}
.formula-input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
.edit-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
/* side by side: fill the (viewport-capped) modal body and let the
reference list scroll inside it instead of the whole body */
grid-template-rows: minmax(0, 1fr);
gap: 16px;
flex: 1 1 auto;
min-height: 0;
}
@media (max-width: 720px) {
/* stacked: let the modal body scroll as a whole */
.edit-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
flex: none;
}
}
.editor {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow-y: auto;
}
.editor .body-row {
flex: 1 1 auto;
min-height: 0;
}
.editor .body-row .formula-input {
flex: 1 1 auto;
min-height: 90px;
max-height: none;
}
.editor .formula-input {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
white-space: pre;
overflow-x: auto;
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
/**
* "🏷 Namen" -- the exclusive UI for creating, viewing, editing and deleting
* the document's named ranges (a document-wide name standing for a fixed
* cell/range on a specific table, usable unqualified in any top-level
* formula -- see src/store/types.ts's `NamedRange`). Mirrors
* CustomFunctionsModal.vue's list/form structure.
*/
import { computed, reactive, ref, watch } from 'vue';
import { looksLikeCell, parseA1, toA1 } from '../engine';
import { useDocument } from '../store/document';
import type { TableObject } from '../store/types';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', tableId: '', start: '', end: '' });
const formError = ref<string | null>(null);
const allTables = computed(() =>
store.doc.sheets.flatMap((s) =>
s.objects
.filter((o): o is TableObject => o.kind === 'table')
.map((t) => ({ id: t.id, name: t.name, sheetName: s.name })),
),
);
function tableLabel(tableId: string): string {
const t = allTables.value.find((x) => x.id === tableId);
return t ? `${t.name} (${t.sheetName})` : '?';
}
/** e.g. "B2" or "B2:B6" (single cell when start === end). */
function rangeLabel(r: { start: { col: number; row: number }; end: { col: number; row: number } }): string {
const a = toA1(r.start.col, r.start.row);
const b = toA1(r.end.col, r.end.row);
return a === b ? a : `${a}:${b}`;
}
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
// -progress never lingers into the next open.
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
formError.value = null;
// prefill from the current cell selection, if any -- "create name from
// selection", same idea as Excel's own shortcut for this.
const sel = store.doc.selection;
if (sel) {
const rect = store.selectionRects(sel)[0];
form.tableId = sel.tableId;
form.start = toA1(rect.c0, rect.r0);
form.end = toA1(rect.c1, rect.r1);
} else {
form.tableId = allTables.value[0]?.id ?? '';
form.start = '';
form.end = '';
}
form.name = '';
mode.value = 'edit';
}
function openEdit(id: string) {
const r = store.doc.namedRanges.find((x) => x.id === id);
if (!r) return;
editingId.value = id;
form.name = r.name;
form.tableId = r.tableId;
form.start = toA1(r.start.col, r.start.row);
form.end = toA1(r.end.col, r.end.row);
formError.value = null;
mode.value = 'edit';
}
function save() {
const startTok = form.start.trim();
const endTok = form.end.trim() || startTok;
if (!looksLikeCell(startTok)) {
formError.value = `"${form.start}" ist kein gültiger Zellbezug.`;
return;
}
if (!looksLikeCell(endTok)) {
formError.value = `"${form.end}" ist kein gültiger Zellbezug.`;
return;
}
const start = parseA1(startTok);
const end = parseA1(endTok);
const result = editingId.value
? store.updateNamedRange(editingId.value, form.name, form.tableId, start, end)
: store.addNamedRange(form.name, form.tableId, start, end);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeRange(id: string, name: string) {
if (confirm(`Name "${name}" löschen? Formeln, die ihn verwenden, zeigen danach #NAME?.`)) {
store.deleteNamedRange(id);
}
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:title="mode === 'list' ? 'Benannte Bereiche' : editingId ? 'Namen bearbeiten' : 'Neuer Name'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.namedRanges.length" class="hint">
Noch keine benannten Bereiche definiert. Mit „+ Neuer Name" einen anlegen und danach
wie <code>=Umsatz_Q1</code> in jeder Formel verwenden, ohne Tabellen-Qualifier.
</p>
<ul v-else class="nr-list">
<li v-for="r in store.doc.namedRanges" :key="r.id" class="nr-row">
<code class="nr-sig">
{{ r.name }} → {{ tableLabel(r.tableId) }}!{{ rangeLabel(r) }}
</code>
<span class="nr-actions">
<button class="mini" @click="openEdit(r.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeRange(r.id, r.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<template v-else>
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. Umsatz_Q1" />
</label>
<label class="row">
Tabelle
<select v-model="form.tableId">
<option v-for="t in allTables" :key="t.id" :value="t.id">
{{ t.name }} ({{ t.sheetName }})
</option>
</select>
</label>
<div class="row range-row">
<span class="range-label">Bereich</span>
<div class="range-fields">
<input v-model="form.start" type="text" placeholder="B2" class="cell-input" />
<span class="range-sep">:</span>
<input v-model="form.end" type="text" placeholder="B6 (leer = nur B2)" class="cell-input" />
</div>
</div>
<p class="hint">
Eine leere zweite Zelle bedeutet „nur die erste Zelle" (kein Bereich). Der Name ist
danach in jeder Formel unqualifiziert nutzbar, z. B. <code>=SUM(Umsatz_Q1)</code> oder
<code>=Umsatz_Q1 + 1</code> für eine einzelne Zelle. Er überlebt eine spätere Umbenennung
der Tabelle; wird die Tabelle gelöscht, zeigen Formeln, die den Namen nutzen, danach
<code>#NAME?</code>.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</template>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neuer Name</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.nr-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nr-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.nr-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.nr-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'],
.row select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.range-fields {
display: flex;
align-items: center;
gap: 6px;
}
.cell-input {
width: 120px;
}
.range-sep {
color: var(--text-dim);
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
+764
View File
@@ -0,0 +1,764 @@
<script setup lang="ts">
/**
* Left inspector drawer, opened from the gear button next to the title.
* Gathers every important setting in one place: the selected object (table
* sizing / frozen rows & columns / position, or chart), the active sheet, and
* document-wide options (theme, CSV default, reset), plus the canvas zoom.
*/
import { computed } from 'vue';
import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { NUMBER_LOCALES, NUM_FORMATS } from '../store/format';
import type { CellFormat, HAlign } from '../store/types';
import { DEFAULT_GRID_SIZE, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types';
import ToolbarIcon from './ToolbarIcon.vue';
const store = useDocument();
const { mode: themeMode } = useTheme();
const open = computed(() => store.ui.settingsOpen);
const obj = computed(() => store.activeObject.value);
const table = computed(() =>
obj.value?.kind === 'table' && obj.value.view ? obj.value : null,
);
const chart = computed(() => (obj.value?.kind === 'chart' ? obj.value : null));
const text = computed(() => (obj.value?.kind === 'text' ? obj.value : null));
const button = computed(() => (obj.value?.kind === 'button' ? obj.value : null));
const sheet = computed(() => store.activeSheet.value);
const num = (e: Event) => Number((e.target as HTMLInputElement).value);
const str = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value;
const chk = (e: Event) => (e.target as HTMLInputElement).checked;
const tableList = computed(() =>
store.doc.sheets.flatMap((s) => s.objects).filter((o) => o.kind === 'table'),
);
// ---- cell-selection formatting (only when a table cell range is selected) ---
const sel = computed(() => store.doc.selection);
const selFmt = computed<CellFormat>(() => store.selectionFormat.value);
const hasCellSel = computed(() => !!sel.value && sel.value.tableId === table.value?.id);
const selFocus = computed(() => sel.value?.focus ?? { col: 0, row: 0 });
const focusColW = computed(() =>
table.value ? (table.value.colWidths[selFocus.value.col] ?? 104) : 104,
);
const focusRowH = computed(() =>
table.value ? store.rowHeightOf(table.value.id, selFocus.value.row) : 26,
);
function applyFmt(patch: Partial<Record<keyof CellFormat, unknown>>) {
store.applySelectionFormat(patch); // applies to every rectangle of the selection
}
function setAlign(a: HAlign) {
applyFmt({ align: selFmt.value.align === a ? null : a });
}
function close() {
store.ui.settingsOpen = false;
}
function resetDoc() {
if (confirm('Vorbefülltes Dokument wiederherstellen? Alle Änderungen gehen verloren.')) {
store.resetToSeed();
}
}
</script>
<template>
<aside class="drawer" :class="{ open }" aria-label="Einstellungen">
<header>
<h2>Einstellungen</h2>
<button class="x" title="Schließen" @click="close">×</button>
</header>
<div class="body">
<!-- ===================== selected object ===================== -->
<section>
<h3>Objekt</h3>
<template v-if="table">
<label class="f">
<span>Name</span>
<input :value="table.name" @change="store.renameObject(table.id, str($event))" />
</label>
<div class="grid2">
<label class="f">
<span>Zeilen</span>
<input type="number" min="1" :value="table.rows" @change="store.setTableRows(table.id, num($event))" />
</label>
<label class="f">
<span>Spalten</span>
<input type="number" min="1" :value="table.cols" @change="store.setTableCols(table.id, num($event))" />
</label>
</div>
<label class="f">
<span>Darstellung</span>
<select :value="table.view.mode" @change="store.setTableView(table.id, { mode: str($event) === 'fixed' ? 'fixed' : 'auto' })">
<option value="auto">Automatisch – alle Zeilen zeigen</option>
<option value="fixed">Feste Größe – scrollbar</option>
</select>
</label>
<template v-if="table.view.mode === 'fixed'">
<div class="grid2">
<label class="f">
<span>Breite (px)</span>
<input type="number" min="200" step="10" :value="table.view.width" @change="store.setTableView(table.id, { width: num($event) })" />
</label>
<label class="f">
<span>Höhe (px)</span>
<input type="number" min="120" step="10" :value="table.view.height" @change="store.setTableView(table.id, { height: num($event) })" />
</label>
</div>
<div class="grid2">
<label class="f">
<span>Fixierte Kopfzeilen</span>
<input type="number" min="0" :max="table.rows - 1" :value="table.view.frozenRows" @change="store.setTableView(table.id, { frozenRows: num($event) })" />
</label>
<label class="f">
<span>Fixierte Spalten</span>
<input type="number" min="0" :max="table.cols - 1" :value="table.view.frozenCols" @change="store.setTableView(table.id, { frozenCols: num($event) })" />
</label>
</div>
</template>
<div class="grid2">
<label class="f">
<span>Position X</span>
<input type="number" min="0" step="4" :value="table.x" @change="store.moveObject(table.id, num($event), table.y)" />
</label>
<label class="f">
<span>Position Y</span>
<input type="number" min="0" step="4" :value="table.y" @change="store.moveObject(table.id, table.x, num($event))" />
</label>
</div>
<label class="f">
<span>Zahlenformat (Locale)</span>
<select :value="table.locale ?? ''" @change="store.setObjectLocale(table.id, str($event) || null)">
<option value="">— vom Blatt / Dokument</option>
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
</select>
</label>
<label class="f">
<span>Zeilenhöhe – Standard (px)</span>
<input type="number" min="14" max="240" :value="table.rowHeight ?? 26" @change="store.setTableRowHeight(table.id, num($event))" />
</label>
<label class="f">
<span>Tabellen-Hintergrund</span>
<span class="color">
<input type="color" :value="table.bg ?? '#ffffff'" @input="store.setObjectBg(table.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectBg(table.id, null)">↺</button>
<em v-if="!table.bg">Design</em>
</span>
</label>
<label class="f">
<span>Tabellen-Textfarbe</span>
<span class="color">
<input type="color" :value="table.fg ?? '#1d1d1f'" @input="store.setObjectTextColor(table.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectTextColor(table.id, null)">↺</button>
<em v-if="!table.fg">Design</em>
</span>
</label>
<label class="f row-inline">
<input
type="checkbox"
:checked="!!table.hideChromeUnfocused"
@change="store.setTableHideChromeUnfocused(table.id, chk($event))"
/>
<span>Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt</span>
</label>
<!-- ---- current cell selection ---- -->
<div v-if="hasCellSel" class="subsection">
<h4>
Auswahl · {{ store.activeCellName.value }}
<template v-if="store.selectionAggregate.value.cells > 1">
· {{ store.selectionAggregate.value.cells }} Zellen
</template>
</h4>
<div class="grid2">
<label class="f">
<span>Spaltenbreite (px)</span>
<input type="number" min="24" max="800" :value="focusColW" @change="store.setColWidth(table.id, selFocus.col, num($event))" />
</label>
<label class="f">
<span>Zeilenhöhe (px)</span>
<input type="number" min="14" max="240" :value="focusRowH" @change="store.setRowHeight(table.id, selFocus.row, num($event))" />
</label>
</div>
<label class="f">
<span>Ausrichtung</span>
<span class="seg">
<button :class="{ on: selFmt.align === 'left' }" @click="setAlign('left')">links</button>
<button :class="{ on: selFmt.align === 'center' }" @click="setAlign('center')">mitte</button>
<button :class="{ on: selFmt.align === 'right' }" @click="setAlign('right')">rechts</button>
</span>
</label>
<label class="f">
<span>Zahlenformat</span>
<select :value="selFmt.numFmt ?? 'auto'" @change="applyFmt({ numFmt: str($event) })">
<option v-for="nfmt in NUM_FORMATS" :key="nfmt.id" :value="nfmt.id">{{ nfmt.label }}</option>
</select>
</label>
<label class="f row-inline">
<button class="tgl" :class="{ on: selFmt.bold }" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
</label>
<label class="f">
<span>Zell-Textfarbe</span>
<span class="color">
<input type="color" :value="selFmt.color ?? '#1d1d1f'" @input="applyFmt({ color: str($event) })" />
<button class="mini" title="Standard" @click="applyFmt({ color: null })">↺</button>
</span>
</label>
<label class="f">
<span>Zell-Füllfarbe</span>
<span class="color">
<input type="color" :value="selFmt.bg ?? '#ffffff'" @input="applyFmt({ bg: str($event) })" />
<button class="mini" title="Standard" @click="applyFmt({ bg: null })">↺</button>
</span>
</label>
<button @click="store.clearSelectionFormat()">Formatierung entfernen</button>
</div>
<button class="danger" @click="store.deleteObject(table.id)">Tabelle löschen</button>
</template>
<template v-else-if="chart">
<label class="f">
<span>Name</span>
<input :value="chart.name" @change="store.renameObject(chart.id, str($event))" />
</label>
<label class="f">
<span>Ansicht</span>
<select :value="chart.view" @change="store.setChartView(chart.id, str($event) === 'line' ? 'line' : 'bar')">
<option value="bar">Balken</option>
<option value="line">Linie</option>
</select>
</label>
<label class="f">
<span>Quelltabelle</span>
<select :value="chart.sourceTableId ?? ''" @change="store.setChartSource(chart.id, str($event))">
<option v-for="t in tableList" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</label>
<div class="grid2">
<label class="f">
<span>Breite (px)</span>
<input type="number" min="200" step="10" :value="chart.width" @change="store.resizeChart(chart.id, num($event), chart.height)" />
</label>
<label class="f">
<span>Höhe (px)</span>
<input type="number" min="140" step="10" :value="chart.height" @change="store.resizeChart(chart.id, chart.width, num($event))" />
</label>
</div>
<div class="grid2">
<label class="f">
<span>Position X</span>
<input type="number" min="0" step="4" :value="chart.x" @change="store.moveObject(chart.id, num($event), chart.y)" />
</label>
<label class="f">
<span>Position Y</span>
<input type="number" min="0" step="4" :value="chart.y" @change="store.moveObject(chart.id, chart.x, num($event))" />
</label>
</div>
<label class="f">
<span>Zahlenformat (Locale)</span>
<select :value="chart.locale ?? ''" @change="store.setObjectLocale(chart.id, str($event) || null)">
<option value="">— vom Blatt / Dokument</option>
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
</select>
</label>
<label class="f">
<span>Hintergrund</span>
<span class="color">
<input type="color" :value="chart.bg ?? '#ffffff'" @input="store.setObjectBg(chart.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectBg(chart.id, null)">↺</button>
<em v-if="!chart.bg">Design</em>
</span>
</label>
<label class="f">
<span>Text-/Achsenfarbe</span>
<span class="color">
<input type="color" :value="chart.fg ?? '#6e6e73'" @input="store.setObjectTextColor(chart.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectTextColor(chart.id, null)">↺</button>
<em v-if="!chart.fg">Design</em>
</span>
</label>
<button class="danger" @click="store.deleteObject(chart.id)">Diagramm löschen</button>
</template>
<template v-else-if="text">
<label class="f">
<span>Name</span>
<input :value="text.name" @change="store.renameObject(text.id, str($event))" />
</label>
<label class="f">
<span>Text</span>
<textarea rows="4" :value="text.text" @change="store.setTextContent(text.id, str($event))" />
</label>
<div class="grid2">
<label class="f">
<span>Schriftgröße (px)</span>
<input type="number" min="9" max="96" :value="text.fontSize" @change="store.setTextFormat(text.id, { fontSize: num($event) })" />
</label>
<label class="f row-inline" style="align-self: end; margin-bottom: 12px">
<button class="tgl" :class="{ on: text.bold }" @click="store.setTextFormat(text.id, { bold: !text.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: text.italic }" @click="store.setTextFormat(text.id, { italic: !text.italic })"><i>K</i></button>
</label>
</div>
<label class="f">
<span>Ausrichtung</span>
<span class="seg">
<button :class="{ on: text.align === 'left' }" @click="store.setTextFormat(text.id, { align: 'left' })">links</button>
<button :class="{ on: text.align === 'center' }" @click="store.setTextFormat(text.id, { align: 'center' })">mitte</button>
<button :class="{ on: text.align === 'right' }" @click="store.setTextFormat(text.id, { align: 'right' })">rechts</button>
</span>
</label>
<label class="f">
<span>Textfarbe</span>
<span class="color">
<input type="color" :value="text.color ?? '#1d1d1f'" @input="store.setObjectTextColor(text.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectTextColor(text.id, null)">↺</button>
<em v-if="!text.color">Design</em>
</span>
</label>
<label class="f">
<span>Hintergrund</span>
<span class="color">
<input type="color" :value="text.bg ?? '#ffffff'" @input="store.setObjectBg(text.id, str($event))" />
<button class="mini" title="Ohne" @click="store.setObjectBg(text.id, null)">↺</button>
<em v-if="!text.bg">ohne</em>
</span>
</label>
<div class="grid2">
<label class="f">
<span>Breite (px)</span>
<input type="number" min="80" step="10" :value="text.width" @change="store.resizeText(text.id, num($event), text.height)" />
</label>
<label class="f">
<span>Höhe (px)</span>
<input type="number" min="32" step="10" :value="text.height" @change="store.resizeText(text.id, text.width, num($event))" />
</label>
</div>
<div class="grid2">
<label class="f">
<span>Position X</span>
<input type="number" min="0" step="4" :value="text.x" @change="store.moveObject(text.id, num($event), text.y)" />
</label>
<label class="f">
<span>Position Y</span>
<input type="number" min="0" step="4" :value="text.y" @change="store.moveObject(text.id, text.x, num($event))" />
</label>
</div>
<button class="danger" @click="store.deleteObject(text.id)">Textfeld löschen</button>
</template>
<template v-else-if="button">
<label class="f">
<span>Name</span>
<input :value="button.name" @change="store.renameObject(button.id, str($event))" />
</label>
<label class="f">
<span>Beschriftung</span>
<input :value="button.label" @change="store.setButtonLabel(button.id, str($event))" />
</label>
<label class="f">
<span>Makro</span>
<select :value="button.macroId ?? ''" @change="store.setButtonMacroId(button.id, str($event) || null)">
<option value="">— keins —</option>
<option v-for="m in store.doc.macros" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</label>
<p v-if="store.doc.macros.length === 0" class="muted">
Noch kein Makro vorhanden — über „▶ Makros" in der Symbolleiste anlegen.
</p>
<label class="f">
<span>Textfarbe</span>
<span class="color">
<input type="color" :value="button.fg ?? '#0a84ff'" @input="store.setObjectTextColor(button.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectTextColor(button.id, null)">↺</button>
<em v-if="!button.fg">Design</em>
</span>
</label>
<label class="f">
<span>Hintergrund</span>
<span class="color">
<input type="color" :value="button.bg ?? '#e8f2ff'" @input="store.setObjectBg(button.id, str($event))" />
<button class="mini" title="Ohne" @click="store.setObjectBg(button.id, null)">↺</button>
<em v-if="!button.bg">Design</em>
</span>
</label>
<div class="grid2">
<label class="f">
<span>Breite (px)</span>
<input type="number" min="60" step="10" :value="button.width" @change="store.resizeButton(button.id, num($event), button.height)" />
</label>
<label class="f">
<span>Höhe (px)</span>
<input type="number" min="28" step="10" :value="button.height" @change="store.resizeButton(button.id, button.width, num($event))" />
</label>
</div>
<div class="grid2">
<label class="f">
<span>Position X</span>
<input type="number" min="0" step="4" :value="button.x" @change="store.moveObject(button.id, num($event), button.y)" />
</label>
<label class="f">
<span>Position Y</span>
<input type="number" min="0" step="4" :value="button.y" @change="store.moveObject(button.id, button.x, num($event))" />
</label>
</div>
<button class="danger" @click="store.deleteObject(button.id)">Button löschen</button>
</template>
<p v-else class="muted">Ein Objekt auf der Fläche anklicken, um es hier zu bearbeiten.</p>
</section>
<!-- ===================== sheet ===================== -->
<section>
<h3>Blatt</h3>
<label class="f">
<span>Name</span>
<input :value="sheet.name" @change="store.renameSheet(sheet.id, str($event))" />
</label>
<label class="f">
<span>Zahlenformat</span>
<select :value="sheet.locale ?? ''" @change="store.setSheetLocale(sheet.id, str($event) || null)">
<option value="">— vom Dokument ({{ store.doc.locale }})</option>
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
</select>
</label>
<label class="f">
<span>Hintergrundfarbe</span>
<span class="color">
<input
type="color"
:value="sheet.background ?? '#ffffff'"
@input="store.setSheetBackground(sheet.id, str($event))"
/>
<button class="mini" title="Auf Standard zurücksetzen" @click="store.setSheetBackground(sheet.id, null)">↺</button>
<em v-if="!sheet.background">Design</em>
</span>
</label>
<label class="f">
<span>Schriftfarbe</span>
<span class="color">
<input
type="color"
:value="sheet.foreground ?? '#1d1d1f'"
@input="store.setSheetForeground(sheet.id, str($event))"
/>
<button class="mini" title="Auf Standard zurücksetzen" @click="store.setSheetForeground(sheet.id, null)">↺</button>
<em v-if="!sheet.foreground">Design</em>
</span>
</label>
<label class="f row-inline">
<input
type="checkbox"
:checked="sheet.showGrid !== false"
@change="store.setSheetShowGrid(sheet.id, chk($event))"
/>
<span>Raster einblenden</span>
</label>
<label class="f">
<span>Rasterweite ({{ sheet.gridSize ?? DEFAULT_GRID_SIZE }} px)</span>
<input
type="range"
:min="MIN_GRID_SIZE"
:max="MAX_GRID_SIZE"
:value="sheet.gridSize ?? DEFAULT_GRID_SIZE"
title="Steuert auch, worauf Objekte beim Verschieben/Skalieren einrasten"
@input="store.setSheetGridSize(sheet.id, num($event))"
/>
</label>
<div class="row-btns">
<button @click="store.addSheet()">Neues Blatt</button>
<button class="danger" :disabled="store.doc.sheets.length <= 1" @click="store.deleteSheet(sheet.id)">
Blatt löschen
</button>
</div>
</section>
<!-- ===================== view ===================== -->
<section>
<h3>Ansicht</h3>
<label class="f">
<span>Zoom (dieses Blatt) · {{ Math.round(store.activeZoom.value * 100) }}%</span>
<input
type="range"
min="40"
max="200"
step="5"
:value="Math.round(store.activeZoom.value * 100)"
@input="store.setZoom(num($event) / 100)"
/>
</label>
</section>
<!-- ===================== document ===================== -->
<section>
<h3>Dokument</h3>
<label class="f">
<span>Erscheinungsbild</span>
<div class="seg">
<button
:class="{ on: themeMode === 'system' }"
title="Auto (folgt dem System)"
aria-label="Auto (folgt dem System)"
@click="themeMode = 'system'"
>
<ToolbarIcon name="auto" />
</button>
<button
:class="{ on: themeMode === 'light' }"
title="Hell"
aria-label="Hell"
@click="themeMode = 'light'"
>
<ToolbarIcon name="sun" />
</button>
<button
:class="{ on: themeMode === 'dark' }"
title="Dunkel"
aria-label="Dunkel"
@click="themeMode = 'dark'"
>
<ToolbarIcon name="moon" />
</button>
</div>
</label>
<label class="f">
<span>Zahlenformat (Ländereinstellung)</span>
<select :value="store.doc.locale" @change="store.setDocumentLocale(str($event))">
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
</select>
</label>
<label class="f">
<span>CSV-Trennzeichen (Standard)</span>
<select :value="store.doc.csvDelimiter" @change="store.setCsvDelimiter(str($event) === ';' ? ';' : ',')">
<option value=",">Komma ,</option>
<option value=";">Semikolon ;</option>
</select>
</label>
<button class="danger" @click="resetDoc">Auf Beispiel zurücksetzen</button>
</section>
</div>
</aside>
</template>
<style scoped>
.drawer {
position: absolute;
z-index: 60;
top: 0;
left: 0;
bottom: 0;
width: 320px;
max-width: 86vw;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
box-shadow: var(--shadow);
transform: translateX(-102%);
transition: transform 0.18s ease;
}
.drawer.open {
transform: none;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
header h2 {
margin: 0;
font-size: 14px;
}
.x {
border: 0;
background: transparent;
font-size: 18px;
cursor: pointer;
color: var(--text-dim);
}
.body {
overflow-y: auto;
padding: 4px 14px 20px;
}
section {
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
section:last-child {
border-bottom: 0;
}
h3 {
margin: 0 0 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.subsection {
margin: 6px 0 12px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-2);
}
.subsection h4 {
margin: 0 0 10px;
font-size: 11px;
color: var(--text-dim);
font-weight: 600;
}
.tgl {
width: 30px;
height: 28px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
}
.tgl.on {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.f textarea {
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 6px 8px;
resize: vertical;
}
.f {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-dim);
}
.f input,
.f select {
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 6px 8px;
}
.f input[type='range'] {
padding: 0;
background: transparent;
border: 0;
}
.f.row-inline {
flex-direction: row;
align-items: center;
gap: 8px;
}
.f.row-inline input {
width: auto;
}
.color {
display: flex;
align-items: center;
gap: 8px;
}
.color input[type='color'] {
width: 42px;
height: 28px;
padding: 2px;
cursor: pointer;
}
.color .mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
width: 26px;
height: 26px;
cursor: pointer;
color: var(--text-dim);
}
.color .mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.color em {
font-style: normal;
font-size: 11px;
color: var(--text-dim);
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.row-btns {
display: flex;
gap: 8px;
}
button {
font: inherit;
font-size: 12.5px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: 7px;
padding: 6px 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 7px;
overflow: hidden;
}
.seg button {
border: 0;
border-radius: 0;
padding: 5px 10px;
}
.seg button.on {
background: var(--accent);
color: #fff;
}
.muted {
color: var(--text-dim);
font-size: 12px;
margin: 0;
}
</style>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
/**
* "Teilen" for the open document. Sharing needs the document on the server,
* which needs an account: a local document is uploaded first (after
* signing in). The owner then manages links ("anyone with the link can
* view / edit") and people (invited by e-mail, with a role).
*/
import { computed, ref, watch } from 'vue';
import { api, type ShareInfo } from '../io/api';
import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
const library = useLibrary();
const session = useSession();
const toasts = useToasts();
const info = ref<ShareInfo | null>(null);
const busy = ref(false);
const inviteEmail = ref('');
const inviteRole = ref<'viewer' | 'editor'>('editor');
const current = computed(() => library.state.current);
const isServer = computed(() => current.value?.source === 'server');
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
const docPath = computed(() => `/documents/${current.value?.id}`);
async function load() {
info.value = null;
if (!isOwner.value) return;
try {
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
} catch (err) {
toasts.error((err as Error).message);
}
}
watch(
() => [props.open, current.value?.id, library.currentRole.value] as const,
([o]) => {
if (o) void load();
},
);
async function call(method: string, path: string, body?: unknown) {
busy.value = true;
try {
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
return true;
} catch (err) {
toasts.error((err as Error).message);
return false;
} finally {
busy.value = false;
}
}
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
async function copy(token: string) {
try {
await navigator.clipboard.writeText(urlFor(token));
toasts.success('Link kopiert.');
} catch {
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
}
}
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
if (await call('POST', '/links', { role })) {
const l = linkFor(role);
if (l) await copy(l.token);
}
}
async function revoke(id: number) {
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
await call('DELETE', `/links/${id}`);
}
}
async function invite() {
const email = inviteEmail.value.trim();
if (!email) return;
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
toasts.success(`Einladung an ${email} verschickt.`);
inviteEmail.value = '';
}
}
async function setRole(userId: number, role: string) {
await call('PATCH', `/members/${userId}`, { role });
}
async function removeMember(userId: number, name: string) {
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
}
async function uploadAndShare() {
const id = current.value?.id;
if (!id) return;
busy.value = true;
try {
await library.upload([id]);
} finally {
busy.value = false;
}
}
const LINK_ROWS = [
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
] as const;
</script>
<template>
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
<!-- local document -->
<template v-if="!isServer">
<p v-if="!session.signedIn.value" class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
Konto hochgeladen.
</p>
<p v-else class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
dem Browser entfernt).
</p>
</template>
<!-- server document, not the owner -->
<p v-else-if="!isOwner" class="lead">
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
</p>
<!-- owner -->
<template v-else-if="info">
<h3>Link</h3>
<ul class="rows">
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
<span class="label">{{ r.label }}</span>
<template v-if="linkFor(r.role)">
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
<span class="actions">
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
</span>
</template>
<span v-else class="actions">
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
</span>
</li>
</ul>
<p class="hint">Links funktionieren auch ohne Konto.</p>
<h3>Personen</h3>
<form class="invite" @submit.prevent="invite">
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
<select v-model="inviteRole">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
</form>
<ul class="rows">
<li class="person">
<span class="who">
<strong>{{ session.state.user?.name }}</strong>
<span class="dim">{{ session.state.user?.email }}</span>
</span>
<span class="dim">Besitzer</span>
</li>
<li v-for="m in info.members" :key="m.user_id" class="person">
<span class="who">
<strong>{{ m.name }}</strong>
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
</span>
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
</li>
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
<span class="who">
<strong>{{ i.email }}</strong>
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
</span>
</li>
</ul>
</template>
<p v-else class="lead">Lade …</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
Hochladen und teilen
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
h3:not(:first-child) {
margin-top: 18px;
}
.hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-dim);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.link-row,
.person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 13px;
}
.label {
flex: 1 1 200px;
}
.url {
flex: 1 1 220px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
font-size: 12px;
background: var(--surface);
color: var(--text-dim);
}
.actions {
display: flex;
gap: 6px;
margin-left: auto;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.invite {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.invite input {
flex: 1 1 200px;
}
.invite input,
select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface-2);
color: var(--text);
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
/**
* The sheet canvas: a pan/scroll/zoom surface that hosts table and chart
* objects. Empty-space click deselects; objects come to front on select.
* Files dropped anywhere on the canvas are handed to the import pipeline.
*/
import { computed, ref } from 'vue';
import { useDocument } from '../store/document';
import { DEFAULT_GRID_SIZE } from '../store/types';
import CanvasObject from './CanvasObject.vue';
import TheLogo from './TheLogo.vue';
const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
const store = useDocument();
const sheet = computed(() => store.activeSheet.value);
const zoom = computed(() => store.activeZoom.value);
const setZoom = store.setZoom;
const dropActive = ref(false);
/** Per-sheet appearance -> CSS custom properties scoped to this canvas. */
const appearance = computed(() => {
const s: Record<string, string> = {};
if (sheet.value.background) s['--bg'] = sheet.value.background;
if (sheet.value.foreground) {
s['--text'] = sheet.value.foreground;
s['--text-dim'] = sheet.value.foreground;
}
return s;
});
const showGrid = computed(() => sheet.value.showGrid !== false);
/** Also the snap increment CanvasObject.vue moves/resizes against -- see
* its own doc comment. */
const gridSize = computed(() => sheet.value.gridSize ?? DEFAULT_GRID_SIZE);
/** Scaled by zoom (unlike the rest of `.canvas-scroll`, which isn't) so the
* visible dots always land exactly where an object will actually snap --
* otherwise a zoomed-in/out grid would visually lie about the snap. */
const gridStyle = computed(() => {
const px = gridSize.value * zoom.value;
return { backgroundSize: `${px}px ${px}px` };
});
/** Unscaled plane size: enclose every object with generous margin.
* A `fixed`-viewport table only ever occupies its own `view.width`/
* `view.height` on the canvas (that's the entire point of "feste Größe" --
* a bounded, internally-scrolling box) regardless of how many rows/cols it
* holds; only an `auto`-size table actually grows with its content, so only
* that case falls back to the row/col-count estimate. */
const plane = computed(() => {
let w = 1600;
let h = 1000;
for (const o of sheet.value.objects) {
let ow: number;
let oh: number;
if (o.kind === 'table' && o.view.mode === 'fixed') {
ow = o.view.width;
oh = o.view.height;
} else {
ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
}
w = Math.max(w, o.x + ow + 500);
h = Math.max(h, o.y + oh + 400);
}
return { w, h };
});
function onPlaneMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('plane')) store.selectObject(null);
}
function onWheel(e: WheelEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
setZoom(zoom.value - Math.sign(e.deltaY) * 0.1);
}
function onKeydown(e: KeyboardEvent) {
const o = store.activeObject.value;
if (!o) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && o.kind === 'chart') {
e.preventDefault();
store.deleteObject(o.id);
}
}
function onDrop(e: DragEvent) {
dropActive.value = false;
if (e.dataTransfer?.files?.length) emit('files', e.dataTransfer.files);
}
function selectObj(id: string) {
store.selectObject(id);
store.bringToFront(id);
}
</script>
<template>
<div
class="canvas-wrap"
:class="{ 'drawer-open': store.ui.settingsOpen, 'dep-open': store.ui.depGraph }"
:style="appearance"
>
<div
class="canvas-scroll"
:class="{ 'no-grid': !showGrid }"
:style="gridStyle"
tabindex="0"
@wheel="onWheel"
@keydown="onKeydown"
@dragover.prevent="dropActive = true"
@dragleave="dropActive = false"
@drop.prevent="onDrop"
>
<div class="zoom-layer" :style="{ width: plane.w * zoom + 'px', height: plane.h * zoom + 'px' }">
<div
class="plane"
:style="{ width: plane.w + 'px', height: plane.h + 'px', transform: `scale(${zoom})` }"
@mousedown="onPlaneMouseDown"
>
<CanvasObject
v-for="o in sheet.objects"
:key="o.id"
:object="o"
:zoom="zoom"
:selected="store.doc.activeObjectId === o.id"
@select="selectObj(o.id)"
/>
</div>
</div>
<div v-if="sheet.objects.length === 0" class="hint">
<TheLogo word class="hint-logo" />
<p>Leeres Blatt — über die Toolbar „Tabelle", „Diagramm" oder „Text" einfügen.</p>
</div>
</div>
<div v-if="dropActive" class="drop">Datei ablegen (.num .json .csv .xlsx)</div>
</div>
</template>
<style scoped>
.canvas-wrap {
position: relative;
height: 100%;
overflow: hidden;
transition: margin-left 0.18s ease, margin-right 0.18s ease;
}
.canvas-wrap.drawer-open {
margin-left: 320px;
}
.canvas-wrap.dep-open {
margin-right: 380px;
}
@media (max-width: 640px) {
.canvas-wrap.drawer-open {
margin-left: 0; /* drawer overlays on narrow screens */
}
.canvas-wrap.dep-open {
margin-right: 0;
}
}
.canvas-scroll {
position: absolute;
inset: 0;
overflow: auto;
background-color: var(--bg);
/* grid dots use a fixed neutral so they read on any custom backdrop --
background-size is set inline (gridStyle) from the sheet's gridSize */
background-image:
linear-gradient(rgba(128, 128, 128, 0.22) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.22) 1px, transparent 1px);
background-position: 0 0;
outline: none;
}
.canvas-scroll.no-grid {
background-image: none;
}
.plane {
position: relative;
transform-origin: top left;
}
.hint {
position: absolute;
top: 44px;
left: 44px;
color: var(--text-dim);
font-size: 13px;
pointer-events: none;
}
.hint p {
margin: 14px 0 0;
}
.hint-logo {
height: 62px;
opacity: 0.9;
}
.drop {
position: absolute;
right: 24px;
bottom: 24px;
padding: 14px 20px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
z-index: 20;
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
/** Numbers-style bottom tab bar. Native HTML5 drag & drop for reordering. */
import { nextTick, ref } from 'vue';
import { useDocument } from '../store/document';
const store = useDocument();
const renaming = ref<string | null>(null);
const renameDraft = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
let dragIndex = -1;
function startRename(id: string, name: string) {
renaming.value = id;
renameDraft.value = name;
nextTick(() => renameInput.value?.select());
}
function commitRename() {
if (renaming.value) store.renameSheet(renaming.value, renameDraft.value);
renaming.value = null;
}
function cancelRename() {
renaming.value = null;
}
function removeSheet(id: string, name: string, hasContent: boolean) {
if (store.doc.sheets.length <= 1) return;
if (hasContent && !confirm(`Blatt „${name}" mit allen Objekten löschen?`)) return;
store.deleteSheet(id);
}
function onDragStart(i: number) {
dragIndex = i;
}
function onDrop(i: number) {
if (dragIndex >= 0 && dragIndex !== i) store.moveSheet(dragIndex, i);
dragIndex = -1;
}
</script>
<template>
<div class="tabs">
<div class="tab-list">
<div
v-for="(s, i) in store.doc.sheets"
:key="s.id"
class="tab"
:class="{ active: s.id === store.doc.activeSheetId }"
draggable="true"
@click="store.setActiveSheet(s.id)"
@dblclick="startRename(s.id, s.name)"
@dragstart="onDragStart(i)"
@dragover.prevent
@drop="onDrop(i)"
>
<input
v-if="renaming === s.id"
ref="renameInput"
v-model="renameDraft"
class="rename"
@click.stop
@blur="commitRename"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="cancelRename"
/>
<template v-else>
<span class="label">{{ s.name }}</span>
<button
v-if="store.doc.sheets.length > 1"
class="close"
title="Blatt löschen"
@click.stop="removeSheet(s.id, s.name, s.objects.length > 0)"
>
×
</button>
</template>
</div>
</div>
<button class="add" title="Blatt hinzufügen" @click="store.addSheet()">+</button>
</div>
</template>
<style scoped>
.tabs {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
flex: 1 1 auto;
min-width: 60px;
}
.tab-list {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: thin;
min-width: 0;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px 8px 0 0;
font-size: 12.5px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
user-select: none;
border: 1px solid transparent;
border-bottom: none;
}
.tab:hover {
background: var(--surface);
}
.tab.active {
background: var(--surface);
color: var(--text);
border-color: var(--border);
font-weight: 600;
}
.close {
border: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
border-radius: 4px;
opacity: 0;
}
.tab:hover .close,
.tab.active .close {
opacity: 1;
}
.close:hover {
background: var(--danger);
color: #fff;
}
.rename {
font: inherit;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 1px 4px;
width: 96px;
}
.add {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
width: 26px;
height: 26px;
cursor: pointer;
font-size: 15px;
color: var(--text-dim);
}
.add:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useDocument } from '../store/document';
import { formatValue } from '../store/format';
const store = useDocument();
const agg = computed(() => store.selectionAggregate.value);
const fmt = (n: number) =>
formatValue(Math.round(n * 1e6) / 1e6, store.activeLocale.value);
const zoom = computed(() => store.activeZoom.value);
const setZoom = store.setZoom;
</script>
<template>
<div class="status-bar">
<span class="range">{{ store.selectionRangeName.value }}</span>
<span v-if="agg.count > 0" class="metric">Summe <b>{{ fmt(agg.sum) }}</b></span>
<span v-if="agg.count > 0" class="metric">Mittel <b>{{ fmt(agg.avg) }}</b></span>
<span class="metric">Anzahl <b>{{ agg.count }}</b></span>
<span class="metric dim">Zellen {{ agg.cells }}</span>
<span class="divider" />
<div class="zoom-ctl" title="Zoom (dieses Blatt)">
<button @click="setZoom(zoom - 0.1)">−</button>
<button class="fit" @click="setZoom(1)">{{ Math.round(zoom * 100) }}%</button>
<button @click="setZoom(zoom + 0.1)">+</button>
</div>
</div>
</template>
<style scoped>
/* shares one row with SheetTabs.vue (see App.vue's .bottom-bar) -- pinned to
the right, scrolls internally rather than wrapping if it ever runs out of
room, so the combined row always stays a single line. */
.status-bar {
display: flex;
align-items: center;
flex: 0 1 auto;
gap: 4px 14px;
padding: 5px 14px;
font-size: 12px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.metric b {
color: var(--text);
font-weight: 600;
}
.metric.dim {
opacity: 0.7;
}
.range {
font-weight: 600;
color: var(--text);
}
.divider {
width: 1px;
align-self: stretch;
background: var(--border);
flex: none;
}
.zoom-ctl {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
}
.zoom-ctl button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
width: 20px;
height: 20px;
line-height: 1;
cursor: pointer;
color: var(--text-dim);
}
.zoom-ctl button:hover {
border-color: var(--accent);
color: var(--accent);
}
.zoom-ctl button.fit {
width: auto;
padding: 0 7px;
font-variant-numeric: tabular-nums;
}
</style>
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* A formatted text box object. Formatting (size, bold, italic, alignment,
* text/background colour) lives on the object and is edited in the inspector;
* double-click here edits the text itself.
*/
import { computed, nextTick, ref } from 'vue';
import { useDocument } from '../store/document';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'text' ? o : undefined;
});
const editing = ref(false);
const draft = ref('');
const area = ref<HTMLTextAreaElement | null>(null);
function beginEdit() {
if (!obj.value) return;
draft.value = obj.value.text;
editing.value = true;
nextTick(() => area.value?.focus());
}
function commit() {
if (obj.value) store.setTextContent(obj.value.id, draft.value);
editing.value = false;
}
function cancel() {
editing.value = false;
}
const boxStyle = computed(() => {
const o = obj.value;
if (!o) return {};
return {
fontSize: o.fontSize + 'px',
fontWeight: o.bold ? '600' : '400',
fontStyle: o.italic ? 'italic' : 'normal',
textAlign: o.align,
color: o.color || 'var(--text)',
background: o.bg || 'transparent',
} as Record<string, string>;
});
</script>
<template>
<div v-if="obj" class="textbox" :style="boxStyle" @dblclick="beginEdit">
<textarea
v-if="editing"
ref="area"
v-model="draft"
class="edit"
spellcheck="false"
@blur="commit"
@keydown.esc.prevent="cancel"
@keydown.enter.exact.stop
@mousedown.stop
/>
<div v-else class="view">{{ obj.text || 'Doppelklick zum Bearbeiten' }}</div>
</div>
</template>
<style scoped>
.textbox {
width: 100%;
height: 100%;
padding: 8px 10px;
overflow: auto;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.view {
min-height: 100%;
}
.edit {
width: 100%;
height: 100%;
border: 0;
outline: none;
resize: none;
padding: 0;
font: inherit;
color: inherit;
text-align: inherit;
background: transparent;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
/**
* Numolus brand mark: three top-aligned, slightly overlapping pop-art squares
* with a keyline. Fills are fixed brand colours; the keyline uses a token so it
* stays visible in dark mode. Pass `word` to also render the "numolus" wordmark.
*/
defineProps<{ word?: boolean }>();
</script>
<template>
<svg
class="logo"
:class="{ 'has-word': word }"
:viewBox="word ? '64 62 270 206' : '64 64 270 142'"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Numolus"
>
<g stroke="var(--logo-keyline, #111)" stroke-width="6">
<rect x="70" y="100" width="40" height="90" style="fill:rgb(255,212,0);"/>
<rect x="115" y="100" width="65" height="90" style="fill:rgb(0,87,255);"/>
<rect x="185" y="100" width="130" height="90" style="fill:rgb(232,56,79);"/>
</g>
<text
v-if="word"
x="72"
y="255"
font-size="46"
font-weight="600"
fill="currentColor"
>
numolus
</text>
</svg>
</template>
<style scoped>
.logo {
display: block;
height: 100%;
width: auto;
}
.logo text {
font-family: var(--font-ui);
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* Small hand-drawn line-icon set for the toolbar (no icon library/font —
* consistent with this project's "nothing off the shelf" approach). One
* shared <svg> shell (24x24, stroke-only) with a name-keyed set of shapes,
* since every icon needs the exact same viewBox/stroke setup and only the
* inner geometry differs.
*/
export type ToolbarIconName =
| 'settings'
| 'undo'
| 'redo'
| 'table'
| 'chart'
| 'text'
| 'tag'
| 'button'
| 'macro'
| 'sun'
| 'moon'
| 'auto';
defineProps<{ name: ToolbarIconName }>();
</script>
<template>
<svg
class="ti"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'settings'">
<line x1="3" y1="6" x2="21" y2="6" />
<circle cx="9" cy="6" r="2" fill="currentColor" />
<line x1="3" y1="12" x2="21" y2="12" />
<circle cx="15" cy="12" r="2" fill="currentColor" />
<line x1="3" y1="18" x2="21" y2="18" />
<circle cx="7" cy="18" r="2" fill="currentColor" />
</template>
<template v-else-if="name === 'undo'">
<path d="M7 10 H15 a4 4 0 0 1 0 8 h-3" />
<polyline points="7 10 11 6" />
<polyline points="7 10 11 14" />
</template>
<template v-else-if="name === 'redo'">
<path d="M17 10 H9 a4 4 0 0 0 0 8 h3" />
<polyline points="17 10 13 6" />
<polyline points="17 10 13 14" />
</template>
<template v-else-if="name === 'table'">
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="10" x2="21" y2="10" />
<line x1="3" y1="16" x2="21" y2="16" />
<line x1="9" y1="4" x2="9" y2="20" />
<line x1="15" y1="4" x2="15" y2="20" />
</template>
<template v-else-if="name === 'chart'">
<line x1="4" y1="20" x2="20" y2="20" />
<rect x="6" y="13" width="3" height="7" />
<rect x="11" y="9" width="3" height="11" />
<rect x="16" y="5" width="3" height="15" />
</template>
<template v-else-if="name === 'text'">
<rect x="4" y="4" width="16" height="16" rx="2" />
<line x1="7" y1="9" x2="17" y2="9" />
<line x1="7" y1="13" x2="17" y2="13" />
<line x1="7" y1="17" x2="13" y2="17" />
</template>
<template v-else-if="name === 'tag'">
<polygon points="4,5 14,5 20,11 14,17 4,17" />
<circle cx="9" cy="9" r="1.6" fill="currentColor" />
</template>
<template v-else-if="name === 'button'">
<rect x="3" y="7" width="18" height="10" rx="3" />
<polygon points="10,10.5 10,13.5 13,12" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'macro'">
<polygon points="6,4 20,12 6,20" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'sun'">
<circle cx="12" cy="12" r="4.5" />
<line x1="12" y1="2.5" x2="12" y2="5" />
<line x1="12" y1="19" x2="12" y2="21.5" />
<line x1="2.5" y1="12" x2="5" y2="12" />
<line x1="19" y1="12" x2="21.5" y2="12" />
<line x1="4.9" y1="4.9" x2="6.7" y2="6.7" />
<line x1="17.3" y1="17.3" x2="19.1" y2="19.1" />
<line x1="4.9" y1="19.1" x2="6.7" y2="17.3" />
<line x1="17.3" y1="6.7" x2="19.1" y2="4.9" />
</template>
<template v-else-if="name === 'moon'">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4 6.6 6.6 0 0 0 20 14.5Z" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'auto'">
<circle cx="12" cy="12" r="8.5" />
<path d="M12 3.5 A8.5 8.5 0 0 1 12 20.5 Z" fill="currentColor" stroke="none" />
</template>
</svg>
</template>
<style scoped>
.ti {
width: 17px;
height: 17px;
display: block;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* Shown once after signing in when this browser still has local documents:
* which of them should move into the account (server)? Uploaded ones are
* removed from the browser. "Später" leaves everything as is -- uploading
* stays possible from the document list.
*/
import { ref, watch } from 'vue';
import { useLibrary } from '../store/library';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const library = useLibrary();
const selected = ref<Set<string>>(new Set());
const busy = ref(false);
watch(
() => props.open,
(o) => {
if (o) selected.value = new Set(library.state.docs.map((d) => d.id));
},
);
function toggle(id: string) {
const s = new Set(selected.value);
if (s.has(id)) s.delete(id);
else s.add(id);
selected.value = s;
}
async function run() {
busy.value = true;
try {
await library.upload([...selected.value]);
emit('close');
} finally {
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Dokumente in dein Konto übernehmen?" @close="emit('close')">
<p class="lead">
In diesem Browser liegen noch Dokumente. Ausgewählte werden in dein Konto hochgeladen – dann sind sie
auf allen Geräten da und lassen sich teilen – und hier aus dem Browser entfernt.
</p>
<ul class="list">
<li v-for="d in library.state.docs" :key="d.id">
<label>
<input type="checkbox" :checked="selected.has(d.id)" @change="toggle(d.id)" />
{{ d.title }}
</label>
</li>
</ul>
<template #footer>
<button @click="emit('close')">Später</button>
<button class="primary" :disabled="busy || !selected.size" @click="run">
{{ busy ? 'Lade hoch …' : `${selected.size} hochladen` }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
}
.list {
list-style: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.list li + li {
margin-top: 4px;
}
.list label {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 13px;
cursor: pointer;
}
</style>