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:
@@ -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 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
||||
/>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user