- init
This commit is contained in:
+89
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import AppToolbar from './components/AppToolbar.vue';
|
||||
import FormulaBar from './components/FormulaBar.vue';
|
||||
import SheetCanvas from './components/SheetCanvas.vue';
|
||||
import SheetTabs from './components/SheetTabs.vue';
|
||||
import StatusBar from './components/StatusBar.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
import { useToasts } from './composables/useToasts';
|
||||
|
||||
const toolbar = ref<InstanceType<typeof AppToolbar> | null>(null);
|
||||
const { toasts, dismiss } = useToasts();
|
||||
|
||||
function onCanvasFiles(files: FileList) {
|
||||
if (files[0]) toolbar.value?.handleFile(files[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<AppToolbar ref="toolbar" />
|
||||
<FormulaBar />
|
||||
|
||||
<main class="workspace">
|
||||
<SheetCanvas @files="onCanvasFiles" />
|
||||
<SettingsPanel />
|
||||
</main>
|
||||
|
||||
<SheetTabs />
|
||||
<StatusBar />
|
||||
|
||||
<div class="toasts">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="t.kind"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1200;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: var(--series-2);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import {
|
||||
exportJson,
|
||||
exportActiveTableCsv,
|
||||
exportXlsx,
|
||||
importJsonReplace,
|
||||
importJsonAppend,
|
||||
importCsv,
|
||||
importXlsxFile,
|
||||
readAsText,
|
||||
readAsArrayBuffer,
|
||||
extensionOf,
|
||||
type CsvImportConfig,
|
||||
} from '../io/operations';
|
||||
import type { Delimiter } from '../io/csv';
|
||||
|
||||
const store = useDocument();
|
||||
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||
const toasts = useToasts();
|
||||
|
||||
const fileMenuOpen = ref(false);
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const themeLabel = computed(
|
||||
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
|
||||
);
|
||||
|
||||
// ---- 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.');
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
}
|
||||
function runJsonExport() {
|
||||
exportJson(store);
|
||||
toasts.success('Dokument als JSON exportiert.');
|
||||
}
|
||||
async function runXlsxExport() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await exportXlsx(store);
|
||||
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 === '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: 'replace' | 'append' | 'confirm') {
|
||||
const ctx = importCtx.value;
|
||||
if (!ctx) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (ctx.kind === 'json') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
if (confirm('Vorbefülltes Dokument wiederherstellen? Alle Änderungen gehen verloren.')) {
|
||||
store.resetToSeed();
|
||||
toasts.info('Dokument zurückgesetzt.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="toolbar">
|
||||
<button
|
||||
class="gear"
|
||||
:class="{ on: store.ui.settingsOpen }"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen"
|
||||
@click="store.ui.settingsOpen = !store.ui.settingsOpen"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div class="brand">
|
||||
<strong>Numolus</strong>
|
||||
</div>
|
||||
|
||||
<span class="divider" />
|
||||
<button class="btn accent" @click="insertTable">+ Tabelle</button>
|
||||
<button class="btn accent" @click="insertChart">+ Diagramm</button>
|
||||
<button class="btn accent" @click="insertText">+ Text</button>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<div class="menu-wrap">
|
||||
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
||||
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
||||
<li><button @click="pickFile">Importieren … (.json .csv .xlsx)</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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn" @click="cycleTheme">Theme: {{ themeLabel }}</button>
|
||||
<button class="btn" @click="onReset">Zurücksetzen</button>
|
||||
|
||||
<input ref="fileInput" type="file" accept=".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>JSON-Dokument erkannt. Wie einfügen?</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 class="primary" @click="doImport('replace')">Ersetzen</button>
|
||||
</template>
|
||||
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.brand .sub {
|
||||
display: none;
|
||||
}
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
.toolbar {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.gear {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.gear:hover,
|
||||
.gear.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.brand .sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--border);
|
||||
margin: 2px 4px;
|
||||
}
|
||||
.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.accent {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.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,94 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; title: string }>();
|
||||
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" 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;
|
||||
}
|
||||
header {
|
||||
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;
|
||||
}
|
||||
footer {
|
||||
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,255 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
|
||||
*
|
||||
* - the header strip and the frame border move the object;
|
||||
* - the SE handle resizes a chart (free w/h) or proportionally scales a table's
|
||||
* columns (Numbers-style);
|
||||
* - 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.
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type { SheetObject } from '../store/types';
|
||||
import { useDocument } from '../store/document';
|
||||
import TableGrid from './TableGrid.vue';
|
||||
import ChartView from './ChartView.vue';
|
||||
import TextBox from './TextBox.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;
|
||||
}
|
||||
|
||||
// ---- 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, drag.ox + dx, 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;
|
||||
const contentEl = ref<HTMLElement | null>(null);
|
||||
|
||||
/** How the SE handle behaves for this object. */
|
||||
const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => {
|
||||
const o = props.object;
|
||||
if (o.kind === 'chart' || o.kind === 'text') return 'free';
|
||||
return o.view.mode === 'fixed' ? 'table-viewport' : 'table-columns';
|
||||
});
|
||||
|
||||
function onResizeDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('select');
|
||||
const o = props.object;
|
||||
const rect = contentEl.value?.getBoundingClientRect();
|
||||
let w: number;
|
||||
let h: number;
|
||||
if (o.kind === 'chart' || o.kind === 'text') {
|
||||
w = o.width;
|
||||
h = o.height;
|
||||
} else if (o.view.mode === 'fixed') {
|
||||
w = o.view.width;
|
||||
h = o.view.height;
|
||||
} else {
|
||||
w = (rect?.width ?? 300) / props.zoom;
|
||||
h = (rect?.height ?? 200) / props.zoom;
|
||||
}
|
||||
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, rs.w + dx, rs.h + dy);
|
||||
else if (o.kind === 'text') store.resizeText(o.id, rs.w + dx, rs.h + dy);
|
||||
} else if (resizeKind.value === 'table-viewport') {
|
||||
store.setTableView(o.id, { width: rs.w + dx, height: rs.h + dy });
|
||||
} else {
|
||||
const factor = Math.max(0.4, (rs.w + dx) / rs.w);
|
||||
store.scaleTableColumns(o.id, factor, 104);
|
||||
rs = { ...rs, px: e.clientX, w: rs.w + dx };
|
||||
}
|
||||
}
|
||||
function onResizeUp() {
|
||||
rs = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onResizeMove);
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
const icon = computed(() =>
|
||||
props.object.kind === 'table' ? '▦' : props.object.kind === 'chart' ? '▤' : '¶',
|
||||
);
|
||||
|
||||
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') {
|
||||
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' }"
|
||||
:style="style"
|
||||
@mousedown="emit('select')"
|
||||
>
|
||||
<div 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 ref="contentEl" class="content">
|
||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
|
||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||
<TextBox v-else :obj-id="object.id" />
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}
|
||||
.canvas-object.flex .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.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,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,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
|
||||
const store = useDocument();
|
||||
const draft = ref('');
|
||||
const focused = ref(false);
|
||||
|
||||
// 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 },
|
||||
);
|
||||
|
||||
function commit() {
|
||||
const sel = store.doc.selection;
|
||||
if (sel) store.setCellInput(sel.tableId, sel.focus.col, sel.focus.row, draft.value);
|
||||
}
|
||||
function onEnter() {
|
||||
commit();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
function onBlur() {
|
||||
focused.value = false;
|
||||
commit();
|
||||
}
|
||||
function onEscape() {
|
||||
draft.value = store.activeCellInput.value;
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="formula-bar">
|
||||
<span class="cell-name" :title="store.activeCellName.value">
|
||||
{{ store.activeCellName.value }}
|
||||
</span>
|
||||
<span class="fx">fx</span>
|
||||
<input
|
||||
v-model="draft"
|
||||
class="formula-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder="Wert oder =Formel"
|
||||
@focus="focused = true"
|
||||
@blur="onBlur"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keydown.esc.prevent="onEscape"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cell-name {
|
||||
min-width: 128px;
|
||||
max-width: 128px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.cell-name {
|
||||
min-width: 64px;
|
||||
max-width: 64px;
|
||||
}
|
||||
.formula-bar {
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
.fx {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.formula-input {
|
||||
flex: 1;
|
||||
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);
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,663 @@
|
||||
<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';
|
||||
|
||||
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 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>
|
||||
|
||||
<!-- ---- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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' }" @click="themeMode = 'system'">Auto</button>
|
||||
<button :class="{ on: themeMode === 'light' }" @click="themeMode = 'light'">Hell</button>
|
||||
<button :class="{ on: themeMode === 'dark' }" @click="themeMode = 'dark'">Dunkel</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,204 @@
|
||||
<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 CanvasObject from './CanvasObject.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);
|
||||
|
||||
/** Unscaled plane size: enclose every object with generous margin. */
|
||||
const plane = computed(() => {
|
||||
let w = 1600;
|
||||
let h = 1000;
|
||||
for (const o of sheet.value.objects) {
|
||||
const ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
|
||||
const 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 }"
|
||||
:style="appearance"
|
||||
>
|
||||
<div
|
||||
class="canvas-scroll"
|
||||
:class="{ 'no-grid': !showGrid }"
|
||||
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>
|
||||
<p v-if="sheet.objects.length === 0" class="hint">
|
||||
Leeres Blatt — über die Toolbar „Tabelle" oder „Diagramm" einfügen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="zoom-ctl">
|
||||
<button @click="setZoom(zoom - 0.1)">−</button>
|
||||
<span>{{ Math.round(zoom * 100) }}%</span>
|
||||
<button @click="setZoom(zoom + 0.1)">+</button>
|
||||
<button class="fit" @click="setZoom(1)">100%</button>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop">Datei ablegen (.json .csv .xlsx)</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.18s ease;
|
||||
}
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 320px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 0; /* drawer overlays on narrow screens */
|
||||
}
|
||||
}
|
||||
.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-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-size: 24px 24px;
|
||||
background-position: 0 0;
|
||||
outline: none;
|
||||
}
|
||||
.canvas-scroll.no-grid {
|
||||
background-image: none;
|
||||
}
|
||||
.plane {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.zoom-ctl {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
z-index: 15;
|
||||
}
|
||||
.zoom-ctl button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-ctl button.fit {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.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,161 @@
|
||||
<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;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.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,50 @@
|
||||
<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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-bar">
|
||||
<span class="range">{{ store.activeCellName.value }}</span>
|
||||
<span class="spacer" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.metric b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric.dim {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.range {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,649 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One table object's grid. Hand-built, no grid/spreadsheet widget.
|
||||
*
|
||||
* `view.mode = 'auto'` -> intrinsic size, all cells visible (Numbers default).
|
||||
* `view.mode = 'fixed'` -> fixed viewport, scrolls internally; the first
|
||||
* `frozenRows` / `frozenCols` are pinned with
|
||||
* `position: sticky` against the scroll container.
|
||||
*
|
||||
* Also handles cell selection (click / shift / drag), keyboard navigation,
|
||||
* in-cell editing and per-column resize.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { numberToCol } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import { DEFAULT_ROW_H } from '../store/types';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
|
||||
const props = defineProps<{ tableId: string }>();
|
||||
|
||||
const store = useDocument();
|
||||
const table = computed(() => store.tableById(props.tableId));
|
||||
|
||||
const HEAD_W = 44;
|
||||
const DEFAULT_W = 104;
|
||||
const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
|
||||
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
|
||||
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
|
||||
|
||||
/** px from the table's top edge to the start of row `r` (incl. header). */
|
||||
function rowTop(r: number): number {
|
||||
let y = headerH.value;
|
||||
for (let i = 0; i < r; i++) y += rowH(i);
|
||||
return y;
|
||||
}
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
const t = table.value;
|
||||
const s: Record<string, string> = {};
|
||||
if (t?.bg) s['--surface'] = t.bg;
|
||||
if (t?.fg) {
|
||||
s['--text'] = t.fg;
|
||||
s['--text-dim'] = t.fg;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
/** Explicit cell formatting -> inline style. */
|
||||
function formatStyle(c: number, r: number): Record<string, string> {
|
||||
const f = store.cellFormat(props.tableId, c, r);
|
||||
const s: Record<string, string> = {};
|
||||
if (f.align) s.textAlign = f.align;
|
||||
if (f.valign) s.verticalAlign = { top: 'top', middle: 'middle', bottom: 'bottom' }[f.valign];
|
||||
if (f.color) s.color = f.color;
|
||||
if (f.bg) s.background = f.bg;
|
||||
if (f.bold) s.fontWeight = '600';
|
||||
if (f.italic) s.fontStyle = 'italic';
|
||||
return s;
|
||||
}
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
// ---- fixed viewport + frozen panes -------------------------------
|
||||
const view = computed(() => table.value?.view ?? { mode: 'auto' as const, width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
|
||||
const isFixed = computed(() => view.value.mode === 'fixed');
|
||||
const frozenRows = computed(() => (isFixed.value ? view.value.frozenRows : 0));
|
||||
const frozenCols = computed(() => (isFixed.value ? view.value.frozenCols : 0));
|
||||
|
||||
const scrollStyle = computed(() =>
|
||||
isFixed.value
|
||||
? { width: view.value.width + 'px', height: view.value.height + 'px', overflow: 'auto' }
|
||||
: {},
|
||||
);
|
||||
|
||||
/** px from the table's left edge to the start of column `c` (incl. row header). */
|
||||
function colLeft(c: number): number {
|
||||
let x = HEAD_W;
|
||||
for (let i = 0; i < c; i++) x += colWidth(i);
|
||||
return x;
|
||||
}
|
||||
|
||||
function cellStyle(c: number, r: number): Record<string, string> {
|
||||
const s: Record<string, string> = formatStyle(c, r);
|
||||
if (!isFixed.value) return s;
|
||||
const fr = r < frozenRows.value;
|
||||
const fc = c < frozenCols.value;
|
||||
if (fr) {
|
||||
s.position = 'sticky';
|
||||
s.top = rowTop(r) + 'px';
|
||||
}
|
||||
if (fc) {
|
||||
s.position = 'sticky';
|
||||
s.left = colLeft(c) + 'px';
|
||||
}
|
||||
if (fr || fc) s.zIndex = fr && fc ? '5' : '3';
|
||||
return s;
|
||||
}
|
||||
function rowHeadStyle(r: number): Record<string, string> {
|
||||
if (!isFixed.value || r >= frozenRows.value) return {};
|
||||
return { top: rowTop(r) + 'px', zIndex: '5' };
|
||||
}
|
||||
|
||||
// ---- selection ------------------------------------------------------
|
||||
const selection = computed(() => store.doc.selection);
|
||||
const mine = computed(
|
||||
() => selection.value?.tableId === props.tableId ? selection.value : null,
|
||||
);
|
||||
/** All selected rectangles (primary + any additional) for this table. */
|
||||
const selRects = computed(() => (mine.value ? store.selectionRects(mine.value) : []));
|
||||
const isActive = (c: number, r: number) =>
|
||||
!!mine.value && mine.value.focus.col === c && mine.value.focus.row === r;
|
||||
const inSelection = (c: number, r: number) =>
|
||||
selRects.value.some((k) => c >= k.c0 && c <= k.c1 && r >= k.r0 && r <= k.r1);
|
||||
const colHot = (c: number) => selRects.value.some((k) => c >= k.c0 && c <= k.c1);
|
||||
const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1);
|
||||
|
||||
const clampC = (c: number) => Math.max(0, Math.min((table.value?.cols ?? 1) - 1, c));
|
||||
const clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r));
|
||||
const focusRoot = () => nextTick(() => root.value?.focus());
|
||||
|
||||
function select(c: number, r: number, extend = false) {
|
||||
store.selectCell(props.tableId, clampC(c), clampR(r), extend);
|
||||
}
|
||||
|
||||
// ---- mouse selection ---------------------------------------------
|
||||
const dragging = ref(false);
|
||||
let dragMode: 'replace' | 'additive' = 'replace';
|
||||
let lastHit: { c: number; r: number } | null = null;
|
||||
let lastPointer = { x: 0, y: 0 };
|
||||
let autoScrollRaf = 0;
|
||||
|
||||
const scrollEl = () =>
|
||||
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
|
||||
|
||||
/** Hit-test by cursor position -- robust to fast moves, borders and child
|
||||
* nodes (the event target during a drag is unreliable). Clamps into the
|
||||
* scroll viewport of a fixed table so dragging past the edge still selects. */
|
||||
function cellFromPoint(x: number, y: number): { c: number; r: number } | null {
|
||||
const box = scrollEl();
|
||||
if (box) {
|
||||
const r = box.getBoundingClientRect();
|
||||
x = Math.min(Math.max(x, r.left + 2), r.right - 2);
|
||||
y = Math.min(Math.max(y, r.top + 2), r.bottom - 2);
|
||||
}
|
||||
for (const el of document.elementsFromPoint(x, y)) {
|
||||
const td = (el as HTMLElement).closest?.('td[data-c]') as HTMLElement | null;
|
||||
if (td && root.value?.contains(td)) {
|
||||
const c = Number(td.dataset.c);
|
||||
const rr = Number(td.dataset.r);
|
||||
if (!Number.isNaN(c) && !Number.isNaN(rr)) return { c, r: rr };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function cellFromEvent(e: MouseEvent) {
|
||||
const el = (e.target as HTMLElement)?.closest?.('td[data-c]') as HTMLElement | null;
|
||||
return el ? { c: Number(el.dataset.c), r: Number(el.dataset.r) } : null;
|
||||
}
|
||||
|
||||
function applyHit(hit: { c: number; r: number }) {
|
||||
if (lastHit && lastHit.c === hit.c && lastHit.r === hit.r) return;
|
||||
lastHit = hit;
|
||||
if (dragMode === 'additive') store.extendActiveRange(props.tableId, hit.c, hit.r);
|
||||
else select(hit.c, hit.r, true);
|
||||
}
|
||||
|
||||
/** While dragging near the edge of a fixed table, keep scrolling + selecting. */
|
||||
function autoScrollTick() {
|
||||
const box = scrollEl();
|
||||
if (!dragging.value || !box) {
|
||||
autoScrollRaf = 0;
|
||||
return;
|
||||
}
|
||||
const r = box.getBoundingClientRect();
|
||||
const EDGE = 26;
|
||||
const S = 16;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (lastPointer.y < r.top + EDGE) dy = -S;
|
||||
else if (lastPointer.y > r.bottom - EDGE) dy = S;
|
||||
if (lastPointer.x < r.left + EDGE) dx = -S;
|
||||
else if (lastPointer.x > r.right - EDGE) dx = S;
|
||||
if (dx || dy) {
|
||||
box.scrollBy(dx, dy);
|
||||
const hit = cellFromPoint(lastPointer.x, lastPointer.y);
|
||||
if (hit) applyHit(hit);
|
||||
}
|
||||
autoScrollRaf = requestAnimationFrame(autoScrollTick);
|
||||
}
|
||||
|
||||
function startDrag(mode: 'replace' | 'additive') {
|
||||
dragMode = mode;
|
||||
dragging.value = true;
|
||||
lastHit = null;
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onDragMove, true);
|
||||
window.addEventListener('mouseup', onDragUp, true);
|
||||
window.addEventListener('blur', onDragUp);
|
||||
if (scrollEl() && !autoScrollRaf) autoScrollRaf = requestAnimationFrame(autoScrollTick);
|
||||
}
|
||||
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
|
||||
if (e.button !== 0) return;
|
||||
if (editing.value) commitEdit();
|
||||
lastPointer = { x: e.clientX, y: e.clientY };
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
store.addToSelection(props.tableId, c, r);
|
||||
startDrag('additive');
|
||||
} else if (e.shiftKey) {
|
||||
select(c, r, true);
|
||||
startDrag('replace');
|
||||
} else {
|
||||
select(c, r, false);
|
||||
startDrag('replace');
|
||||
}
|
||||
focusRoot();
|
||||
}
|
||||
function onDragMove(e: MouseEvent) {
|
||||
if (e.buttons === 0) {
|
||||
onDragUp(); // a mouseup we never saw -- self-heal a stuck drag
|
||||
return;
|
||||
}
|
||||
lastPointer = { x: e.clientX, y: e.clientY };
|
||||
const hit = cellFromPoint(e.clientX, e.clientY);
|
||||
if (hit) applyHit(hit);
|
||||
}
|
||||
function onDragUp() {
|
||||
if (!dragging.value) return;
|
||||
dragging.value = false;
|
||||
lastHit = null;
|
||||
if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf);
|
||||
autoScrollRaf = 0;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onDragMove, true);
|
||||
window.removeEventListener('mouseup', onDragUp, true);
|
||||
window.removeEventListener('blur', onDragUp);
|
||||
}
|
||||
|
||||
function selectColumn(c: number, e?: MouseEvent) {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
const a = { col: c, row: 0 };
|
||||
const f = { col: c, row: t.rows - 1 };
|
||||
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
|
||||
else store.setSelection({ tableId: props.tableId, anchor: a, focus: f });
|
||||
focusRoot();
|
||||
}
|
||||
function selectRow(r: number, e?: MouseEvent) {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
const a = { col: 0, row: r };
|
||||
const f = { col: t.cols - 1, row: r };
|
||||
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
|
||||
else store.setSelection({ tableId: props.tableId, anchor: a, focus: f });
|
||||
focusRoot();
|
||||
}
|
||||
function selectAll() {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
|
||||
focusRoot();
|
||||
}
|
||||
|
||||
// ---- editing ---------------------------------------------------
|
||||
const editing = ref<{ col: number; row: number } | null>(null);
|
||||
const editValue = ref('');
|
||||
const editInput = ref<HTMLInputElement | null>(null);
|
||||
const setEditRef = (el: unknown) => {
|
||||
editInput.value = (el as HTMLInputElement) ?? null;
|
||||
};
|
||||
let committing = false;
|
||||
|
||||
function beginEdit(c: number, r: number, initial?: string) {
|
||||
editing.value = { col: c, row: r };
|
||||
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
|
||||
nextTick(() => {
|
||||
const el = editInput.value;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (initial === undefined) el.select();
|
||||
else el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
}
|
||||
function commitEdit(advance: 'down' | 'up' | 'left' | 'right' | 'none' = 'none') {
|
||||
if (!editing.value) return;
|
||||
committing = true;
|
||||
const { col, row } = editing.value;
|
||||
store.setCellInput(props.tableId, col, row, editValue.value);
|
||||
editing.value = null;
|
||||
const d: Record<string, [number, number]> = {
|
||||
down: [0, 1], up: [0, -1], left: [-1, 0], right: [1, 0], none: [0, 0],
|
||||
};
|
||||
const [dc, dr] = d[advance];
|
||||
select(col + dc, row + dr);
|
||||
focusRoot();
|
||||
setTimeout(() => (committing = false), 0);
|
||||
}
|
||||
function cancelEdit() {
|
||||
editing.value = null;
|
||||
focusRoot();
|
||||
}
|
||||
function onEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitEdit(e.shiftKey ? 'up' : 'down');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
commitEdit(e.shiftKey ? 'left' : 'right');
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}
|
||||
function onEditBlur() {
|
||||
if (!committing && editing.value) commitEdit('none');
|
||||
}
|
||||
|
||||
// ---- keyboard navigation -------------------------------------
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (editing.value) return;
|
||||
const s = mine.value;
|
||||
if (!s) return;
|
||||
const { col, row } = s.focus;
|
||||
const ext = e.shiftKey;
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': e.preventDefault(); return select(col, row - 1, ext);
|
||||
case 'ArrowDown': e.preventDefault(); return select(col, row + 1, ext);
|
||||
case 'ArrowLeft': e.preventDefault(); return select(col - 1, row, ext);
|
||||
case 'ArrowRight': e.preventDefault(); return select(col + 1, row, ext);
|
||||
case 'Tab': e.preventDefault(); return select(col + (e.shiftKey ? -1 : 1), row);
|
||||
case 'Enter': e.preventDefault(); return e.shiftKey ? select(col, row - 1) : beginEdit(col, row);
|
||||
case 'F2': e.preventDefault(); return beginEdit(col, row);
|
||||
case 'Backspace':
|
||||
case 'Delete':
|
||||
e.preventDefault();
|
||||
return store.clearSelectionCells();
|
||||
default:
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
beginEdit(col, row, e.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- context menu -----------------------------------------
|
||||
const menu = ref({ x: 0, y: 0, open: false });
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
const hit = cellFromEvent(e);
|
||||
// keep a multi-selection if the click landed inside it; otherwise select the cell
|
||||
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
|
||||
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
||||
}
|
||||
const menuItems = computed(() => [
|
||||
{ label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
|
||||
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
||||
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
||||
{ label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) },
|
||||
{ label: 'Auswahl leeren', action: () => store.clearSelectionCells() },
|
||||
]);
|
||||
|
||||
// ---- column resize --------------------------------------
|
||||
let resize: { col: number; startX: number; startW: number } | null = null;
|
||||
function onResizeDown(e: MouseEvent, c: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resize = { col: c, startX: e.clientX, startW: colWidth(c) };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onResizeMove);
|
||||
window.addEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (resize) store.setColWidth(props.tableId, resize.col, resize.startW + (e.clientX - resize.startX));
|
||||
}
|
||||
function onResizeUp() {
|
||||
resize = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onResizeMove);
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
// ---- row resize (drag the bottom edge of a row header) ----
|
||||
let rrs: { row: number; startY: number; startH: number } | null = null;
|
||||
function onRowResizeDown(e: MouseEvent, r: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
rrs = { row: r, startY: e.clientY, startH: rowH(r) };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onRowResizeMove);
|
||||
window.addEventListener('mouseup', onRowResizeUp);
|
||||
}
|
||||
function onRowResizeMove(e: MouseEvent) {
|
||||
if (rrs) store.setRowHeight(props.tableId, rrs.row, rrs.startH + (e.clientY - rrs.startY));
|
||||
}
|
||||
function onRowResizeUp() {
|
||||
rrs = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onRowResizeMove);
|
||||
window.removeEventListener('mouseup', onRowResizeUp);
|
||||
}
|
||||
|
||||
const totalWidth = computed(() => {
|
||||
const t = table.value;
|
||||
if (!t) return HEAD_W;
|
||||
let w = HEAD_W;
|
||||
for (let c = 0; c < t.cols; c++) w += colWidth(c);
|
||||
return w;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onDragUp();
|
||||
onResizeUp();
|
||||
onRowResizeUp();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus: () => root.value?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="table"
|
||||
ref="root"
|
||||
class="table-grid"
|
||||
:class="{ selecting: dragging, 'tg-fixed': isFixed }"
|
||||
:style="rootStyle"
|
||||
tabindex="0"
|
||||
role="grid"
|
||||
:aria-label="`Tabelle ${table.name}`"
|
||||
@keydown="onKeydown"
|
||||
@contextmenu.prevent="onContextMenu"
|
||||
>
|
||||
<div class="tg-scroll" :style="scrollStyle">
|
||||
<table class="grid" :style="{ width: totalWidth + 'px' }">
|
||||
<colgroup>
|
||||
<col :style="{ width: HEAD_W + 'px' }" />
|
||||
<col v-for="c in table.cols" :key="'c' + (c - 1)" :style="{ width: colWidth(c - 1) + 'px' }" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr :style="{ height: headerH + 'px' }">
|
||||
<th class="corner" @click="selectAll" />
|
||||
<th
|
||||
v-for="c in table.cols"
|
||||
:key="'h' + (c - 1)"
|
||||
class="col-head"
|
||||
:class="{ hot: colHot(c - 1), 'freeze-edge-right': c - 1 === frozenCols - 1 }"
|
||||
:style="c - 1 < frozenCols ? { left: colLeft(c - 1) + 'px', zIndex: '7' } : {}"
|
||||
@click="selectColumn(c - 1, $event)"
|
||||
>
|
||||
{{ numberToCol(c - 1) }}
|
||||
<span class="col-resize" @mousedown="onResizeDown($event, c - 1)" @click.stop />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }">
|
||||
<th
|
||||
class="row-head"
|
||||
:class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }"
|
||||
:style="rowHeadStyle(r - 1)"
|
||||
@click="selectRow(r - 1, $event)"
|
||||
>
|
||||
{{ r }}
|
||||
<span class="row-resize" @mousedown="onRowResizeDown($event, r - 1)" @click.stop />
|
||||
</th>
|
||||
<td
|
||||
v-for="c in table.cols"
|
||||
:key="(c - 1) + ',' + (r - 1)"
|
||||
:data-c="c - 1"
|
||||
:data-r="r - 1"
|
||||
class="cell"
|
||||
:class="{
|
||||
'is-active': isActive(c - 1, r - 1),
|
||||
'in-range': inSelection(c - 1, r - 1),
|
||||
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
|
||||
error: store.cellIsError(tableId, c - 1, r - 1),
|
||||
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
|
||||
'freeze-edge-right': c - 1 === frozenCols - 1,
|
||||
'freeze-edge-bottom': r - 1 === frozenRows - 1,
|
||||
}"
|
||||
:style="cellStyle(c - 1, r - 1)"
|
||||
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
|
||||
@dblclick="beginEdit(c - 1, r - 1)"
|
||||
>
|
||||
<input
|
||||
v-if="editing && editing.col === c - 1 && editing.row === r - 1"
|
||||
:ref="setEditRef"
|
||||
v-model="editValue"
|
||||
class="cell-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
@keydown="onEditKeydown"
|
||||
@blur="onEditBlur"
|
||||
@mousedown.stop
|
||||
/>
|
||||
<template v-else>
|
||||
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
|
||||
<span v-if="isActive(c - 1, r - 1)" class="handle" />
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-grid {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: var(--surface);
|
||||
outline: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* a spreadsheet grid is never text-selectable -- prevents native selection
|
||||
from fighting the JS range selection during a drag */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.tg-scroll {
|
||||
position: relative;
|
||||
}
|
||||
.grid {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* frozen panes -- active only in fixed-viewport mode */
|
||||
.tg-fixed thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
}
|
||||
.tg-fixed .corner {
|
||||
left: 0;
|
||||
z-index: 7;
|
||||
}
|
||||
.tg-fixed .row-head {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
.cell.frozen {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.freeze-edge-right {
|
||||
border-right: 2px solid var(--border) !important;
|
||||
}
|
||||
.freeze-edge-bottom {
|
||||
border-bottom: 2px solid var(--border) !important;
|
||||
}
|
||||
.grid th {
|
||||
background: var(--header-bg);
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
border-right: 1px solid var(--grid-line);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.corner {
|
||||
cursor: pointer;
|
||||
border-top-left-radius: 6px;
|
||||
}
|
||||
.col-head {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-head {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hot {
|
||||
background: var(--accent-header);
|
||||
color: var(--text);
|
||||
}
|
||||
.col-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
.row-resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
.cell {
|
||||
padding: 0 7px;
|
||||
border-right: 1px solid var(--grid-line);
|
||||
border-bottom: 1px solid var(--grid-line);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: var(--surface);
|
||||
vertical-align: middle;
|
||||
cursor: cell;
|
||||
position: relative;
|
||||
}
|
||||
.cell.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
.cell.error {
|
||||
color: var(--danger);
|
||||
text-align: right;
|
||||
}
|
||||
.cell.in-range {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.cell.is-active {
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
.handle {
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--surface);
|
||||
border-radius: 2px;
|
||||
z-index: 6;
|
||||
}
|
||||
.cell-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--accent);
|
||||
padding: 0 5px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
outline: none;
|
||||
z-index: 7;
|
||||
}
|
||||
</style>
|
||||
@@ -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,33 @@
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
type Mode = 'system' | 'light' | 'dark';
|
||||
|
||||
const KEY = 'numbers-study:theme';
|
||||
const stored = (localStorage.getItem(KEY) as Mode | null) ?? 'system';
|
||||
const mode = ref<Mode>(stored);
|
||||
|
||||
watchEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode.value === 'system') root.removeAttribute('data-theme');
|
||||
else root.setAttribute('data-theme', mode.value);
|
||||
try {
|
||||
localStorage.setItem(KEY, mode.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
/** True when the effective theme is dark (used to recolour Chart.js). */
|
||||
function isDarkNow(): boolean {
|
||||
if (mode.value === 'dark') return true;
|
||||
if (mode.value === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
function cycle() {
|
||||
mode.value =
|
||||
mode.value === 'system' ? 'light' : mode.value === 'light' ? 'dark' : 'system';
|
||||
}
|
||||
return { mode, cycle, isDarkNow };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
const state = reactive<{ items: Toast[] }>({ items: [] });
|
||||
let seq = 0;
|
||||
|
||||
export function useToasts() {
|
||||
function push(kind: Toast['kind'], message: string, ttl = 4000) {
|
||||
const id = ++seq;
|
||||
state.items.push({ id, kind, message });
|
||||
if (ttl > 0) setTimeout(() => dismiss(id), ttl);
|
||||
return id;
|
||||
}
|
||||
function dismiss(id: number) {
|
||||
const i = state.items.findIndex((t) => t.id === id);
|
||||
if (i >= 0) state.items.splice(i, 1);
|
||||
}
|
||||
return {
|
||||
toasts: state.items,
|
||||
info: (m: string) => push('info', m),
|
||||
success: (m: string) => push('success', m),
|
||||
error: (m: string) => push('error', m, 6000),
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The pre-filled document ("roter Faden" of the study), in the v2 canvas model:
|
||||
* one sheet ("Analyse") carrying three table objects and a chart object, plus a
|
||||
* second empty sheet to try things out.
|
||||
*
|
||||
* Note on the brief: the acceptance list mentions "Übersicht!B2 = 6070" as the
|
||||
* yearly Nord revenue, but the given Nord row is 1200 + 1450 + 1600 + 1720 =
|
||||
* 5970. The explicit table wins -- the exact number is deterministic and the
|
||||
* propagation behaviour it demonstrates is unchanged.
|
||||
*/
|
||||
|
||||
import { parseA1 } from '../engine';
|
||||
import type { ChartObject, DocumentState, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
|
||||
let uid = 0;
|
||||
const nextId = (p: string) => `${p}-seed-${(uid++).toString(36)}`;
|
||||
|
||||
function makeTable(
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
rows: number,
|
||||
cols: number,
|
||||
entries: Array<[string, string | number]>,
|
||||
colWidths: Record<number, number> = {},
|
||||
viewOverride: Partial<TableObject['view']> = {},
|
||||
): TableObject {
|
||||
const cells: Record<string, string> = {};
|
||||
for (const [a1, value] of entries) {
|
||||
const { col, row } = parseA1(a1);
|
||||
cells[`${col},${row}`] = String(value);
|
||||
}
|
||||
return {
|
||||
kind: 'table',
|
||||
id: nextId('tbl'),
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
rows,
|
||||
cols,
|
||||
colWidths,
|
||||
cells,
|
||||
view: { ...defaultTableView(), ...viewOverride },
|
||||
};
|
||||
}
|
||||
|
||||
export function createSeedDocument(): DocumentState {
|
||||
const umsatz = makeTable('Umsatz', 40, 40, 20, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 1200], ['C2', 1450], ['D2', 1600], ['E2', 1720],
|
||||
['A3', 'Süd'], ['B3', 980], ['C3', 1100], ['D3', 1250], ['E3', 1310],
|
||||
['A4', 'Ost'], ['B4', 1550], ['C4', 1480], ['D4', 1620], ['E4', 1700],
|
||||
['A5', 'West'], ['B5', 1340], ['C5', 1390], ['D5', 1500], ['E5', 1580],
|
||||
['A6', 'Zentral'], ['B6', 1105], ['C6', 1205], ['D6', 1298], ['E6', 1402],
|
||||
['A8', 'Summe'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'], ['D8', '=SUM(D2:D6)'], ['E8', '=SUM(E2:E6)'],
|
||||
['A9', 'Mittelwert'],
|
||||
['B9', '=AVG(B2:B6)'], ['C9', '=AVG(C2:C6)'], ['D9', '=AVG(D2:D6)'], ['E9', '=AVG(E2:E6)'],
|
||||
['A10', 'Minimum'],
|
||||
['B10', '=MIN(B2:B6)'], ['C10', '=MIN(C2:C6)'], ['D10', '=MIN(D2:D6)'], ['E10', '=MIN(E2:E6)'],
|
||||
['A11', 'Maximum'],
|
||||
['B11', '=MAX(B2:B6)'], ['C11', '=MAX(C2:C6)'], ['D11', '=MAX(D2:D6)'], ['E11', '=MAX(E2:E6)'],
|
||||
['A12', 'Anzahl'],
|
||||
['B12', '=COUNT(B2:B6)'], ['C12', '=COUNT(C2:C6)'], ['D12', '=COUNT(D2:D6)'], ['E12', '=COUNT(E2:E6)'],
|
||||
['G2', 'Jahr Nord'], ['H2', '=SUM(B2:E2)'],
|
||||
['G3', 'Schnitt/Q'], ['H3', '=SUM(B2:E2)/4'],
|
||||
['G4', 'Delta Q4-Q1'], ['H4', '=E2-B2'],
|
||||
], { 0: 92, 6: 96 }, { mode: 'fixed', width: 545, height: 360, frozenRows: 1, frozenCols: 1 });
|
||||
|
||||
const kosten = makeTable('Kosten', 40, 700, 12, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 800], ['C2', 820], ['D2', 900], ['E2', 880],
|
||||
['A3', 'Süd'], ['B3', 700], ['C3', 690], ['D3', 710], ['E3', 720],
|
||||
['A4', 'Ost'], ['B4', 900], ['C4', 910], ['D4', 950], ['E4', 980],
|
||||
['A5', 'West'], ['B5', 760], ['C5', 770], ['D5', 780], ['E5', 790],
|
||||
['A6', 'Zentral'], ['B6', 640], ['C6', 650], ['D6', 660], ['E6', 670],
|
||||
], { 0: 92 });
|
||||
|
||||
const regions = ['Nord', 'Süd', 'Ost', 'West', 'Zentral'];
|
||||
const overview: Array<[string, string | number]> = [
|
||||
['A1', 'Region'], ['B1', 'Umsatz Jahr'], ['C1', 'Kosten Jahr'],
|
||||
['D1', 'Deckungsbeitrag'], ['E1', 'Marge %'],
|
||||
];
|
||||
regions.forEach((region, i) => {
|
||||
const r = i + 2;
|
||||
overview.push(
|
||||
[`A${r}`, region],
|
||||
[`B${r}`, `=SUM(Umsatz!B${r}:E${r})`],
|
||||
[`C${r}`, `=SUM(Kosten!B${r}:E${r})`],
|
||||
[`D${r}`, `=B${r}-C${r}`],
|
||||
[`E${r}`, `=D${r}/B${r}*100`],
|
||||
);
|
||||
});
|
||||
overview.push(
|
||||
['A8', 'Gesamt'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'],
|
||||
['D8', '=SUM(D2:D6)'], ['E8', '=AVG(E2:E6)'],
|
||||
);
|
||||
const uebersicht = makeTable('Übersicht', 1000, 40, 12, 6, overview, {
|
||||
0: 84, 3: 118,
|
||||
});
|
||||
|
||||
const chart: ChartObject = {
|
||||
kind: 'chart',
|
||||
id: nextId('cht'),
|
||||
name: 'Umsatz je Region',
|
||||
x: 1000,
|
||||
y: 460,
|
||||
width: 560,
|
||||
height: 340,
|
||||
view: 'bar',
|
||||
sourceTableId: umsatz.id,
|
||||
};
|
||||
|
||||
const analyse = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Analyse',
|
||||
objects: [umsatz, kosten, uebersicht, chart],
|
||||
};
|
||||
// second sheet demos the per-sheet backdrop colour + hidden grid
|
||||
const blatt2 = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Blatt 2',
|
||||
objects: [],
|
||||
background: '#f4f1ff',
|
||||
showGrid: false,
|
||||
};
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: [analyse, blatt2],
|
||||
activeSheetId: analyse.id,
|
||||
activeObjectId: umsatz.id,
|
||||
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Formel-Engine
|
||||
|
||||
Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kein
|
||||
`formula-parser`, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.
|
||||
|
||||
Die adressierbare Einheit ist eine **Tabelle** – ein Tabellen-Objekt auf einer
|
||||
Numbers-artigen Blattfläche. Tabellennamen sind dokumentweit eindeutig, deshalb
|
||||
löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
|
||||
|
||||
## Module
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|----------------|---------|
|
||||
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
|
||||
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
|
||||
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)`, Spaltenbuchstaben, Bereichs-Expansion. |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
|
||||
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
|
||||
Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
|
||||
`recalcTick`-Ref – dadurch verhält sich jede Formelzelle im UI wie ein `computed`.
|
||||
|
||||
## Grammatik (EBNF)
|
||||
|
||||
```ebnf
|
||||
formula = "=" expression ;
|
||||
|
||||
expression = term { ("+" | "-") term } ; (* links-assoziativ *)
|
||||
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
||||
unary = ("+" | "-") unary
|
||||
| power ;
|
||||
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
||||
stärker als unäres Minus:
|
||||
-2^2 = -(2^2) = -4 *)
|
||||
primary = NUMBER
|
||||
| STRING
|
||||
| "(" expression ")"
|
||||
| funcall
|
||||
| reference ;
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||
Numbers-Stil "::" sind
|
||||
austauschbar *)
|
||||
|
||||
NUMBER = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/ | /"." [0-9]+/ ;
|
||||
STRING = /" ... "/ (Backslash-Escapes) ;
|
||||
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
|
||||
QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *)
|
||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
```
|
||||
|
||||
Präzedenz, schwach → stark: `+ -` < `* /` < unäres `+ -` < `^`.
|
||||
|
||||
`$` in Referenzen wird toleriert, aber ignoriert (es gibt kein Kopieren/Füllen,
|
||||
alle Bezüge sind relativ). Tabellennamen werden **case-insensitiv** und
|
||||
whitespace-tolerant aufgelöst: `Kosten!A2`, `'Kosten'!A2`, `kosten!a2`,
|
||||
`Kosten::A2` sind identisch.
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
||||
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
|
||||
gefundene Fehler gewinnt).
|
||||
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
|
||||
ergibt er `#VALUE!`.
|
||||
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
|
||||
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
|
||||
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
|
||||
|
||||
## Neuberechnung
|
||||
|
||||
1. `setCell` reparst **eine** Zelle, aktualisiert ihre Graph-Kanten.
|
||||
2. `graph.affected(dirty)` = geänderte Zellen + alle transitiven Abhängigen.
|
||||
3. `graph.topoOrder(affected)` liefert eine sichere Auswertungsreihenfolge
|
||||
(Präzedenzen zuerst) **und** die Menge der Zellen auf einem Zyklus.
|
||||
4. Zyklus-Zellen bekommen direkt `#CIRC`; der Rest wird in topologischer
|
||||
Reihenfolge ausgewertet. Nicht betroffene Zellen behalten ihren Wert
|
||||
(„nur betroffene Zellen neu rechnen“).
|
||||
|
||||
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
||||
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|
||||
## Fehlermodell
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|------------|-----------|
|
||||
| `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`) |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern,
|
||||
`Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle.
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula } from '../parser';
|
||||
import { evaluate } from '../evaluator';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name. */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
S2: { cols: 10, rows: 10 },
|
||||
};
|
||||
const idFor = (name: string): string | null => {
|
||||
const n = name.trim().toLowerCase();
|
||||
if (n === 's1') return 'S1';
|
||||
if (n === 's2') return 'S2';
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
const v = data[tableId]?.[cell];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ev = (src: string, host: EngineHost) => evaluate(parseFormula(src), host);
|
||||
|
||||
describe('evaluator arithmetic', () => {
|
||||
const host = makeHost({ S1: { B2: 10, C2: 5, D2: 8 } });
|
||||
|
||||
it('respects precedence and associativity', () => {
|
||||
expect(ev('=(B2+C2)*2-D2/4', host)).toBe(28); // (15)*2 - 2
|
||||
expect(ev('=2^3^2', host)).toBe(512);
|
||||
expect(ev('=-2^2', host)).toBe(-4);
|
||||
expect(ev('=2+3*4', host)).toBe(14);
|
||||
});
|
||||
|
||||
it('treats an empty cell as 0', () => {
|
||||
expect(ev('=B2+Z9', host)).toBe(10);
|
||||
});
|
||||
|
||||
it('division by zero -> #DIV/0!', () => {
|
||||
expect(ev('=1/0', host)).toEqual(new FormulaError('#DIV/0!'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator functions', () => {
|
||||
const host = makeHost({
|
||||
S1: { B2: 1200, B3: 980, B4: 1550, B5: 1340, B6: 1105 },
|
||||
});
|
||||
|
||||
it('SUM / AVG / MIN / MAX / COUNT over a range', () => {
|
||||
expect(ev('=SUM(B2:B6)', host)).toBe(6175);
|
||||
expect(ev('=AVG(B2:B6)', host)).toBe(1235);
|
||||
expect(ev('=MIN(B2:B6)', host)).toBe(980);
|
||||
expect(ev('=MAX(B2:B6)', host)).toBe(1550);
|
||||
expect(ev('=COUNT(B2:B6)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVERAGE is an alias of AVG', () => {
|
||||
expect(ev('=AVERAGE(B2:B6)', host)).toBe(1235);
|
||||
});
|
||||
|
||||
it('mixes ranges, literals and single cells', () => {
|
||||
expect(ev('=SUM(B2:B3; 100; B4)', host)).toBe(1200 + 980 + 100 + 1550);
|
||||
});
|
||||
|
||||
it('COUNT ignores empty cells', () => {
|
||||
expect(ev('=COUNT(B2:B10)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVG of no numbers -> #DIV/0!', () => {
|
||||
const r = ev('=AVG(Z1:Z5)', host);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('unknown function -> #NAME?', () => {
|
||||
const r = ev('=FOO(1)', host);
|
||||
expect(isError(r) && r.code).toBe('#NAME?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator cross-sheet & errors', () => {
|
||||
const host = makeHost({
|
||||
S1: { A1: 3 },
|
||||
S2: { A1: 7, B1: 100 },
|
||||
});
|
||||
|
||||
it('resolves Name!Ref and is case-insensitive', () => {
|
||||
expect(ev('=S2!A1 + S2!B1', host)).toBe(107);
|
||||
expect(ev('=s2!a1', host)).toBe(7);
|
||||
});
|
||||
|
||||
it('accepts "::" the same as "!"', () => {
|
||||
expect(ev('=S2::A1 + S2::B1', host)).toBe(107);
|
||||
expect(ev('=s2::a1', host)).toBe(ev('=S2!A1', host));
|
||||
});
|
||||
|
||||
it('unknown sheet -> #REF', () => {
|
||||
const r = ev('=Nix!A1', host);
|
||||
expect(isError(r) && r.code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('propagates an error from a referenced cell', () => {
|
||||
const h = makeHost({ S1: { A1: new FormulaError('#DIV/0!') } });
|
||||
const r = ev('=A1 + 1', h);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
const r2 = ev('=SUM(A1; 5)', h);
|
||||
expect(isError(r2) && r2.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('a bare range in scalar position -> #VALUE!', () => {
|
||||
const r = ev('=A1:A3 + 1', host);
|
||||
expect(isError(r) && r.code).toBe('#VALUE!');
|
||||
});
|
||||
|
||||
it('text in arithmetic -> #VALUE!, numeric text is coerced', () => {
|
||||
const h = makeHost({ S1: { A1: 'abc', A2: '12' } });
|
||||
expect(isError(ev('=A1*2', h))).toBe(true);
|
||||
expect(ev('=A2*2', h)).toBe(24);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DependencyGraph } from '../graph';
|
||||
|
||||
/**
|
||||
* Helper: build a graph from "cell <- precedent, precedent" lines.
|
||||
* Node names are arbitrary strings here (real code uses `keyOf`).
|
||||
*/
|
||||
function build(edges: Record<string, string[]>): DependencyGraph {
|
||||
const g = new DependencyGraph();
|
||||
for (const [cell, precs] of Object.entries(edges)) g.setPrecedents(cell, precs);
|
||||
return g;
|
||||
}
|
||||
|
||||
describe('DependencyGraph.affected', () => {
|
||||
it('returns dirty cells plus their transitive dependents', () => {
|
||||
// A -> B -> C ; A -> D
|
||||
const g = build({ B: ['A'], C: ['B'], D: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B', 'C', 'D']);
|
||||
expect([...g.affected(['B'])].sort()).toEqual(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DependencyGraph.topoOrder', () => {
|
||||
it('orders precedents before dependents', () => {
|
||||
const g = build({ B: ['A'], C: ['A', 'B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect(circular.size).toBe(0);
|
||||
const pos = (n: string) => order.indexOf(n);
|
||||
expect(pos('A')).toBeLessThan(pos('B'));
|
||||
expect(pos('B')).toBeLessThan(pos('C'));
|
||||
expect(pos('C')).toBeLessThan(pos('D'));
|
||||
});
|
||||
|
||||
it('detects a direct self-reference', () => {
|
||||
const g = build({ A: ['A'] });
|
||||
const { circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular]).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('detects a 2-cycle and flags both nodes', () => {
|
||||
const g = build({ A: ['B'], B: ['A'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B']);
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects a longer cycle, leaves acyclic tail evaluable', () => {
|
||||
// cycle A->B->C->A ; plus D reads C (D is not on the cycle)
|
||||
const g = build({ A: ['C'], B: ['A'], C: ['B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B', 'C']);
|
||||
expect(order).toEqual(['D']);
|
||||
});
|
||||
|
||||
it('rewiring precedents removes stale edges', () => {
|
||||
const g = build({ B: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B']);
|
||||
g.setPrecedents('B', []); // B no longer reads A
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tokenize } from '../lexer';
|
||||
|
||||
const types = (s: string) => tokenize(s).map((t) => t.type);
|
||||
const values = (s: string) => tokenize(s).map((t) => t.value);
|
||||
|
||||
describe('lexer', () => {
|
||||
it('tokenizes numbers incl. decimals and exponents', () => {
|
||||
expect(values('1 2.5 .5 1e3 1.2E-3')).toEqual([
|
||||
'1',
|
||||
'2.5',
|
||||
'.5',
|
||||
'1e3',
|
||||
'1.2E-3',
|
||||
'',
|
||||
]);
|
||||
expect(types('42')).toEqual(['NUMBER', 'EOF']);
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
'SEMICOLON',
|
||||
'BANG',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads bare words as IDENT (cell refs, functions, sheet names alike)', () => {
|
||||
expect(types('SUM(A1:B2)')).toEqual([
|
||||
'IDENT',
|
||||
'LPAREN',
|
||||
'IDENT',
|
||||
'COLON',
|
||||
'IDENT',
|
||||
'RPAREN',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads single-quoted sheet names with spaces', () => {
|
||||
const t = tokenize("'Kosten Q3'!B2");
|
||||
expect(t[0]).toMatchObject({ type: 'QUOTED', value: 'Kosten Q3' });
|
||||
expect(t[1].type).toBe('BANG');
|
||||
expect(t[2]).toMatchObject({ type: 'IDENT', value: 'B2' });
|
||||
});
|
||||
|
||||
it('handles escaped double-quoted strings', () => {
|
||||
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
|
||||
});
|
||||
|
||||
it('skips whitespace', () => {
|
||||
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, collectRefs, ParseError, type Node } from '../parser';
|
||||
|
||||
/** Compact s-expression rendering of an AST for precedence assertions. */
|
||||
function sexpr(n: Node): string {
|
||||
switch (n.kind) {
|
||||
case 'number':
|
||||
return String(n.value);
|
||||
case 'string':
|
||||
return JSON.stringify(n.value);
|
||||
case 'unary':
|
||||
return `(${n.op} ${sexpr(n.operand)})`;
|
||||
case 'binary':
|
||||
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
|
||||
case 'func':
|
||||
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
|
||||
case 'cell':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
case 'range':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
|
||||
describe('parser precedence & associativity', () => {
|
||||
it('* / bind tighter than + -', () => {
|
||||
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
||||
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
|
||||
});
|
||||
|
||||
it('+ - are left-associative', () => {
|
||||
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
|
||||
});
|
||||
|
||||
it('^ binds tighter than * / and is right-associative', () => {
|
||||
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
|
||||
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
|
||||
});
|
||||
|
||||
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
|
||||
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
|
||||
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
|
||||
});
|
||||
|
||||
it('parentheses override precedence', () => {
|
||||
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
|
||||
expect(parse('=(B2+C2)*2-D2/4')).toBe(
|
||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser references', () => {
|
||||
it('parses local cells and ranges', () => {
|
||||
expect(parse('=A1')).toBe('cell(0,0)');
|
||||
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
|
||||
});
|
||||
|
||||
it('parses table-qualified refs, bare and quoted', () => {
|
||||
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
});
|
||||
|
||||
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
|
||||
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
|
||||
});
|
||||
|
||||
it('parses function calls with ; separator and mixed args', () => {
|
||||
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
|
||||
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
|
||||
);
|
||||
});
|
||||
|
||||
it('collectRefs finds every reference', () => {
|
||||
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
|
||||
expect(refs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser errors', () => {
|
||||
it('throws ParseError on a dangling operator', () => {
|
||||
expect(() => parseFormula('=A1+')).toThrow(ParseError);
|
||||
});
|
||||
it('throws on an unbalanced paren', () => {
|
||||
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
||||
});
|
||||
it('throws on a non-cell identifier used as a reference', () => {
|
||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
|
||||
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
|
||||
function fixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A2: 'Nord', B2: '1200', C2: '1450', D2: '1600', E2: '1720',
|
||||
A3: 'Süd', B3: '980', C3: '1100', D3: '1250', E3: '1310',
|
||||
A4: 'Ost', B4: '1550', C4: '1480', D4: '1620', E4: '1700',
|
||||
A5: 'West', B5: '1340', C5: '1390', D5: '1500', E5: '1580',
|
||||
A6: 'Zentral', B6: '1105', C6: '1205', D6: '1298', E6: '1402',
|
||||
B8: '=SUM(B2:B6)', B9: '=AVG(B2:B6)', B10: '=MIN(B2:B6)',
|
||||
B11: '=MAX(B2:B6)', B12: '=COUNT(B2:B6)',
|
||||
H2: '=SUM(B2:E2)', H3: '=SUM(B2:E2)/4', H4: '=E2-B2',
|
||||
});
|
||||
const kosten = sheet('s-kosten', 'Kosten', {
|
||||
A2: 'Nord', B2: '800', C2: '820', D2: '900', E2: '880',
|
||||
});
|
||||
const uebersicht = sheet('s-ueber', 'Übersicht', {
|
||||
B2: '=SUM(Umsatz!B2:E2)', C2: '=SUM(Kosten!B2:E2)',
|
||||
D2: '=B2-C2', E2: '=D2/B2*100',
|
||||
B8: '=SUM(B2:B6)', D8: '=SUM(D2:D6)',
|
||||
});
|
||||
return new Workbook([umsatz, kosten, uebersicht]);
|
||||
}
|
||||
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
describe('Workbook aggregates', () => {
|
||||
const wb = fixture();
|
||||
it('SUM/AVG/MIN/MAX/COUNT of Umsatz!B2:B6', () => {
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(6175);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1235);
|
||||
expect(val(wb, 's-umsatz', 'B10')).toBe(980);
|
||||
expect(val(wb, 's-umsatz', 'B11')).toBe(1550);
|
||||
expect(val(wb, 's-umsatz', 'B12')).toBe(5);
|
||||
});
|
||||
it('row helpers', () => {
|
||||
expect(val(wb, 's-umsatz', 'H2')).toBe(5970); // 1200+1450+1600+1720
|
||||
expect(val(wb, 's-umsatz', 'H3')).toBe(1492.5);
|
||||
expect(val(wb, 's-umsatz', 'H4')).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook cross-sheet', () => {
|
||||
it('resolves Name!Range across sheets', () => {
|
||||
const wb = fixture();
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
expect(val(wb, 's-ueber', 'C2')).toBe(3400);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(2570);
|
||||
expect(val(wb, 's-ueber', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
|
||||
});
|
||||
|
||||
it('editing Umsatz!B2 propagates to Übersicht B2/D2/E2 and the Gesamt row', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '2200'); // B2: 1200 -> 2200 (+1000)
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(7175);
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(6970);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(3570);
|
||||
expect(val(wb, 's-ueber', 'B8')).toBe(6970); // only row 2 populated in fixture
|
||||
expect(val(wb, 's-ueber', 'D8')).toBe(3570);
|
||||
});
|
||||
|
||||
it('is tolerant about sheet-name case and quoting', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-kosten', 0, 8, "='Kosten'!B2"); // A9 = 'Kosten'!B2
|
||||
wb.setCell('s-kosten', 1, 8, '=kosten!b2'); // B9 lower-case
|
||||
expect(val(wb, 's-kosten', 'A9')).toBe(800);
|
||||
expect(val(wb, 's-kosten', 'B9')).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook error values', () => {
|
||||
const wb = fixture();
|
||||
it('classifies each error kind', () => {
|
||||
wb.setCell('s-umsatz', 6, 20, '=1/0');
|
||||
wb.setCell('s-umsatz', 6, 21, '=FOO(1)');
|
||||
wb.setCell('s-umsatz', 6, 22, '=A1+');
|
||||
wb.setCell('s-umsatz', 6, 23, '=Nix!A1');
|
||||
expect((val(wb, 's-umsatz', 'G21') as FormulaError).code).toBe('#DIV/0!');
|
||||
expect((val(wb, 's-umsatz', 'G22') as FormulaError).code).toBe('#NAME?');
|
||||
expect((val(wb, 's-umsatz', 'G23') as FormulaError).code).toBe('#ERR');
|
||||
expect((val(wb, 's-umsatz', 'G24') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('detects a direct circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, '=A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('detects a cross-sheet circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, "='Kosten'!Z9");
|
||||
wb2.setCell('s-kosten', 25, 8, '=Umsatz!A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
expect((val(wb2, 's-kosten', 'Z9') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('errors propagate to dependents', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 1, 1, '=1/0');
|
||||
expect(isError(val(wb2, 's-umsatz', 'B8'))).toBe(true);
|
||||
expect(isError(val(wb2, 's-ueber', 'B2'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook structural changes', () => {
|
||||
it('deleting a referenced sheet turns dependents into #REF', () => {
|
||||
const wb = fixture();
|
||||
wb.tables = wb.tables.filter((t) => t.id !== 's-kosten');
|
||||
wb.rebuild();
|
||||
expect((val(wb, 's-ueber', 'C2') as FormulaError).code).toBe('#REF');
|
||||
// unrelated cells still fine
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
// chain: Umsatz!B2 -> Übersicht!B2 -> Übersicht!D2 -> Übersicht!E2
|
||||
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 1, 1)]);
|
||||
expect(circular).toEqual([]);
|
||||
const pos = (k: string) => order.indexOf(k);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeGreaterThan(-1);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeLessThan(pos(keyOf('s-ueber', 3, 1)));
|
||||
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AST evaluator.
|
||||
*
|
||||
* `evaluate(ast, host)` walks the tree and returns a {@link CellValue}. It never
|
||||
* recurses into other cells directly -- it asks the {@link EngineHost} for their
|
||||
* *already computed* values, which the recalculation driver guarantees are ready
|
||||
* (topological order). Circular cells are detected up front by the graph module
|
||||
* and never reach the evaluator.
|
||||
*
|
||||
* Evaluation model
|
||||
* ----------------
|
||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||
* string is coerced; any other string yields `#VALUE!`.
|
||||
* - Any error among operands / arguments propagates unchanged (first wins).
|
||||
* - A range may only appear as a function argument. Used in scalar position it
|
||||
* yields `#VALUE!`.
|
||||
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
|
||||
* - Division by zero -> `#DIV/0!`.
|
||||
*/
|
||||
|
||||
import type { Node } from './parser';
|
||||
import { getFunction } from './functions';
|
||||
import { expandRange } from './refs';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
|
||||
|
||||
export function evaluate(ast: Node, host: EngineHost): CellValue {
|
||||
return evalScalar(ast, host);
|
||||
}
|
||||
|
||||
function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
switch (node.kind) {
|
||||
case 'number':
|
||||
return node.value;
|
||||
case 'string':
|
||||
return node.value;
|
||||
|
||||
case 'unary': {
|
||||
const v = toNumber(evalScalar(node.operand, host));
|
||||
if (isError(v)) return v;
|
||||
return node.op === '-' ? -v : v;
|
||||
}
|
||||
|
||||
case 'binary': {
|
||||
if (node.op === '^') {
|
||||
const l = toNumber(evalScalar(node.left, host));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
if (isError(r)) return r;
|
||||
return Math.pow(l, r);
|
||||
}
|
||||
const l = toNumber(evalScalar(node.left, host));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
if (isError(r)) return r;
|
||||
switch (node.op) {
|
||||
case '+':
|
||||
return l + r;
|
||||
case '-':
|
||||
return l - r;
|
||||
case '*':
|
||||
return l * r;
|
||||
case '/':
|
||||
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
||||
}
|
||||
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
||||
}
|
||||
|
||||
case 'cell': {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return host.getComputed(tableId, node.col, node.row);
|
||||
}
|
||||
|
||||
case 'range':
|
||||
// A bare range can't collapse to a scalar.
|
||||
return new FormulaError('#VALUE!', 'range used where a value is expected');
|
||||
|
||||
case 'func': {
|
||||
const fn = getFunction(node.name);
|
||||
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
|
||||
const values: CellValue[] = [];
|
||||
for (const arg of node.args) {
|
||||
const part = evalArg(arg, host);
|
||||
if (isError(part)) return part;
|
||||
for (const v of part) {
|
||||
if (isError(v)) return v;
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
return fn(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate one function argument into a flat list of values (ranges expand). */
|
||||
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
if (node.kind === 'range') {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return expandRange(node.start, node.end).map((c) => host.getComputed(tableId, c.col, c.row));
|
||||
}
|
||||
const v = evalScalar(node, host);
|
||||
return [v];
|
||||
}
|
||||
|
||||
/** Coerce a cell value to a number for arithmetic, or return an error value. */
|
||||
function toNumber(v: CellValue): number | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
if (v === null) return 0;
|
||||
if (typeof v === 'number') return v;
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') return 0;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Built-in functions: a plain table { NAME -> fn(values) }.
|
||||
*
|
||||
* By the time a function runs, the evaluator has already:
|
||||
* - resolved every range argument into a flat list of cell values,
|
||||
* - flattened scalar arguments into that same list,
|
||||
* - checked for errors among the values (an error short-circuits and is
|
||||
* returned instead of calling the function).
|
||||
*
|
||||
* So a function only ever sees `(number | string | null)[]`. Each function
|
||||
* decides for itself how to treat text and empty cells. Per spec:
|
||||
* - empty cell -> ignored by every aggregate, never counted by COUNT
|
||||
* - text -> ignored by the numeric aggregates
|
||||
*/
|
||||
|
||||
import { FormulaError, type CellValue } from './types';
|
||||
|
||||
export type FunctionFn = (values: CellValue[]) => CellValue;
|
||||
|
||||
/** Keep only real numbers (finite). */
|
||||
function nums(values: CellValue[]): number[] {
|
||||
const out: number[] = [];
|
||||
for (const v of values) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
SUM(values) {
|
||||
return nums(values).reduce((a, b) => a + b, 0);
|
||||
},
|
||||
|
||||
AVG(values) {
|
||||
const n = nums(values);
|
||||
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
|
||||
return n.reduce((a, b) => a + b, 0) / n.length;
|
||||
},
|
||||
|
||||
MIN(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.min(...n) : 0;
|
||||
},
|
||||
|
||||
MAX(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.max(...n) : 0;
|
||||
},
|
||||
|
||||
COUNT(values) {
|
||||
return nums(values).length;
|
||||
},
|
||||
};
|
||||
|
||||
// AVERAGE is an alias of AVG.
|
||||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||||
|
||||
export function hasFunction(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
||||
}
|
||||
|
||||
export function getFunction(name: string): FunctionFn | undefined {
|
||||
return FUNCTIONS[name.toUpperCase()];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Dependency graph over *all* sheets.
|
||||
*
|
||||
* Nodes are cell keys (see `keyOf` in ./types). An edge P -> C means
|
||||
* "cell C reads cell P" (P is a precedent of C, C is a dependent of P).
|
||||
*
|
||||
* The graph offers two things the recalculation driver needs:
|
||||
* 1. `affected(dirty)` -- every cell that must be revisited after `dirty`
|
||||
* changed (the dirty cells plus their transitive dependents).
|
||||
* 2. `topoOrder(nodes)` -- a safe evaluation order for a set of nodes, plus
|
||||
* the subset that sits on a dependency cycle (`#CIRC`).
|
||||
*
|
||||
* Circular references -- including cross-sheet ones -- are found by an iterative
|
||||
* DFS with white/grey/black colouring. When a back-edge to a grey node is seen,
|
||||
* every node currently on the DFS stack from that node onwards is flagged
|
||||
* circular. Flagged nodes are dropped from the returned order; the driver writes
|
||||
* `#CIRC` into them directly and their dependents pick that up through normal
|
||||
* error propagation.
|
||||
*/
|
||||
|
||||
export class DependencyGraph {
|
||||
/** precedents: key -> set of cells it reads */
|
||||
private readonly precedents = new Map<string, Set<string>>();
|
||||
/** dependents: key -> set of cells that read it */
|
||||
private readonly dependents = new Map<string, Set<string>>();
|
||||
|
||||
/** Replace the precedent set of `cell` (called after (re)parsing a formula). */
|
||||
setPrecedents(cell: string, precedents: Iterable<string>): void {
|
||||
// unlink old edges
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) {
|
||||
for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
}
|
||||
// Keep a self-reference if present: `=A1` inside A1 must surface as #CIRC.
|
||||
const next = new Set(precedents);
|
||||
this.precedents.set(cell, next);
|
||||
for (const p of next) {
|
||||
let d = this.dependents.get(p);
|
||||
if (!d) this.dependents.set(p, (d = new Set()));
|
||||
d.add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a cell entirely (e.g. it became a literal or was cleared). */
|
||||
clear(cell: string): void {
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
this.precedents.delete(cell);
|
||||
// keep `dependents.get(cell)` -- other cells may still point here (=> #REF/0),
|
||||
// that entry is harmless and gets cleaned as those cells are re-parsed.
|
||||
}
|
||||
|
||||
/** Drop every edge (used on structural rebuilds). */
|
||||
reset(): void {
|
||||
this.precedents.clear();
|
||||
this.dependents.clear();
|
||||
}
|
||||
|
||||
getPrecedents(cell: string): Set<string> {
|
||||
return this.precedents.get(cell) ?? new Set();
|
||||
}
|
||||
getDependents(cell: string): Set<string> {
|
||||
return this.dependents.get(cell) ?? new Set();
|
||||
}
|
||||
|
||||
/** Dirty cells + all their transitive dependents. */
|
||||
affected(dirty: Iterable<string>): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...dirty];
|
||||
while (stack.length) {
|
||||
const k = stack.pop()!;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
const deps = this.dependents.get(k);
|
||||
if (deps) for (const d of deps) if (!seen.has(d)) stack.push(d);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topologically sort `nodes` using precedent edges that stay inside `nodes`.
|
||||
* Returns the evaluation order (precedents first) and the set of nodes that
|
||||
* lie on a cycle.
|
||||
*/
|
||||
topoOrder(nodes: Set<string>): { order: string[]; circular: Set<string> } {
|
||||
const WHITE = 0;
|
||||
const GREY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
const order: string[] = [];
|
||||
const circular = new Set<string>();
|
||||
for (const n of nodes) color.set(n, WHITE);
|
||||
|
||||
for (const root of nodes) {
|
||||
if (color.get(root) !== WHITE) continue;
|
||||
|
||||
// iterative DFS; frame = { node, iterator over its in-scope precedents }
|
||||
const stack: Array<{ node: string; iter: Iterator<string> }> = [];
|
||||
const pathIndex = new Map<string, number>(); // node -> position on stack
|
||||
|
||||
const push = (node: string) => {
|
||||
color.set(node, GREY);
|
||||
pathIndex.set(node, stack.length);
|
||||
const precs = [...this.getPrecedents(node)].filter((p) => nodes.has(p));
|
||||
stack.push({ node, iter: precs[Symbol.iterator]() });
|
||||
};
|
||||
push(root);
|
||||
|
||||
while (stack.length) {
|
||||
const frame = stack[stack.length - 1];
|
||||
const step = frame.iter.next();
|
||||
if (step.done) {
|
||||
color.set(frame.node, BLACK);
|
||||
pathIndex.delete(frame.node);
|
||||
order.push(frame.node);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const p = step.value;
|
||||
const c = color.get(p);
|
||||
if (c === WHITE) {
|
||||
push(p);
|
||||
} else if (c === GREY) {
|
||||
// back-edge -> cycle. Flag everything from p to the current frame.
|
||||
const from = pathIndex.get(p)!;
|
||||
for (let k = from; k < stack.length; k++) circular.add(stack[k].node);
|
||||
}
|
||||
// BLACK -> already settled, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// A node that depends on a circular node is not itself circular, but it must
|
||||
// be evaluated *after* the driver writes #CIRC. Keeping it in `order` is
|
||||
// fine; drop only the truly circular nodes.
|
||||
return { order: order.filter((n) => !circular.has(n)), circular };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Public surface of the formula engine.
|
||||
*
|
||||
* Nothing in `./` imports Vue, the DOM or any spreadsheet library. See
|
||||
* ./README.md for the grammar (EBNF), the evaluation model and the error model.
|
||||
*/
|
||||
|
||||
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
||||
export {
|
||||
parseFormula,
|
||||
collectRefs,
|
||||
ParseError,
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
export { DependencyGraph } from './graph';
|
||||
export { FUNCTIONS, getFunction, hasFunction } from './functions';
|
||||
export {
|
||||
colToNumber,
|
||||
numberToCol,
|
||||
parseA1,
|
||||
toA1,
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
literalValue,
|
||||
type TableModel,
|
||||
} from './workbook';
|
||||
export {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
ERROR_CODES,
|
||||
type ErrorCode,
|
||||
type CellValue,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
} from './types';
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tokenizer for the formula language.
|
||||
*
|
||||
* The lexer is deliberately dumb: it splits the input into a flat token stream
|
||||
* and leaves *all* structural decisions to the parser. In particular it does not
|
||||
* try to tell a cell reference ("A1") from a function name ("SUM") or a sheet
|
||||
* name ("Umsatz") -- they are all `IDENT` tokens and the parser disambiguates
|
||||
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
| 'NUMBER'
|
||||
| 'STRING'
|
||||
| 'IDENT' // bare word: A1, SUM, Region, Q1 ...
|
||||
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
|
||||
| 'PLUS'
|
||||
| 'MINUS'
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
| 'COLON'
|
||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||
| 'BANG'
|
||||
| 'EOF';
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
/** Raw text of the token (unquoted for STRING/QUOTED). */
|
||||
value: string;
|
||||
/** Character offset in the source, useful for error messages. */
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const SINGLE: Record<string, TokenType> = {
|
||||
'+': 'PLUS',
|
||||
'-': 'MINUS',
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
':': 'COLON',
|
||||
'!': 'BANG',
|
||||
};
|
||||
|
||||
function isDigit(ch: string): boolean {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
function isWordStart(ch: string): boolean {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
|
||||
}
|
||||
function isWordPart(ch: string): boolean {
|
||||
return isWordStart(ch) || isDigit(ch);
|
||||
}
|
||||
|
||||
export function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i];
|
||||
|
||||
// whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// number: 12 12.5 .5 1e3 1.2E-3
|
||||
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
|
||||
const start = i;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
if (src[i] === '.') {
|
||||
i++;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
if (src[i] === 'e' || src[i] === 'E') {
|
||||
let j = i + 1;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
if (isDigit(src[j])) {
|
||||
i = j;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: 'NUMBER', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// double-quoted string literal
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n && src[i] !== '"') {
|
||||
if (src[i] === '\\' && i + 1 < n) {
|
||||
out += src[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== '"') throw new LexError(`Unterminated string at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'STRING', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-quoted sheet name: 'Kosten Q3' ('' -> literal quote)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n) {
|
||||
if (src[i] === "'" && src[i + 1] === "'") {
|
||||
out += "'";
|
||||
i += 2;
|
||||
} else if (src[i] === "'") {
|
||||
break;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== "'") throw new LexError(`Unterminated sheet name at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'QUOTED', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// bare word: identifiers, function names, cell refs
|
||||
if (isWordStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n && isWordPart(src[i])) i++;
|
||||
tokens.push({ type: 'IDENT', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// "::" table qualifier -- must be checked before the single ":"
|
||||
if (ch === ':' && src[i + 1] === ':') {
|
||||
tokens.push({ type: 'DBLCOLON', value: '::', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-character punctuation / operators
|
||||
const t = SINGLE[ch];
|
||||
if (t) {
|
||||
tokens.push({ type: t, value: ch, pos: i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${ch}' at ${i}`);
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', pos: n });
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Recursive-descent parser: token stream -> AST.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* GRAMMAR (EBNF)
|
||||
* ---------------------------------------------------------------------------
|
||||
* formula = "=" expression ;
|
||||
*
|
||||
* expression = term { ("+" | "-") term } ; (* left assoc *)
|
||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||
* unary = ("+" | "-") unary
|
||||
* | power ;
|
||||
* power = primary [ "^" unary ] ; (* right assoc, and
|
||||
* binds tighter than
|
||||
* unary minus so that
|
||||
* -2^2 = -(2^2) = -4 *)
|
||||
* primary = NUMBER
|
||||
* | STRING
|
||||
* | "(" expression ")"
|
||||
* | funcall
|
||||
* | reference ;
|
||||
*
|
||||
* funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
*
|
||||
* reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
|
||||
* Numbers-style "::" are
|
||||
* interchangeable *)
|
||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
*
|
||||
* Precedence, lowest to highest: + - < * / < unary +/- < ^
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
||||
import { looksLikeCell, parseA1 } from './refs';
|
||||
|
||||
export type Node =
|
||||
| NumberNode
|
||||
| StringNode
|
||||
| BinaryNode
|
||||
| UnaryNode
|
||||
| FuncNode
|
||||
| CellNode
|
||||
| RangeNode;
|
||||
|
||||
export interface NumberNode {
|
||||
kind: 'number';
|
||||
value: number;
|
||||
}
|
||||
export interface StringNode {
|
||||
kind: 'string';
|
||||
value: string;
|
||||
}
|
||||
export interface BinaryNode {
|
||||
kind: 'binary';
|
||||
op: '+' | '-' | '*' | '/' | '^';
|
||||
left: Node;
|
||||
right: Node;
|
||||
}
|
||||
export interface UnaryNode {
|
||||
kind: 'unary';
|
||||
op: '+' | '-';
|
||||
operand: Node;
|
||||
}
|
||||
export interface FuncNode {
|
||||
kind: 'func';
|
||||
name: string;
|
||||
args: Node[];
|
||||
}
|
||||
/** Single cell reference, optionally qualified with a table name. */
|
||||
export interface CellNode {
|
||||
kind: 'cell';
|
||||
/** raw table name as written ("Umsatz"), or null for "this table" */
|
||||
qualifier: string | null;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
/** Rectangular range reference, optionally qualified with a table name. */
|
||||
export interface RangeNode {
|
||||
kind: 'range';
|
||||
qualifier: string | null;
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly toks: Token[]) {}
|
||||
|
||||
private peek(): Token {
|
||||
return this.toks[this.i];
|
||||
}
|
||||
private next(): Token {
|
||||
return this.toks[this.i++];
|
||||
}
|
||||
private at(t: TokenType): boolean {
|
||||
return this.toks[this.i].type === t;
|
||||
}
|
||||
private expect(t: TokenType): Token {
|
||||
if (!this.at(t)) {
|
||||
throw new ParseError(`Expected ${t} but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
|
||||
parse(): Node {
|
||||
const node = this.expression();
|
||||
if (!this.at('EOF')) {
|
||||
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private expression(): Node {
|
||||
let left = this.term();
|
||||
while (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
left = { kind: 'binary', op, left, right: this.term() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private term(): Node {
|
||||
let left = this.unary();
|
||||
while (this.at('STAR') || this.at('SLASH')) {
|
||||
const op = this.next().type === 'STAR' ? '*' : '/';
|
||||
left = { kind: 'binary', op, left, right: this.unary() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private unary(): Node {
|
||||
if (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
return { kind: 'unary', op, operand: this.unary() };
|
||||
}
|
||||
return this.power();
|
||||
}
|
||||
|
||||
private power(): Node {
|
||||
const base = this.primary();
|
||||
if (this.at('CARET')) {
|
||||
this.next();
|
||||
// right operand is `unary` -> right-associative and allows -exponent
|
||||
return { kind: 'binary', op: '^', left: base, right: this.unary() };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private primary(): Node {
|
||||
const t = this.peek();
|
||||
|
||||
if (t.type === 'NUMBER') {
|
||||
this.next();
|
||||
return { kind: 'number', value: Number(t.value) };
|
||||
}
|
||||
if (t.type === 'STRING') {
|
||||
this.next();
|
||||
return { kind: 'string', value: t.value };
|
||||
}
|
||||
if (t.type === 'LPAREN') {
|
||||
this.next();
|
||||
const inner = this.expression();
|
||||
this.expect('RPAREN');
|
||||
return inner;
|
||||
}
|
||||
if (t.type === 'QUOTED') {
|
||||
// must be a table-qualified reference: 'Kosten Q3'!B2 or 'Kosten Q3'::B2
|
||||
this.next();
|
||||
this.expectQualifierSep();
|
||||
return this.reference(t.value);
|
||||
}
|
||||
if (t.type === 'IDENT') {
|
||||
this.next();
|
||||
// function call?
|
||||
if (this.at('LPAREN')) {
|
||||
this.next();
|
||||
const args: Node[] = [];
|
||||
if (!this.at('RPAREN')) {
|
||||
args.push(this.expression());
|
||||
while (this.at('SEMICOLON')) {
|
||||
this.next();
|
||||
args.push(this.expression());
|
||||
}
|
||||
}
|
||||
this.expect('RPAREN');
|
||||
return { kind: 'func', name: t.value, args };
|
||||
}
|
||||
// table-qualified reference? IDENT ("!" | "::") ...
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return this.reference(t.value);
|
||||
}
|
||||
// otherwise it must be a local cell reference
|
||||
return this.reference(null, t);
|
||||
}
|
||||
|
||||
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cell/range part of a reference. `firstTok` (when given) is an
|
||||
* IDENT already consumed by the caller for the *local* case.
|
||||
*/
|
||||
private reference(qualifier: string | null, firstTok?: Token): Node {
|
||||
const a = firstTok ?? this.expectCellIdent();
|
||||
if (!firstTok && a.type !== 'IDENT') {
|
||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||
}
|
||||
if (!looksLikeCell(a.value)) {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
const start = parseA1(a.value);
|
||||
|
||||
if (this.at('COLON')) {
|
||||
this.next();
|
||||
const b = this.expectCellIdent();
|
||||
if (!looksLikeCell(b.value)) {
|
||||
throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`);
|
||||
}
|
||||
const end = parseA1(b.value);
|
||||
return { kind: 'range', qualifier, start, end };
|
||||
}
|
||||
return { kind: 'cell', qualifier, col: start.col, row: start.row };
|
||||
}
|
||||
|
||||
private expectQualifierSep(): void {
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
throw new ParseError(
|
||||
`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`,
|
||||
);
|
||||
}
|
||||
|
||||
private expectCellIdent(): Token {
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected cell reference but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw formula. The leading "=" is optional (callers usually strip it).
|
||||
* Throws {@link ParseError} / {@link LexError} on malformed input; the store
|
||||
* turns those into the `#ERR` cell value.
|
||||
*/
|
||||
export function parseFormula(src: string): Node {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch (e) {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks).parse();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range reference node it contains. */
|
||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||
switch (node.kind) {
|
||||
case 'cell':
|
||||
case 'range':
|
||||
out.push(node);
|
||||
break;
|
||||
case 'binary':
|
||||
collectRefs(node.left, out);
|
||||
collectRefs(node.right, out);
|
||||
break;
|
||||
case 'unary':
|
||||
collectRefs(node.operand, out);
|
||||
break;
|
||||
case 'func':
|
||||
node.args.forEach((a) => collectRefs(a, out));
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
|
||||
*
|
||||
* All coordinates the engine uses internally are 0-based. The A1 notation
|
||||
* exposed to the user is 1-based for rows and letter-based for columns
|
||||
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
||||
*/
|
||||
|
||||
const CELL_RE = /^\$?([A-Za-z]{1,3})\$?([0-9]{1,7})$/;
|
||||
|
||||
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
|
||||
export function colToNumber(letters: string): number {
|
||||
let n = 0;
|
||||
const up = letters.toUpperCase();
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
|
||||
export function numberToCol(n: number): string {
|
||||
let s = '';
|
||||
let x = n + 1;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
x = Math.floor((x - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
|
||||
export function looksLikeCell(text: string): boolean {
|
||||
return CELL_RE.test(text);
|
||||
}
|
||||
|
||||
/** Parse "A1" -> { col: 0, row: 0 }. Throws for malformed input. */
|
||||
export function parseA1(text: string): { col: number; row: number } {
|
||||
const m = CELL_RE.exec(text);
|
||||
if (!m) throw new Error(`Bad cell reference: ${text}`);
|
||||
return { col: colToNumber(m[1]), row: Number(m[2]) - 1 };
|
||||
}
|
||||
|
||||
/** Build "A1" from 0-based coordinates. */
|
||||
export function toA1(col: number, row: number): string {
|
||||
return `${numberToCol(col)}${row + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a rectangular range (given by two corners, any order) into the list of
|
||||
* cell coordinates it covers, row-major.
|
||||
*/
|
||||
export function expandRange(
|
||||
a: { col: number; row: number },
|
||||
b: { col: number; row: number },
|
||||
): Array<{ col: number; row: number }> {
|
||||
const c0 = Math.min(a.col, b.col);
|
||||
const c1 = Math.max(a.col, b.col);
|
||||
const r0 = Math.min(a.row, b.row);
|
||||
const r1 = Math.max(a.row, b.row);
|
||||
const out: Array<{ col: number; row: number }> = [];
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
out.push({ col: c, row: r });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared types for the formula engine.
|
||||
*
|
||||
* The engine is intentionally framework-agnostic: it knows nothing about Vue,
|
||||
* the store or the DOM. The host application implements {@link EngineHost} to
|
||||
* expose the current document to the evaluator.
|
||||
*/
|
||||
|
||||
/** All error values the engine can produce. They propagate to dependent cells. */
|
||||
export type ErrorCode =
|
||||
| '#REF' // invalid / deleted reference or unknown sheet name
|
||||
| '#DIV/0!' // division by zero
|
||||
| '#NAME?' // unknown function
|
||||
| '#ERR' // parse error
|
||||
| '#CIRC' // circular reference
|
||||
| '#VALUE!'; // wrong value type in arithmetic (documented extension)
|
||||
|
||||
export const ERROR_CODES: ErrorCode[] = [
|
||||
'#REF',
|
||||
'#DIV/0!',
|
||||
'#NAME?',
|
||||
'#ERR',
|
||||
'#CIRC',
|
||||
'#VALUE!',
|
||||
];
|
||||
|
||||
/** A formula error. Instances are immutable and comparable by `code`. */
|
||||
export class FormulaError {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
public readonly detail?: string,
|
||||
) {}
|
||||
toString(): string {
|
||||
return this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isError(v: unknown): v is FormulaError {
|
||||
return v instanceof FormulaError;
|
||||
}
|
||||
|
||||
/** A plain scalar a cell can hold once evaluated. `null` means "empty cell". */
|
||||
export type Scalar = number | string | null;
|
||||
|
||||
/** The fully evaluated value of a cell. */
|
||||
export type CellValue = Scalar | FormulaError;
|
||||
|
||||
/** 0-based cell coordinate inside a single table. */
|
||||
export interface Coord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
/** A cell address that also carries the id of the table it lives on. */
|
||||
export interface CellRef extends Coord {
|
||||
tableId: string;
|
||||
}
|
||||
|
||||
/** Stable string key for a cell across the whole document. */
|
||||
export function keyOf(tableId: string, col: number, row: number): string {
|
||||
return `${tableId}␟${col}␟${row}`;
|
||||
}
|
||||
|
||||
export function parseKey(key: string): CellRef {
|
||||
const [tableId, col, row] = key.split('␟');
|
||||
return { tableId, col: Number(col), row: Number(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as seen by the engine. Implemented by the Vue store.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). Table names are unique across the whole document, so a qualified
|
||||
* reference like `Umsatz!B2` / `Umsatz::B2` resolves the same no matter which
|
||||
* sheet the formula or the target table sits on.
|
||||
*/
|
||||
export interface EngineHost {
|
||||
/** Id of the table the formula currently being evaluated lives on. */
|
||||
homeTableId: string;
|
||||
/**
|
||||
* Resolve a table *name* (as written in a formula) to its id. Matching is
|
||||
* case-insensitive and tolerant of surrounding whitespace. Returns `null`
|
||||
* for an unknown / deleted table.
|
||||
*/
|
||||
resolveTableName(name: string): string | null;
|
||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
* Already-computed value of another cell. The recalculation driver guarantees
|
||||
* precedents are evaluated first (topological order), so this never triggers
|
||||
* a nested evaluation.
|
||||
*/
|
||||
getComputed(tableId: string, col: number, row: number): CellValue;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Framework-agnostic spreadsheet model that ties the engine modules together.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). A `Workbook` owns a flat list of tables and, across all of them:
|
||||
* - the raw text of every non-empty cell,
|
||||
* - the parsed AST of every formula cell (or an `#ERR` marker),
|
||||
* - the computed value of every cell,
|
||||
* - the document-wide {@link DependencyGraph}.
|
||||
*
|
||||
* It implements {@link EngineHost} so the evaluator can read neighbouring cell
|
||||
* values through it. The Vue store wraps an instance of this class in `reactive`
|
||||
* and mirrors its computed values into a reactive map, which makes every formula
|
||||
* cell behave like a `computed` value in the UI.
|
||||
*
|
||||
* Recalculation is incremental: `setCell` reparses one cell, updates its graph
|
||||
* edges and recomputes only the affected sub-graph in topological order.
|
||||
* Structural edits (rename / delete table, add rows, ...) call `rebuild()` for a
|
||||
* full, still-cheap pass.
|
||||
*/
|
||||
|
||||
import { DependencyGraph } from './graph';
|
||||
import { collectRefs, parseFormula, type Node } from './parser';
|
||||
import { expandRange } from './refs';
|
||||
import {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
export interface TableModel {
|
||||
id: string;
|
||||
/** document-unique table name, used as the qualifier in formulas */
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
|
||||
inputs: Map<string, string>;
|
||||
}
|
||||
|
||||
const NUMBER_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
||||
|
||||
function localKey(col: number, row: number): string {
|
||||
return `${col},${row}`;
|
||||
}
|
||||
|
||||
/** Interpret a raw literal (non-formula) cell input. */
|
||||
export function literalValue(raw: string): CellValue {
|
||||
const t = raw.trim();
|
||||
if (t === '') return null;
|
||||
if (NUMBER_RE.test(t)) return Number(t);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export class Workbook implements EngineHost {
|
||||
tables: TableModel[];
|
||||
/** transiently set to the home table while a formula is being evaluated */
|
||||
homeTableId = '';
|
||||
|
||||
private readonly graph = new DependencyGraph();
|
||||
/** cellKey -> parsed formula AST, or an error marker for un-parsable input */
|
||||
private readonly asts = new Map<string, Node | FormulaError>();
|
||||
/** cellKey -> computed value */
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
|
||||
constructor(tables: TableModel[]) {
|
||||
this.tables = tables;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
// ---- EngineHost ---------------------------------------------------------
|
||||
|
||||
resolveTableName(name: string): string | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const hit = this.tables.find((t) => t.name.trim().toLowerCase() === needle);
|
||||
return hit ? hit.id : null;
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
}
|
||||
|
||||
getComputed(tableId: string, col: number, row: number): CellValue {
|
||||
if (col < 0 || row < 0) return new FormulaError('#REF');
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- reads ------------------------------------------------------------
|
||||
|
||||
tableById(id: string): TableModel | undefined {
|
||||
return this.tables.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
getInput(tableId: string, col: number, row: number): string {
|
||||
return this.tableById(tableId)?.inputs.get(localKey(col, row)) ?? '';
|
||||
}
|
||||
|
||||
/** Public computed-value read used by the store / UI. */
|
||||
cellValue(tableId: string, col: number, row: number): CellValue {
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------
|
||||
|
||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||
setCell(tableId: string, col: number, row: number, raw: string): void {
|
||||
const table = this.tableById(tableId);
|
||||
if (!table) return;
|
||||
const lk = localKey(col, row);
|
||||
if (raw === '') table.inputs.delete(lk);
|
||||
else table.inputs.set(lk, raw);
|
||||
|
||||
const key = keyOf(tableId, col, row);
|
||||
this.parseCell(key, tableId, raw);
|
||||
this.recalc([key]);
|
||||
}
|
||||
|
||||
/** Reparse every formula, rebuild the graph, recompute everything. */
|
||||
rebuild(): void {
|
||||
this.graph.reset();
|
||||
this.asts.clear();
|
||||
const all: string[] = [];
|
||||
for (const table of this.tables) {
|
||||
for (const [lk, raw] of table.inputs) {
|
||||
const [c, r] = lk.split(',').map(Number);
|
||||
const key = keyOf(table.id, c, r);
|
||||
all.push(key);
|
||||
this.parseCell(key, table.id, raw);
|
||||
}
|
||||
}
|
||||
this.recalcAll(all);
|
||||
}
|
||||
|
||||
// ---- internals ------------------------------------------------------
|
||||
|
||||
/** Parse one cell's raw input and (re)wire its graph edges. */
|
||||
private parseCell(key: string, tableId: string, raw: string): void {
|
||||
if (!raw.startsWith('=')) {
|
||||
this.asts.delete(key);
|
||||
this.graph.clear(key);
|
||||
return;
|
||||
}
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = parseFormula(raw);
|
||||
} catch (e) {
|
||||
// a bad formula is a cell-level error, never a crash
|
||||
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
|
||||
this.graph.setPrecedents(key, []);
|
||||
return;
|
||||
}
|
||||
this.asts.set(key, ast);
|
||||
this.graph.setPrecedents(key, this.precedentsOf(ast, tableId));
|
||||
}
|
||||
|
||||
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
|
||||
private precedentsOf(ast: Node, tableId: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
const target = ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if (ref.kind === 'cell') {
|
||||
out.push(keyOf(target, ref.col, ref.row));
|
||||
} else {
|
||||
for (const c of expandRange(ref.start, ref.end)) {
|
||||
out.push(keyOf(target, c.col, c.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Incremental recalculation starting from `dirty`. */
|
||||
private recalc(dirty: string[]): void {
|
||||
this.evaluateSet(this.graph.affected(dirty));
|
||||
}
|
||||
|
||||
/** Full recalculation of the given (complete) cell set. */
|
||||
private recalcAll(all: string[]): void {
|
||||
this.values.clear();
|
||||
this.evaluateSet(new Set(all));
|
||||
}
|
||||
|
||||
private evaluateSet(affected: Set<string>): void {
|
||||
const { order, circular } = this.graph.topoOrder(affected);
|
||||
for (const key of circular) this.values.set(key, new FormulaError('#CIRC'));
|
||||
for (const key of order) this.values.set(key, this.evaluateCell(key));
|
||||
}
|
||||
|
||||
private evaluateCell(key: string): CellValue {
|
||||
const ast = this.asts.get(key);
|
||||
if (ast === undefined) {
|
||||
const { tableId, col, row } = parseKey(key);
|
||||
return literalValue(this.getInput(tableId, col, row));
|
||||
}
|
||||
if (isError(ast)) return ast;
|
||||
this.homeTableId = parseKey(key).tableId;
|
||||
try {
|
||||
return evaluate(ast, this);
|
||||
} catch {
|
||||
return new FormulaError('#ERR', 'evaluation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute order for a hand-picked dirty set -- exposed for tests. */
|
||||
debugOrder(dirty: string[]): { order: string[]; circular: string[] } {
|
||||
const { order, circular } = this.graph.topoOrder(this.graph.affected(dirty));
|
||||
return { order, circular: [...circular] };
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Import/export acceptance checks at the pure (de)serialisation layer -- the
|
||||
* `<a download>` glue is trivial and browser-only, so it is not exercised here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useDocument, type DocumentStore } from '../../store/document';
|
||||
import { isError } from '../../engine';
|
||||
import { serializeDocument, parseDocument, JsonImportError } from '../json';
|
||||
import { tableToCsv, parseCsv, matrixToCells } from '../csv';
|
||||
import { importJsonReplace } from '../operations';
|
||||
|
||||
function fresh(): DocumentStore {
|
||||
const s = useDocument();
|
||||
s.resetToSeed();
|
||||
return s;
|
||||
}
|
||||
function tableId(s: DocumentStore, name: string): string {
|
||||
for (const sh of s.doc.sheets) {
|
||||
const o = sh.objects.find((x) => x.kind === 'table' && x.name === name);
|
||||
if (o) return o.id;
|
||||
}
|
||||
throw new Error(name);
|
||||
}
|
||||
const coords = (a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
return {
|
||||
col: m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1,
|
||||
row: Number(m[2]) - 1,
|
||||
};
|
||||
};
|
||||
|
||||
describe('JSON native format', () => {
|
||||
it('round-trips a document losslessly (export → reset → import → identical)', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
const json = serializeDocument(before);
|
||||
|
||||
s.addSheet('scratch');
|
||||
s.setCellInput(tableId(s, 'Umsatz'), 1, 1, '999999');
|
||||
|
||||
s.replaceDocument(parseDocument(json));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects corrupt / empty input and leaves the document untouched', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
|
||||
expect(() => importJsonReplace(s, '{ not json')).toThrow(JsonImportError);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 2, sheets: [] }))).toThrow(
|
||||
JsonImportError,
|
||||
);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 7 }))).toThrow(JsonImportError);
|
||||
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('an unknown table name in an imported formula yields #REF, no crash', () => {
|
||||
const s = fresh();
|
||||
const doc = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 't1',
|
||||
name: 'T1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rows: 4,
|
||||
cols: 4,
|
||||
colWidths: {},
|
||||
cells: { '0,0': '=Nirgends!A1', '1,0': '=A1+1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
importJsonReplace(s, JSON.stringify(doc));
|
||||
const a1 = s.cellValue('t1', 0, 0);
|
||||
const b1 = s.cellValue('t1', 1, 0);
|
||||
expect(isError(a1) && a1.code).toBe('#REF');
|
||||
expect(isError(b1) && b1.code).toBe('#REF'); // error propagates
|
||||
});
|
||||
|
||||
it('normalises a v2 doc saved by an older build (missing table.view)', () => {
|
||||
const stale = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
// no `view` key -- as written before the frozen-panes feature
|
||||
{ kind: 'table', id: 't1', name: 'T1', x: 10, y: 10, rows: 8, cols: 5, colWidths: {}, cells: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
const doc = parseDocument(JSON.stringify(stale));
|
||||
const t = doc.sheets[0].objects[0];
|
||||
expect(t.kind).toBe('table');
|
||||
if (t.kind === 'table') {
|
||||
expect(t.view).toEqual({ mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates a v1 document on import', () => {
|
||||
const s = fresh();
|
||||
const v1 = JSON.stringify({
|
||||
version: 1,
|
||||
sheets: [
|
||||
{ id: 'a', name: 'Zahlen', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '10', '0,1': '=A1*3' } },
|
||||
],
|
||||
activeSheetId: 'a',
|
||||
selection: null,
|
||||
});
|
||||
importJsonReplace(s, v1);
|
||||
expect(s.doc.version).toBe(2);
|
||||
const id = tableId(s, 'Zahlen');
|
||||
expect(s.cellValue(id, 0, 1)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV', () => {
|
||||
it('exports computed values of "Übersicht" and re-imports to the same numbers', () => {
|
||||
const s = fresh();
|
||||
const src = tableId(s, 'Übersicht');
|
||||
const csv = tableToCsv(s, src, { delimiter: ',', bom: false, crlf: true });
|
||||
|
||||
expect(csv.includes('\r\n')).toBe(true);
|
||||
// header row is text, body carries the computed numbers
|
||||
expect(csv.split('\r\n')[0]).toBe('Region,Umsatz Jahr,Kosten Jahr,Deckungsbeitrag,Marge %');
|
||||
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv(csv, ','), true);
|
||||
const copyId = s.addTableFromCells(s.doc.sheets[1].id, 'Kopie', cells, rows, cols);
|
||||
|
||||
for (const a1 of ['B2', 'C2', 'D2', 'B8', 'C8', 'D8']) {
|
||||
const { col, row } = coords(a1);
|
||||
expect(s.cellValue(copyId, col, row)).toBe(s.cellValue(src, col, row));
|
||||
}
|
||||
});
|
||||
|
||||
it('RFC-4180 quoting: a field with a comma is wrapped and survives the round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.sheets[1].id);
|
||||
s.setCellInput(id, 0, 0, 'Müller, Hans');
|
||||
s.setCellInput(id, 1, 0, 'x"y');
|
||||
const csv = tableToCsv(s, id, { delimiter: ',', bom: false, crlf: true });
|
||||
expect(csv).toContain('"Müller, Hans"');
|
||||
expect(csv).toContain('"x""y"');
|
||||
const [line] = parseCsv(csv, ',');
|
||||
expect(line).toEqual(['Müller, Hans', 'x"y']);
|
||||
});
|
||||
|
||||
it('a CSV cell "=1+2" becomes a formula that evaluates to 3', () => {
|
||||
const s = fresh();
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv('=1+2\n', ','), true);
|
||||
const id = s.addTableFromCells(s.doc.sheets[1].id, 'Rechnen', cells, rows, cols);
|
||||
expect(s.cellValue(id, 0, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('locale number detection (de vs en)', () => {
|
||||
const { cells } = matrixToCells(parseCsv('1.234,56;7,89;1,234.50\n', ';'), true);
|
||||
expect(cells['0,0']).toBe('1234.56');
|
||||
expect(cells['1,0']).toBe('7.89');
|
||||
expect(cells['2,0']).toBe('1234.50');
|
||||
});
|
||||
});
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* CSV import/export -- hand-written, no library.
|
||||
*
|
||||
* Export follows RFC 4180: fields containing the delimiter, a double quote, CR
|
||||
* or LF are wrapped in double quotes and inner quotes are doubled. Line ending
|
||||
* is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";".
|
||||
*
|
||||
* Import: a small state-machine parser that understands quoted fields with
|
||||
* embedded delimiters / newlines, plus delimiter sniffing and de/en locale
|
||||
* number normalisation. A leading "=" makes the cell a formula.
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
export type Delimiter = ',' | ';' | '\t';
|
||||
|
||||
export interface CsvExportOptions {
|
||||
delimiter: Delimiter;
|
||||
bom: boolean;
|
||||
crlf: boolean;
|
||||
}
|
||||
|
||||
// ---- export --------------------------------------------------------------
|
||||
|
||||
function needsQuoting(field: string, delimiter: string): boolean {
|
||||
return (
|
||||
field.includes(delimiter) ||
|
||||
field.includes('"') ||
|
||||
field.includes('\n') ||
|
||||
field.includes('\r')
|
||||
);
|
||||
}
|
||||
|
||||
function quoteField(field: string, delimiter: string): string {
|
||||
if (!needsQuoting(field, delimiter)) return field;
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Bounding box of the used area of a sheet. */
|
||||
function usedBounds(cells: Record<string, string>): { rows: number; cols: number } {
|
||||
let rows = 0;
|
||||
let cols = 0;
|
||||
for (const key of Object.keys(cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
if (c + 1 > cols) cols = c + 1;
|
||||
if (r + 1 > rows) rows = r + 1;
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
export function tableToCsv(
|
||||
store: DocumentStore,
|
||||
tableId: string,
|
||||
opts: CsvExportOptions,
|
||||
): string {
|
||||
const table = store.tableById(tableId);
|
||||
if (!table) return '';
|
||||
const bounds = usedBounds(table.cells);
|
||||
const rows = Math.max(bounds.rows, 1);
|
||||
const cols = Math.max(bounds.cols, 1);
|
||||
const eol = opts.crlf ? '\r\n' : '\n';
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const fields: string[] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const value = rawString(store.cellValue(tableId, c, r));
|
||||
fields.push(quoteField(value, opts.delimiter));
|
||||
}
|
||||
lines.push(fields.join(opts.delimiter));
|
||||
}
|
||||
return (opts.bom ? '' : '') + lines.join(eol) + eol;
|
||||
}
|
||||
|
||||
// ---- import --------------------------------------------------------------
|
||||
|
||||
export function sniffDelimiter(text: string): Delimiter {
|
||||
const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length);
|
||||
const counts: Record<Delimiter, number> = { ',': 0, ';': 0, '\t': 0 };
|
||||
let inQuotes = false;
|
||||
for (const ch of firstLine) {
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++;
|
||||
}
|
||||
const best = (Object.keys(counts) as Delimiter[]).sort(
|
||||
(a, b) => counts[b] - counts[a],
|
||||
)[0];
|
||||
return counts[best] > 0 ? best : ',';
|
||||
}
|
||||
|
||||
/** RFC-4180-ish parser. Returns a matrix of raw string cells. */
|
||||
export function parseCsv(text: string, delimiter: Delimiter): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
// strip BOM
|
||||
if (text.charCodeAt(0) === 0xfeff) i = 1;
|
||||
|
||||
const endField = () => {
|
||||
row.push(field);
|
||||
field = '';
|
||||
};
|
||||
const endRow = () => {
|
||||
endField();
|
||||
rows.push(row);
|
||||
row = [];
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === delimiter) {
|
||||
endField();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
if (text[i + 1] === '\n') i++;
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
// trailing field / row (unless the file ended exactly on a newline)
|
||||
if (field !== '' || row.length > 0) endRow();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/;
|
||||
const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/;
|
||||
|
||||
/** Normalise a locale-formatted number to a plain JS number literal string. */
|
||||
export function normalizeNumber(value: string): string {
|
||||
const v = value.trim();
|
||||
if (v === '' || v.startsWith('=')) return value;
|
||||
if (/^-?\d+$/.test(v)) return v;
|
||||
if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.');
|
||||
if (EN_NUMBER.test(v)) return v.replace(/,/g, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface CsvToCellsResult {
|
||||
cells: Record<string, string>;
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function matrixToCells(
|
||||
matrix: string[][],
|
||||
detectNumbers = true,
|
||||
): CsvToCellsResult {
|
||||
const cells: Record<string, string> = {};
|
||||
let cols = 0;
|
||||
matrix.forEach((line, r) => {
|
||||
if (line.length > cols) cols = line.length;
|
||||
line.forEach((cell, c) => {
|
||||
let raw = cell;
|
||||
if (detectNumbers) raw = normalizeNumber(raw);
|
||||
if (raw !== '') cells[`${c},${r}`] = raw;
|
||||
});
|
||||
});
|
||||
return { cells, rows: matrix.length, cols: Math.max(cols, 1) };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Trigger a client-side file download from a Blob. */
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on the next tick so the download has started
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function downloadText(
|
||||
filename: string,
|
||||
text: string,
|
||||
mime = 'text/plain;charset=utf-8',
|
||||
): void {
|
||||
downloadBlob(filename, new Blob([text], { type: mime }));
|
||||
}
|
||||
|
||||
export function downloadDataUrl(filename: string, dataUrl: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Native JSON format -- full-fidelity, lossless round-trip.
|
||||
* Contains every sheet, every table/chart object with position & size, raw
|
||||
* formulas, column widths, tab order, the active object and the cell selection.
|
||||
* No library: `JSON.stringify` / `JSON.parse` plus schema validation.
|
||||
*
|
||||
* Version 1 documents (one grid per sheet) are migrated to version 2
|
||||
* (canvas + objects) on import.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
Sheet,
|
||||
SheetObject,
|
||||
TableObject,
|
||||
TableView,
|
||||
TextObject,
|
||||
} from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isCellNumFmt,
|
||||
isNumberLocale,
|
||||
type NumberLocale,
|
||||
} from '../store/format';
|
||||
|
||||
/** Keep a valid locale override, drop anything else (undefined = inherit). */
|
||||
function localeOrUndef(x: unknown): NumberLocale | undefined {
|
||||
return isNumberLocale(x) ? x : undefined;
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
function colorOrUndef(x: unknown): string | undefined {
|
||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||
}
|
||||
function boolOrUndef(x: unknown): boolean | undefined {
|
||||
return typeof x === 'boolean' ? x : undefined;
|
||||
}
|
||||
function zoomOrUndef(x: unknown): number | undefined {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(2, Math.max(0.4, Math.round(x * 20) / 20))
|
||||
: undefined;
|
||||
}
|
||||
function clampNum(x: unknown, lo: number, hi: number, fallback: number): number {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(hi, Math.max(lo, Math.round(x)))
|
||||
: fallback;
|
||||
}
|
||||
const H_ALIGNS = new Set<HAlign>(['left', 'center', 'right']);
|
||||
function alignOrUndef(x: unknown): HAlign | undefined {
|
||||
return H_ALIGNS.has(x as HAlign) ? (x as HAlign) : undefined;
|
||||
}
|
||||
|
||||
/** Validate + trim one CellFormat; returns undefined if nothing valid remains. */
|
||||
function normaliseCellFormat(x: unknown): CellFormat | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const f = x as CellFormat;
|
||||
const out: CellFormat = {};
|
||||
if (alignOrUndef(f.align)) out.align = f.align;
|
||||
if (f.valign === 'top' || f.valign === 'middle' || f.valign === 'bottom') out.valign = f.valign;
|
||||
if (isCellNumFmt(f.numFmt) && f.numFmt !== 'auto') out.numFmt = f.numFmt;
|
||||
if (colorOrUndef(f.color)) out.color = f.color;
|
||||
if (colorOrUndef(f.bg)) out.bg = f.bg;
|
||||
if (f.bold === true) out.bold = true;
|
||||
if (f.italic === true) out.italic = true;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<string, CellFormat> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+,\d+$/.test(k)) continue;
|
||||
const f = normaliseCellFormat(v);
|
||||
if (f) out[k] = f;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseRowHeights(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(k)) continue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.min(240, Math.max(14, Math.round(v)));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function coord(x: unknown): { col: number; row: number } | null {
|
||||
const c = x as { col?: unknown; row?: unknown };
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
if (typeof c.col !== 'number' || typeof c.row !== 'number') return null;
|
||||
return { col: Math.max(0, Math.floor(c.col)), row: Math.max(0, Math.floor(c.row)) };
|
||||
}
|
||||
function normaliseSelection(x: unknown): DocumentState['selection'] {
|
||||
const s = x as { tableId?: unknown; anchor?: unknown; focus?: unknown; ranges?: unknown };
|
||||
const anchor = coord(s.anchor);
|
||||
const focus = coord(s.focus);
|
||||
if (typeof s.tableId !== 'string' || !anchor || !focus) return null;
|
||||
const ranges = Array.isArray(s.ranges)
|
||||
? s.ranges
|
||||
.map((r) => {
|
||||
const a = coord((r as { anchor?: unknown }).anchor);
|
||||
const f = coord((r as { focus?: unknown }).focus);
|
||||
return a && f ? { anchor: a, focus: f } : null;
|
||||
})
|
||||
.filter((r): r is { anchor: { col: number; row: number }; focus: { col: number; row: number } } => !!r)
|
||||
: [];
|
||||
return { tableId: s.tableId, anchor, focus, ranges: ranges.length ? ranges : undefined };
|
||||
}
|
||||
function numMapOrUndef(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (/^\d+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.max(20, Math.round(v));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
export class JsonImportError extends Error {}
|
||||
|
||||
// ---- v2 validation ----------------------------------------------------
|
||||
|
||||
function isRecordOfString(x: unknown): x is Record<string, string> {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
Object.values(x as Record<string, unknown>).every((v) => typeof v === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isTableObject(x: unknown): x is TableObject {
|
||||
const o = x as TableObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'table' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.rows) &&
|
||||
Number.isFinite(o.cols) &&
|
||||
!!o.colWidths &&
|
||||
typeof o.colWidths === 'object' &&
|
||||
isRecordOfString(o.cells) &&
|
||||
Object.keys(o.cells).every((k) => /^\d+,\d+$/.test(k))
|
||||
);
|
||||
}
|
||||
|
||||
function isChartObject(x: unknown): x is ChartObject {
|
||||
const o = x as ChartObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'chart' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
(o.view === 'bar' || o.view === 'line')
|
||||
);
|
||||
}
|
||||
|
||||
function isTextObject(x: unknown): x is TextObject {
|
||||
const o = x as TextObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'text' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.text === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
|
||||
const d = defaultTableView();
|
||||
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
|
||||
return {
|
||||
mode,
|
||||
width: Number.isFinite(v?.width) ? Math.max(200, Math.round(v!.width!)) : d.width,
|
||||
height: Number.isFinite(v?.height) ? Math.max(120, Math.round(v!.height!)) : d.height,
|
||||
frozenRows: Math.min(Math.max(0, Math.floor(v?.frozenRows ?? 0)), Math.max(0, rows - 1)),
|
||||
frozenCols: Math.min(Math.max(0, Math.floor(v?.frozenCols ?? 0)), Math.max(0, cols - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseObject(o: SheetObject): SheetObject {
|
||||
if (o.kind === 'table') {
|
||||
const rows = Math.max(1, Math.floor(o.rows));
|
||||
const cols = Math.max(1, Math.floor(o.cols));
|
||||
return {
|
||||
kind: 'table',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
rows,
|
||||
cols,
|
||||
colWidths: numMapOrUndef(o.colWidths) ?? {},
|
||||
rowHeight:
|
||||
typeof o.rowHeight === 'number' && Number.isFinite(o.rowHeight)
|
||||
? Math.min(240, Math.max(14, Math.round(o.rowHeight)))
|
||||
: undefined,
|
||||
rowHeights: normaliseRowHeights(o.rowHeights),
|
||||
cells: { ...o.cells },
|
||||
formats: normaliseFormats(o.formats),
|
||||
view: normaliseTableView(o.view, rows, cols),
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'text') {
|
||||
return {
|
||||
kind: 'text',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(80, Math.round(o.width)),
|
||||
height: Math.max(32, Math.round(o.height)),
|
||||
text: String(o.text ?? ''),
|
||||
fontSize: clampNum(o.fontSize, 9, 96, 15),
|
||||
align: alignOrUndef(o.align) ?? 'left',
|
||||
bold: o.bold === true || undefined,
|
||||
italic: o.italic === true || undefined,
|
||||
color: colorOrUndef(o.color),
|
||||
bg: colorOrUndef(o.bg),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'chart',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(200, Math.round(o.width)),
|
||||
height: Math.max(140, Math.round(o.height)),
|
||||
view: o.view,
|
||||
sourceTableId: o.sourceTableId ?? null,
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateDocument(x: unknown): DocumentState {
|
||||
if (!x || typeof x !== 'object') throw new JsonImportError('Keine gültige JSON-Struktur.');
|
||||
const d = x as { version?: unknown };
|
||||
|
||||
if (d.version === 1) return migrateToV2(x);
|
||||
if (d.version !== 2) throw new JsonImportError(`Nicht unterstützte Version: ${String(d.version)}`);
|
||||
|
||||
const doc = x as DocumentState;
|
||||
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0)
|
||||
throw new JsonImportError('Dokument enthält keine Blätter.');
|
||||
|
||||
const sheetIds = new Set<string>();
|
||||
const objectIds = new Set<string>();
|
||||
for (const s of doc.sheets) {
|
||||
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
||||
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
|
||||
if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
|
||||
sheetIds.add(s.id);
|
||||
for (const o of s.objects) {
|
||||
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o))
|
||||
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||
objectIds.add(o.id);
|
||||
}
|
||||
}
|
||||
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
|
||||
throw new JsonImportError('activeSheetId zeigt auf kein vorhandenes Blatt.');
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: doc.sheets.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
objects: s.objects.map(normaliseObject),
|
||||
locale: localeOrUndef(s.locale),
|
||||
background: colorOrUndef(s.background),
|
||||
foreground: colorOrUndef(s.foreground),
|
||||
showGrid: boolOrUndef(s.showGrid),
|
||||
zoom: zoomOrUndef(s.zoom),
|
||||
})),
|
||||
activeSheetId: doc.activeSheetId,
|
||||
activeObjectId:
|
||||
doc.activeObjectId && objectIds.has(doc.activeObjectId) ? doc.activeObjectId : null,
|
||||
selection:
|
||||
doc.selection && objectIds.has(doc.selection.tableId)
|
||||
? normaliseSelection(doc.selection)
|
||||
: null,
|
||||
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
};
|
||||
}
|
||||
|
||||
// ---- v1 -> v2 migration --------------------------------------------
|
||||
|
||||
interface V1Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
colWidths?: Record<number, number>;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
interface V1Doc {
|
||||
version: 1;
|
||||
sheets: V1Sheet[];
|
||||
activeSheetId: string;
|
||||
selection?: { sheetId: string; anchor: { col: number; row: number }; focus: { col: number; row: number } } | null;
|
||||
}
|
||||
|
||||
let migSeq = 0;
|
||||
const migId = (p: string) => `${p}-mig-${Date.now().toString(36)}-${migSeq++}`;
|
||||
|
||||
export function migrateToV2(x: unknown): DocumentState {
|
||||
const v1 = x as V1Doc;
|
||||
if (!Array.isArray(v1.sheets) || v1.sheets.length === 0)
|
||||
throw new JsonImportError('v1-Dokument enthält keine Tabellen.');
|
||||
|
||||
const sheetIdMap = new Map<string, { sheetId: string; tableId: string }>();
|
||||
const sheets: Sheet[] = v1.sheets.map((s) => {
|
||||
const sheetId = migId('sheet');
|
||||
const tableId = migId('tbl');
|
||||
sheetIdMap.set(s.id, { sheetId, tableId });
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: tableId,
|
||||
name: s.name, // keep the name -> cross-sheet formulas still resolve
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: Math.max(1, Math.floor(s.rows ?? 20)),
|
||||
cols: Math.max(1, Math.floor(s.cols ?? 8)),
|
||||
colWidths: { ...(s.colWidths ?? {}) },
|
||||
cells: { ...(s.cells ?? {}) },
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: sheetId, name: s.name, objects: [table] };
|
||||
});
|
||||
|
||||
const active = sheetIdMap.get(v1.activeSheetId) ?? [...sheetIdMap.values()][0];
|
||||
const selSheet = v1.selection ? sheetIdMap.get(v1.selection.sheetId) : undefined;
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets,
|
||||
activeSheetId: active.sheetId,
|
||||
activeObjectId: active.tableId,
|
||||
selection:
|
||||
v1.selection && selSheet
|
||||
? { tableId: selSheet.tableId, anchor: v1.selection.anchor, focus: v1.selection.focus }
|
||||
: null,
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
};
|
||||
}
|
||||
|
||||
// ---- (de)serialise ------------------------------------------------
|
||||
|
||||
export function serializeDocument(doc: DocumentState): string {
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
export function parseDocument(text: string): DocumentState {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new JsonImportError('Datei ist kein gültiges JSON.');
|
||||
}
|
||||
return validateDocument(data);
|
||||
}
|
||||
|
||||
/** Give imported sheets & their objects fresh ids so they can be appended. */
|
||||
export function reidSheets(doc: DocumentState): Sheet[] {
|
||||
return doc.sheets.map((s, i) => {
|
||||
const idMap = new Map<string, string>();
|
||||
const objects = s.objects.map((o, j) => {
|
||||
const nid = `imp-${Date.now().toString(36)}-${i}-${j}`;
|
||||
idMap.set(o.id, nid);
|
||||
return { ...o, id: nid } as SheetObject;
|
||||
});
|
||||
// repoint chart sources that referenced a sibling table in the same import
|
||||
for (const o of objects) {
|
||||
if (o.kind === 'chart' && o.sourceTableId && idMap.has(o.sourceTableId)) {
|
||||
o.sourceTableId = idMap.get(o.sourceTableId)!;
|
||||
}
|
||||
}
|
||||
return { id: `imp-sheet-${Date.now().toString(36)}-${i}`, name: s.name, objects };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* High-level import/export operations. Each one is atomic: on any failure it
|
||||
* throws and leaves the document untouched (the UI catches and shows a toast).
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { downloadBlob, downloadText } from './download';
|
||||
import { serializeDocument, parseDocument, reidSheets } from './json';
|
||||
import {
|
||||
parseCsv,
|
||||
sniffDelimiter,
|
||||
matrixToCells,
|
||||
tableToCsv,
|
||||
type Delimiter,
|
||||
type CsvExportOptions,
|
||||
} from './csv';
|
||||
import { documentToXlsx, xlsxToSheets, importedToSheet } from './xlsx';
|
||||
|
||||
export function readAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(fr.result as ArrayBuffer);
|
||||
fr.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** Yield to the event loop so a large parse doesn't freeze the UI. */
|
||||
const breathe = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// ---- export ---------------------------------------------------------
|
||||
|
||||
export function exportJson(store: DocumentStore): void {
|
||||
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
|
||||
}
|
||||
|
||||
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
|
||||
const table = store.activeTable.value;
|
||||
if (!table) throw new Error('Keine Tabelle ausgewählt.');
|
||||
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function exportXlsx(store: DocumentStore): Promise<void> {
|
||||
downloadBlob('arbeitsmappe.xlsx', await documentToXlsx(store));
|
||||
}
|
||||
|
||||
// ---- import: JSON ------------------------------------------------
|
||||
|
||||
export function importJsonReplace(store: DocumentStore, text: string): void {
|
||||
store.replaceDocument(parseDocument(text)); // throws JsonImportError on bad schema
|
||||
}
|
||||
|
||||
export function importJsonAppend(store: DocumentStore, text: string): void {
|
||||
store.appendSheets(reidSheets(parseDocument(text)));
|
||||
}
|
||||
|
||||
// ---- import: CSV -----------------------------------------------
|
||||
|
||||
export interface CsvImportConfig {
|
||||
mode: 'new-table' | 'replace-active';
|
||||
delimiter: Delimiter | 'auto';
|
||||
detectNumbers: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function importCsv(
|
||||
store: DocumentStore,
|
||||
text: string,
|
||||
cfg: CsvImportConfig,
|
||||
): Promise<void> {
|
||||
const delimiter: Delimiter = cfg.delimiter === 'auto' ? sniffDelimiter(text) : cfg.delimiter;
|
||||
const matrix = parseCsv(text, delimiter);
|
||||
if (matrix.length > 2000) await breathe();
|
||||
const { cells, rows, cols } = matrixToCells(matrix, cfg.detectNumbers);
|
||||
|
||||
if (cfg.mode === 'replace-active') {
|
||||
const table = store.activeTable.value;
|
||||
if (!table) throw new Error('Keine Zieltabelle ausgewählt.');
|
||||
store.replaceTableCells(table.id, cells, Math.max(rows, 12), Math.max(cols, 6));
|
||||
} else {
|
||||
store.addTableFromCells(
|
||||
store.doc.activeSheetId,
|
||||
cfg.name?.trim() || 'Import',
|
||||
cells,
|
||||
Math.max(rows, 12),
|
||||
Math.max(cols, 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- import: XLSX --------------------------------------------
|
||||
|
||||
export async function importXlsxFile(store: DocumentStore, buffer: ArrayBuffer): Promise<void> {
|
||||
const imported = await xlsxToSheets(buffer);
|
||||
if (imported.length === 0) throw new Error('Die Arbeitsmappe enthält keine Tabellen.');
|
||||
await breathe();
|
||||
store.appendSheets(imported.map((s, i) => importedToSheet(s, i)));
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* .xlsx import/export via SheetJS (the *only* place a spreadsheet library is
|
||||
* used, and never for formula evaluation -- that stays in our engine).
|
||||
*
|
||||
* export: one worksheet per table. Formula cells are written as real Excel
|
||||
* formulas when they translate cleanly (`;` -> `,`, `AVG` ->
|
||||
* `AVERAGE`), always accompanied by the cached value from our engine.
|
||||
* import: one table per worksheet. `cell.f` becomes a formula when our parser
|
||||
* accepts it, otherwise the cached value is taken.
|
||||
*/
|
||||
|
||||
import type * as XLSXNS from 'xlsx';
|
||||
import { parseFormula } from '../engine';
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import type { Sheet, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
/** SheetJS is the only spreadsheet library and it is loaded on demand. */
|
||||
let xlsxModule: typeof XLSXNS | null = null;
|
||||
async function loadXLSX(): Promise<typeof XLSXNS> {
|
||||
if (!xlsxModule) xlsxModule = await import('xlsx');
|
||||
return xlsxModule;
|
||||
}
|
||||
|
||||
/** Swap top-level separators while respecting "..." and '...' quoting. */
|
||||
function swapOutsideQuotes(src: string, from: string, to: string): string {
|
||||
let out = '';
|
||||
let q: '"' | "'" | null = null;
|
||||
for (const ch of src) {
|
||||
if (q) {
|
||||
out += ch;
|
||||
if (ch === q) q = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
q = ch;
|
||||
out += ch;
|
||||
} else if (ch === from) {
|
||||
out += to;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toExcelFormula(input: string): string | null {
|
||||
// input starts with '='
|
||||
let body = input.slice(1);
|
||||
body = swapOutsideQuotes(body, ';', ',');
|
||||
body = body.replace(/\bAVG\s*\(/gi, 'AVERAGE(');
|
||||
return body;
|
||||
}
|
||||
|
||||
function fromExcelFormula(f: string): string | null {
|
||||
const body = swapOutsideQuotes(f, ',', ';');
|
||||
const candidate = '=' + body;
|
||||
try {
|
||||
parseFormula(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSheetName(name: string, taken: Set<string>): string {
|
||||
const clean = name.replace(/[[\]:*?/\\]/g, ' ').trim().slice(0, 31) || 'Tabelle';
|
||||
let candidate = clean;
|
||||
let i = 2;
|
||||
while (taken.has(candidate.toLowerCase())) {
|
||||
candidate = `${clean.slice(0, 28)} ${i++}`;
|
||||
}
|
||||
taken.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ---- export -----------------------------------------------------------
|
||||
|
||||
export async function documentToXlsx(store: DocumentStore): Promise<Blob> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.utils.book_new();
|
||||
const taken = new Set<string>();
|
||||
|
||||
// one worksheet per table object, across all sheets
|
||||
const tables = store.doc.sheets.flatMap((s) =>
|
||||
s.objects.filter((o): o is TableObject => o.kind === 'table'),
|
||||
);
|
||||
for (const table of tables) {
|
||||
const ws: XLSXNS.WorkSheet = {};
|
||||
let maxC = 0;
|
||||
let maxR = 0;
|
||||
|
||||
for (const key of Object.keys(table.cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
const raw = table.cells[key];
|
||||
const addr = XLSX.utils.encode_cell({ c, r });
|
||||
const value = store.cellValue(table.id, c, r);
|
||||
|
||||
if (raw.startsWith('=')) {
|
||||
const f = toExcelFormula(raw);
|
||||
const num = typeof value === 'number' ? value : undefined;
|
||||
ws[addr] =
|
||||
num !== undefined
|
||||
? { t: 'n', v: num, f: f ?? undefined }
|
||||
: { t: 's', v: rawString(value), f: f ?? undefined };
|
||||
} else if (typeof value === 'number') {
|
||||
ws[addr] = { t: 'n', v: value };
|
||||
} else {
|
||||
ws[addr] = { t: 's', v: raw };
|
||||
}
|
||||
if (c > maxC) maxC = c;
|
||||
if (r > maxR) maxR = r;
|
||||
}
|
||||
|
||||
ws['!ref'] = XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: maxC, r: maxR } });
|
||||
XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(table.name, taken));
|
||||
}
|
||||
|
||||
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
|
||||
return new Blob([out], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------------
|
||||
|
||||
export interface ImportedSheet {
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function xlsxToSheets(buffer: ArrayBuffer): Promise<ImportedSheet[]> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellFormula: true, cellNF: false });
|
||||
const result: ImportedSheet[] = [];
|
||||
|
||||
for (const name of wb.SheetNames) {
|
||||
const ws = wb.Sheets[name];
|
||||
const cells: Record<string, string> = {};
|
||||
let rows = 1;
|
||||
let cols = 1;
|
||||
if (ws && ws['!ref']) {
|
||||
const range = XLSX.utils.decode_range(ws['!ref']);
|
||||
for (let r = range.s.r; r <= range.e.r; r++) {
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const cell = ws[XLSX.utils.encode_cell({ r, c })] as XLSXNS.CellObject | undefined;
|
||||
if (!cell) continue;
|
||||
let raw: string | null = null;
|
||||
if (cell.f) raw = fromExcelFormula(String(cell.f));
|
||||
if (raw == null) {
|
||||
if (cell.v == null) continue;
|
||||
raw = cell.t === 'n' ? String(cell.v) : String(cell.w ?? cell.v);
|
||||
}
|
||||
if (raw === '') continue;
|
||||
cells[`${c},${r}`] = raw;
|
||||
rows = Math.max(rows, r + 1);
|
||||
cols = Math.max(cols, c + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push({ name, rows: Math.max(rows, 20), cols: Math.max(cols, 8), cells });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wrap an imported worksheet as a canvas sheet holding one table object. */
|
||||
export function importedToSheet(imported: ImportedSheet, idx: number): Sheet {
|
||||
const stamp = Date.now().toString(36);
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: `xlsx-tbl-${stamp}-${idx}`,
|
||||
name: imported.name,
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: imported.rows,
|
||||
cols: imported.cols,
|
||||
colWidths: {},
|
||||
cells: imported.cells,
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: `xlsx-sheet-${stamp}-${idx}`, name: imported.name, objects: [table] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './styles/main.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,540 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useDocument, type DocumentStore } from '../document';
|
||||
import { isError, toA1 } from '../../engine';
|
||||
import { serializeDocument, parseDocument } from '../../io/json';
|
||||
|
||||
/** The store is a module-level singleton; `resetToSeed()` gives each test a
|
||||
* clean, fully-defined starting document (no localStorage needed here). */
|
||||
function fresh(): DocumentStore {
|
||||
const store = useDocument();
|
||||
store.resetToSeed();
|
||||
return store;
|
||||
}
|
||||
|
||||
/** id of a table object by its (document-unique) name */
|
||||
function tableId(store: DocumentStore, name: string): string {
|
||||
for (const s of store.doc.sheets) {
|
||||
const o = s.objects.find((x) => x.kind === 'table' && x.name === name);
|
||||
if (o) return o.id;
|
||||
}
|
||||
throw new Error(`no table named ${name}`);
|
||||
}
|
||||
|
||||
function coords(a1: string) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1;
|
||||
return { col, row: Number(m[2]) - 1 };
|
||||
}
|
||||
const val = (s: DocumentStore, table: string, a1: string) => {
|
||||
const { col, row } = coords(a1);
|
||||
return s.cellValue(tableId(s, table), col, row);
|
||||
};
|
||||
const setCell = (s: DocumentStore, table: string, a1: string, raw: string) => {
|
||||
const { col, row } = coords(a1);
|
||||
s.setCellInput(tableId(s, table), col, row, raw);
|
||||
};
|
||||
|
||||
describe('seed document acceptance (canvas model)', () => {
|
||||
it('the seed lays three tables + a chart on one sheet, plus an empty sheet', () => {
|
||||
const s = fresh();
|
||||
expect(s.doc.version).toBe(2);
|
||||
expect(s.doc.sheets).toHaveLength(2);
|
||||
const analyse = s.doc.sheets[0];
|
||||
expect(analyse.objects.filter((o) => o.kind === 'table')).toHaveLength(3);
|
||||
expect(analyse.objects.filter((o) => o.kind === 'chart')).toHaveLength(1);
|
||||
expect(s.doc.sheets[1].objects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('aggregates on Umsatz', () => {
|
||||
const s = fresh();
|
||||
expect(val(s, 'Umsatz', 'B8')).toBe(6175);
|
||||
expect(val(s, 'Umsatz', 'B9')).toBe(1235);
|
||||
expect(val(s, 'Umsatz', 'B10')).toBe(980);
|
||||
expect(val(s, 'Umsatz', 'B11')).toBe(1550);
|
||||
expect(val(s, 'Umsatz', 'B12')).toBe(5);
|
||||
});
|
||||
|
||||
it('Übersicht cross-table totals (Umsatz!B2:E2 = 5970, see seed.ts note)', () => {
|
||||
const s = fresh();
|
||||
expect(val(s, 'Übersicht', 'B2')).toBe(5970);
|
||||
expect(val(s, 'Übersicht', 'C2')).toBe(3400);
|
||||
expect(val(s, 'Übersicht', 'D2')).toBe(2570);
|
||||
expect(val(s, 'Übersicht', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
|
||||
});
|
||||
|
||||
it('editing Umsatz!B2 ripples through the whole chain', () => {
|
||||
const s = fresh();
|
||||
setCell(s, 'Umsatz', 'B2', '2200'); // +1000
|
||||
expect(val(s, 'Umsatz', 'B8')).toBe(7175);
|
||||
expect(val(s, 'Übersicht', 'B2')).toBe(6970);
|
||||
expect(val(s, 'Übersicht', 'D2')).toBe(3570);
|
||||
const perRegion = [2, 3, 4, 5, 6].map((r) => val(s, 'Übersicht', `B${r}`) as number);
|
||||
expect(val(s, 'Übersicht', 'B8')).toBe(perRegion.reduce((a, b) => a + b, 0));
|
||||
expect(perRegion[0]).toBe(6970);
|
||||
});
|
||||
|
||||
it('precedence & associativity in a live cell', () => {
|
||||
const s = fresh();
|
||||
setCell(s, 'Umsatz', 'G20', '=(B2+C2)*2-D2/4');
|
||||
expect(val(s, 'Umsatz', 'G20')).toBe((1200 + 1450) * 2 - 1600 / 4);
|
||||
setCell(s, 'Umsatz', 'G21', '=2^3^2');
|
||||
expect(val(s, 'Umsatz', 'G21')).toBe(512);
|
||||
});
|
||||
|
||||
it('table-name matching is case/quote tolerant and accepts "::"', () => {
|
||||
const s = fresh();
|
||||
setCell(s, 'Umsatz', 'G16', "='Kosten'!B2");
|
||||
setCell(s, 'Umsatz', 'G17', '=kosten!b2');
|
||||
setCell(s, 'Umsatz', 'G18', '=Kosten::B2');
|
||||
expect(val(s, 'Umsatz', 'G16')).toBe(800);
|
||||
expect(val(s, 'Umsatz', 'G17')).toBe(800);
|
||||
expect(val(s, 'Umsatz', 'G18')).toBe(800);
|
||||
});
|
||||
|
||||
it('every error kind is classified', () => {
|
||||
const s = fresh();
|
||||
setCell(s, 'Umsatz', 'G14', '=1/0');
|
||||
setCell(s, 'Umsatz', 'G15', '=FOO(1)');
|
||||
setCell(s, 'Umsatz', 'G13', '=A1+');
|
||||
setCell(s, 'Umsatz', 'G12', '=Nix!A1');
|
||||
setCell(s, 'Umsatz', 'A1', '=A1');
|
||||
expect((val(s, 'Umsatz', 'G14') as { code: string }).code).toBe('#DIV/0!');
|
||||
expect((val(s, 'Umsatz', 'G15') as { code: string }).code).toBe('#NAME?');
|
||||
expect((val(s, 'Umsatz', 'G13') as { code: string }).code).toBe('#ERR');
|
||||
expect((val(s, 'Umsatz', 'G12') as { code: string }).code).toBe('#REF');
|
||||
expect((val(s, 'Umsatz', 'A1') as { code: string }).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('deleting the "Kosten" table turns dependents into #REF, no crash', () => {
|
||||
const s = fresh();
|
||||
s.deleteObject(tableId(s, 'Kosten'));
|
||||
for (let r = 2; r <= 6; r++) expect(isError(val(s, 'Übersicht', `C${r}`))).toBe(true);
|
||||
expect(val(s, 'Übersicht', 'B2')).toBe(5970); // unrelated cell still fine
|
||||
});
|
||||
|
||||
it('resetToSeed restores the prefilled document', () => {
|
||||
const s = fresh();
|
||||
setCell(s, 'Umsatz', 'B2', '99999');
|
||||
s.resetToSeed();
|
||||
expect(val(s, 'Umsatz', 'B2')).toBe(1200);
|
||||
expect(val(s, 'Übersicht', 'B2')).toBe(5970);
|
||||
});
|
||||
|
||||
it('inserting a new table and referencing it across sheets works', () => {
|
||||
const s = fresh();
|
||||
const sheet2 = s.doc.sheets[1].id;
|
||||
const id = s.addTable(sheet2);
|
||||
s.renameObject(id, 'Extra');
|
||||
s.setCellInput(id, 0, 0, '=Umsatz!B8 + 25'); // A1 on the new table
|
||||
expect(s.cellValue(id, 0, 0)).toBe(6175 + 25);
|
||||
});
|
||||
|
||||
it('selection aggregate & cell name are live', () => {
|
||||
const s = fresh();
|
||||
const umsatz = tableId(s, 'Umsatz');
|
||||
s.setSelection({ tableId: umsatz, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 5 } });
|
||||
expect(s.selectionAggregate.value.sum).toBe(6175);
|
||||
expect(s.selectionAggregate.value.count).toBe(5);
|
||||
expect(s.activeCellName.value).toBe(`Umsatz::${toA1(1, 5)}`);
|
||||
});
|
||||
|
||||
it('recalculation visits precedents before dependents across tables', () => {
|
||||
const s = fresh();
|
||||
const wb = s.engine();
|
||||
const { order, circular } = wb.debugOrder([
|
||||
// keyOf is internal; use the store to find the id then build the key shape
|
||||
`${tableId(s, 'Umsatz')}␟1␟1`,
|
||||
]);
|
||||
expect(circular).toEqual([]);
|
||||
const uid = tableId(s, 'Übersicht');
|
||||
const pos = (k: string) => order.indexOf(k);
|
||||
expect(pos(`${uid}␟1␟1`)).toBeGreaterThan(-1);
|
||||
expect(pos(`${uid}␟1␟1`)).toBeLessThan(pos(`${uid}␟3␟1`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('table view: frozen rows/cols + scroll', () => {
|
||||
it('seed marks "Umsatz" as a fixed, scrollable table with frozen header row & column', () => {
|
||||
const s = fresh();
|
||||
const t = s.tableById(tableId(s, 'Umsatz'))!;
|
||||
expect(t.view.mode).toBe('fixed');
|
||||
expect(t.view.frozenRows).toBe(1);
|
||||
expect(t.view.frozenCols).toBe(1);
|
||||
});
|
||||
|
||||
it('setTableView clamps frozen counts to the grid size', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten'); // 12 rows x 8 cols
|
||||
s.setTableView(id, { mode: 'fixed', frozenRows: 999, frozenCols: 999 });
|
||||
const t = s.tableById(id)!;
|
||||
expect(t.view.frozenRows).toBe(11);
|
||||
expect(t.view.frozenCols).toBe(7);
|
||||
});
|
||||
|
||||
it('shrinking a table drops out-of-range cells and re-clamps frozen counts', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
s.setTableView(id, { mode: 'fixed', frozenRows: 5, frozenCols: 4 });
|
||||
s.setTableRows(id, 3);
|
||||
s.setTableCols(id, 2);
|
||||
const t = s.tableById(id)!;
|
||||
expect(t.rows).toBe(3);
|
||||
expect(t.cols).toBe(2);
|
||||
expect(t.view.frozenRows).toBe(2);
|
||||
expect(t.view.frozenCols).toBe(1);
|
||||
// a cell that was at C6 is gone
|
||||
expect(s.cellValue(id, 2, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('view settings survive a JSON round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Übersicht');
|
||||
s.setTableView(id, { mode: 'fixed', width: 500, height: 260, frozenRows: 1, frozenCols: 2 });
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number-format locale (object -> sheet -> document cascade)', () => {
|
||||
it('cellDisplay uses the document locale by default', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
expect(s.doc.locale).toBe('de-DE');
|
||||
// B8 = 6175 -> "6.175" in de-DE
|
||||
expect(s.cellDisplay(id, 1, 7)).toBe('6.175');
|
||||
});
|
||||
|
||||
it('a sheet-level override wins over the document', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setSheetLocale(s.doc.sheets[0].id, 'en-US');
|
||||
expect(s.cellDisplay(id, 1, 7)).toBe('6,175');
|
||||
expect(s.effectiveLocale(id)).toBe('en-US');
|
||||
});
|
||||
|
||||
it('an object-level override wins over the sheet', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setSheetLocale(s.doc.sheets[0].id, 'en-US');
|
||||
s.setObjectLocale(id, 'plain');
|
||||
expect(s.cellDisplay(id, 1, 7)).toBe('6175');
|
||||
// a different table on the same sheet still follows the sheet override
|
||||
expect(s.cellDisplay(tableId(s, 'Übersicht'), 1, 1)).toBe('5,970');
|
||||
});
|
||||
|
||||
it('clearing an override falls back down the cascade', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setObjectLocale(id, 'fr-FR');
|
||||
expect(s.effectiveLocale(id)).toBe('fr-FR');
|
||||
s.setObjectLocale(id, null);
|
||||
expect(s.effectiveLocale(id)).toBe('de-DE');
|
||||
expect(s.tableById(id)!.locale).toBeUndefined();
|
||||
});
|
||||
|
||||
it('locale overrides survive a JSON round-trip; invalid values are dropped', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
s.setDocumentLocale('en-GB');
|
||||
s.setSheetLocale(s.doc.sheets[0].id, 'de-CH');
|
||||
s.setObjectLocale(id, 'it-IT');
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
expect(s.doc.locale).toBe('en-GB');
|
||||
|
||||
// a garbage locale in imported JSON is ignored, not fatal
|
||||
const doc = JSON.parse(serializeDocument(s.snapshot()));
|
||||
doc.locale = 'kl-KL';
|
||||
doc.sheets[0].objects[0].locale = 'zz';
|
||||
s.replaceDocument(parseDocument(JSON.stringify(doc)));
|
||||
expect(s.doc.locale).toBe('de-DE');
|
||||
const first = s.doc.sheets[0].objects[0];
|
||||
expect(first.kind === 'table' && first.locale).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-sheet appearance, zoom & CSV default (saved in the file)', () => {
|
||||
it('seed ships "Blatt 2" with a backdrop colour and the grid hidden', () => {
|
||||
const s = fresh();
|
||||
const b2 = s.doc.sheets[1];
|
||||
expect(b2.background).toBe('#f4f1ff');
|
||||
expect(b2.showGrid).toBe(false);
|
||||
});
|
||||
|
||||
it('background / foreground / grid are per-sheet and clearable', () => {
|
||||
const s = fresh();
|
||||
const a = s.doc.sheets[0].id;
|
||||
const b = s.doc.sheets[1].id;
|
||||
s.setSheetBackground(a, '#102030');
|
||||
s.setSheetForeground(a, '#eef');
|
||||
s.setSheetShowGrid(a, false);
|
||||
expect(s.doc.sheets[0]).toMatchObject({ background: '#102030', foreground: '#eef', showGrid: false });
|
||||
expect(s.doc.sheets[1].background).toBe('#f4f1ff'); // untouched
|
||||
s.setSheetBackground(a, null);
|
||||
s.setSheetForeground(a, null);
|
||||
expect(s.doc.sheets[0].background).toBeUndefined();
|
||||
expect(s.doc.sheets[0].foreground).toBeUndefined();
|
||||
void b;
|
||||
});
|
||||
|
||||
it('zoom is stored per sheet; the active-sheet zoom follows the tab', () => {
|
||||
const s = fresh();
|
||||
expect(s.activeZoom.value).toBe(1);
|
||||
s.setZoom(1.5); // targets the active sheet
|
||||
expect(s.activeZoom.value).toBe(1.5);
|
||||
expect(s.doc.sheets[0].zoom).toBe(1.5);
|
||||
s.setActiveSheet(s.doc.sheets[1].id);
|
||||
expect(s.activeZoom.value).toBe(1); // sheet 2 still default
|
||||
s.setZoom(0.8);
|
||||
s.setActiveSheet(s.doc.sheets[0].id);
|
||||
expect(s.activeZoom.value).toBe(1.5); // sheet 1 kept its own
|
||||
});
|
||||
|
||||
it('appearance + zoom + csvDelimiter survive a JSON round-trip; bad values dropped', () => {
|
||||
const s = fresh();
|
||||
const a = s.doc.sheets[0].id;
|
||||
s.setSheetBackground(a, '#abcdef');
|
||||
s.setSheetForeground(a, '#012');
|
||||
s.setSheetShowGrid(a, false);
|
||||
s.setSheetZoom(a, 1.25);
|
||||
s.setCsvDelimiter(';');
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
expect(s.doc.csvDelimiter).toBe(';');
|
||||
|
||||
const doc = JSON.parse(serializeDocument(s.snapshot()));
|
||||
doc.sheets[0].background = 'not-a-color';
|
||||
doc.sheets[0].zoom = 99;
|
||||
doc.csvDelimiter = 'x';
|
||||
s.replaceDocument(parseDocument(JSON.stringify(doc)));
|
||||
expect(s.doc.sheets[0].background).toBeUndefined();
|
||||
expect(s.doc.sheets[0].zoom).toBe(2); // clamped, not dropped
|
||||
expect(s.doc.csvDelimiter).toBe(',');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cell formatting, number formats, row height', () => {
|
||||
it('per-cell number format combines with the locale', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
const norm = (x: unknown) => String(x).replace(/[\u00a0\u202f]/g, " ");
|
||||
s.setCellInput(id, 6, 0, '0.1234'); // G1
|
||||
expect(s.cellDisplay(id, 6, 0)).toBe('0,1234'); // auto, de-DE
|
||||
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'pct2' });
|
||||
expect(norm(s.cellDisplay(id, 6, 0))).toBe('12,34 %');
|
||||
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'int' });
|
||||
expect(s.cellDisplay(id, 6, 0)).toBe('0');
|
||||
});
|
||||
|
||||
it('setRangeFormat applies to a whole range and clears with falsy/auto', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 2, row: 3 }, { align: 'center', bold: true });
|
||||
expect(s.cellFormat(id, 1, 1)).toEqual({ align: 'center', bold: true });
|
||||
expect(s.cellFormat(id, 2, 3)).toEqual({ align: 'center', bold: true });
|
||||
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 2, row: 3 }, { bold: false, align: 'auto' });
|
||||
expect(s.cellFormat(id, 1, 1)).toEqual({});
|
||||
});
|
||||
|
||||
it('selectionFormat reports only the values shared across the selection', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 1, row: 1 }, { align: 'right', color: '#ff0000' });
|
||||
s.setRangeFormat(id, { col: 1, row: 2 }, { col: 1, row: 2 }, { align: 'right', color: '#00ff00' });
|
||||
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 2 } });
|
||||
expect(s.selectionFormat.value).toEqual({ align: 'right' }); // color differs -> dropped
|
||||
});
|
||||
|
||||
it('row height: per-row override beats the table default', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
expect(s.rowHeightOf(id, 0)).toBe(26); // DEFAULT_ROW_H
|
||||
s.setTableRowHeight(id, 34);
|
||||
expect(s.rowHeightOf(id, 0)).toBe(34);
|
||||
s.setRowHeight(id, 2, 60);
|
||||
expect(s.rowHeightOf(id, 2)).toBe(60);
|
||||
expect(s.rowHeightOf(id, 1)).toBe(34);
|
||||
});
|
||||
|
||||
it('column width can now go well below the old 48px floor', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
s.setColWidth(id, 0, 8);
|
||||
expect(s.tableById(id)!.colWidths[0]).toBe(24); // new minimum
|
||||
});
|
||||
|
||||
it('shrinking a table prunes stale formats and row heights', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
s.setRangeFormat(id, { col: 5, row: 8 }, { col: 5, row: 8 }, { bold: true });
|
||||
s.setRowHeight(id, 9, 50);
|
||||
s.setTableRows(id, 4);
|
||||
s.setTableCols(id, 3);
|
||||
expect(s.tableById(id)!.formats).toBeUndefined();
|
||||
expect(s.tableById(id)!.rowHeights).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formatting + row heights survive a JSON round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 3, row: 4 }, { numFmt: 'dec2', bg: '#eef' });
|
||||
s.setTableRowHeight(id, 30);
|
||||
s.setRowHeight(id, 5, 48);
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-cell (discontiguous) selection', () => {
|
||||
it('addToSelection keeps prior rectangles and moves the active cell', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 3 } });
|
||||
s.addToSelection(id, 4, 6);
|
||||
const sel = s.doc.selection!;
|
||||
expect(sel.focus).toEqual({ col: 4, row: 6 }); // active cell follows the click
|
||||
expect(sel.ranges).toEqual([{ anchor: { col: 1, row: 1 }, focus: { col: 1, row: 3 } }]);
|
||||
// two rectangles -> 3 + 1 cells
|
||||
expect(s.selectionAggregate.value.cells).toBe(4);
|
||||
});
|
||||
|
||||
it('ctrl-clicking an extra single cell again toggles it off', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
||||
s.addToSelection(id, 2, 2); // add B..C? -> cell (2,2)
|
||||
expect(s.doc.selection!.ranges).toHaveLength(1);
|
||||
s.addToSelection(id, 2, 2); // click it again -> off
|
||||
expect(s.doc.selection!.ranges).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extendActiveRange grows the most recent rectangle', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz');
|
||||
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
||||
s.addToSelection(id, 3, 3);
|
||||
s.extendActiveRange(id, 5, 4);
|
||||
expect(s.doc.selection!.focus).toEqual({ col: 5, row: 4 });
|
||||
// primary rect 3..5 x 3..4 = 6 cells, plus the A1 cell = 7
|
||||
expect(s.selectionAggregate.value.cells).toBe(7);
|
||||
});
|
||||
|
||||
it('aggregate & formatting span every selected rectangle', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Umsatz'); // B2:B6 = 1200 980 1550 1340 1105
|
||||
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 1 } }); // B2
|
||||
s.addToSelection(id, 1, 3); // + B4
|
||||
s.addToSelection(id, 1, 5); // + B6 (active)
|
||||
expect(s.selectionAggregate.value.sum).toBe(1200 + 1550 + 1105);
|
||||
expect(s.selectionAggregate.value.count).toBe(3);
|
||||
|
||||
s.applySelectionFormat({ bold: true });
|
||||
expect(s.cellFormat(id, 1, 1)).toEqual({ bold: true });
|
||||
expect(s.cellFormat(id, 1, 3)).toEqual({ bold: true });
|
||||
expect(s.cellFormat(id, 1, 5)).toEqual({ bold: true });
|
||||
expect(s.cellFormat(id, 1, 2)).toEqual({}); // B3 was never selected
|
||||
|
||||
s.clearSelectionCells();
|
||||
expect(s.cellValue(id, 1, 1)).toBeNull();
|
||||
expect(s.cellValue(id, 1, 3)).toBeNull();
|
||||
expect(s.cellValue(id, 1, 2)).toBe(980); // untouched
|
||||
});
|
||||
|
||||
it('the multi-selection survives a JSON round-trip and is clamped on shrink', () => {
|
||||
const s = fresh();
|
||||
const id = tableId(s, 'Kosten');
|
||||
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
||||
s.addToSelection(id, 6, 9);
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
s.setTableRows(id, 4);
|
||||
s.setTableCols(id, 3);
|
||||
for (const r of [s.doc.selection!.focus, ...(s.doc.selection!.ranges ?? []).flatMap((x) => [x.anchor, x.focus])]) {
|
||||
expect(r.col).toBeLessThan(3);
|
||||
expect(r.row).toBeLessThan(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('text box objects', () => {
|
||||
it('insert / edit / format / round-trip a text object', () => {
|
||||
const s = fresh();
|
||||
const sheetId = s.doc.sheets[0].id;
|
||||
const id = s.addText(sheetId);
|
||||
const o = s.objectById(id)!;
|
||||
expect(o.kind).toBe('text');
|
||||
s.setTextContent(id, 'Hallo\nWelt');
|
||||
s.setTextFormat(id, { fontSize: 22, bold: true, align: 'center', color: '#123456' });
|
||||
s.setObjectBg(id, '#ffffcc');
|
||||
const t = s.objectById(id);
|
||||
expect(t).toMatchObject({
|
||||
kind: 'text',
|
||||
text: 'Hallo\nWelt',
|
||||
fontSize: 22,
|
||||
bold: true,
|
||||
align: 'center',
|
||||
color: '#123456',
|
||||
bg: '#ffffcc',
|
||||
});
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('a garbage text object in imported JSON is rejected, document unchanged', () => {
|
||||
const s = fresh();
|
||||
const b = s.snapshot();
|
||||
const doc = JSON.parse(serializeDocument(b));
|
||||
doc.sheets[0].objects.push({ kind: 'text', id: 'x' }); // missing required fields
|
||||
expect(() => s.replaceDocument(parseDocument(JSON.stringify(doc)))).toThrow();
|
||||
expect(s.snapshot()).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-object background & text colour', () => {
|
||||
it('table / chart / text each carry their own bg + fg and round-trip', () => {
|
||||
const s = fresh();
|
||||
const tId = tableId(s, 'Umsatz');
|
||||
const cId = s.doc.sheets[0].objects.find((o) => o.kind === 'chart')!.id;
|
||||
s.setObjectBg(tId, '#101010');
|
||||
s.setObjectTextColor(tId, '#fafafa');
|
||||
s.setObjectBg(cId, '#202020');
|
||||
s.setObjectTextColor(cId, '#eeeeee');
|
||||
const tbl = s.tableById(tId)!;
|
||||
expect([tbl.bg, tbl.fg]).toEqual(['#101010', '#fafafa']);
|
||||
const before = s.snapshot();
|
||||
s.replaceDocument(parseDocument(serializeDocument(before)));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
s.setObjectBg(tId, null);
|
||||
expect(s.tableById(tId)!.bg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 -> v2 migration', () => {
|
||||
it('turns each old sheet into a canvas sheet holding one table of the same name', async () => {
|
||||
const { migrateToV2 } = await import('../../io/json');
|
||||
const v1 = {
|
||||
version: 1,
|
||||
sheets: [
|
||||
{ id: 'a', name: 'Umsatz', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '7', '1,0': '=A1*2' } },
|
||||
{ id: 'b', name: 'Report', rows: 4, cols: 2, colWidths: {}, cells: { '0,0': '=Umsatz!A1' } },
|
||||
],
|
||||
activeSheetId: 'b',
|
||||
selection: { sheetId: 'b', anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
};
|
||||
const v2 = migrateToV2(v1);
|
||||
expect(v2.version).toBe(2);
|
||||
expect(v2.sheets).toHaveLength(2);
|
||||
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
|
||||
// cross-sheet formula still resolves after loading it
|
||||
const store = useDocument();
|
||||
store.replaceDocument(v2);
|
||||
const rep = tableId(store, 'Report');
|
||||
expect(store.cellValue(rep, 0, 0)).toBe(7);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { isError, type CellValue } from '../engine';
|
||||
import type { CellNumFmt } from './types';
|
||||
|
||||
/**
|
||||
* Locale used to render numeric values. `'plain'` is not a real locale -- it
|
||||
* renders the bare JS number (`.` decimal, no grouping).
|
||||
*
|
||||
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
|
||||
* locale-neutral so round-trips are lossless.
|
||||
*/
|
||||
export type NumberLocale =
|
||||
| 'plain'
|
||||
| 'de-DE'
|
||||
| 'de-CH'
|
||||
| 'en-US'
|
||||
| 'en-GB'
|
||||
| 'fr-FR'
|
||||
| 'it-IT';
|
||||
|
||||
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
|
||||
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
|
||||
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
|
||||
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
|
||||
{ id: 'en-US', label: 'English (US) · 1,234.5' },
|
||||
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
|
||||
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
|
||||
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
|
||||
];
|
||||
|
||||
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
|
||||
export function isNumberLocale(x: unknown): x is NumberLocale {
|
||||
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
|
||||
}
|
||||
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
|
||||
|
||||
/** Cell number-format options offered in the UI. */
|
||||
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
||||
{ id: 'auto', label: 'Automatisch' },
|
||||
{ id: 'plain', label: 'Ohne Formatierung' },
|
||||
{ id: 'int', label: 'Ganzzahl · 1.235' },
|
||||
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
|
||||
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
|
||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||
];
|
||||
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
|
||||
export function isCellNumFmt(x: unknown): x is CellNumFmt {
|
||||
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
|
||||
|
||||
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
|
||||
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
|
||||
useGrouping: locale !== 'plain',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
/** Render a computed value for display (grid cell / status bar / chart). */
|
||||
export function formatValue(
|
||||
v: CellValue,
|
||||
locale: NumberLocale = DEFAULT_LOCALE,
|
||||
numFmt: CellNumFmt = 'auto',
|
||||
): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v !== 'number') return v;
|
||||
if (!Number.isFinite(v)) return '#NUM!';
|
||||
|
||||
const n = round10(v);
|
||||
switch (numFmt) {
|
||||
case 'plain':
|
||||
return String(n);
|
||||
case 'int':
|
||||
return nf(locale, { maximumFractionDigits: 0 }).format(n);
|
||||
case 'dec1':
|
||||
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
|
||||
case 'dec2':
|
||||
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
case 'pct0':
|
||||
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
|
||||
case 'pct2':
|
||||
return nf(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
default: // 'auto'
|
||||
return locale === 'plain'
|
||||
? String(n)
|
||||
: nf(locale, { maximumFractionDigits: 10 }).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
|
||||
export function rawString(v: CellValue): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v === 'number') return String(round10(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Persisted / JSON-exported document shape. **Version 2** — Numbers-style
|
||||
* canvas model: a sheet is a free surface holding table, chart and text *objects*.
|
||||
*
|
||||
* (Version 1 was one grid per sheet; `io/json.ts` migrates v1 -> v2 on import.)
|
||||
*/
|
||||
|
||||
import type { NumberLocale } from './format';
|
||||
|
||||
export interface CellCoord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
export type HAlign = 'left' | 'center' | 'right';
|
||||
export type VAlign = 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Per-cell number-display rule, combined with the effective locale. */
|
||||
export type CellNumFmt =
|
||||
| 'auto'
|
||||
| 'plain'
|
||||
| 'int' // 0 decimals, grouped
|
||||
| 'dec1' // 1 decimal
|
||||
| 'dec2' // 2 decimals
|
||||
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
|
||||
| 'pct2'; // percent, 2 decimals
|
||||
|
||||
/** Explicit formatting for a single cell (all fields optional = inherit). */
|
||||
export interface CellFormat {
|
||||
align?: HAlign;
|
||||
valign?: VAlign;
|
||||
numFmt?: CellNumFmt;
|
||||
/** text colour (#rgb/#rrggbb/#rrggbbaa) */
|
||||
color?: string;
|
||||
/** fill colour */
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
export interface TableView {
|
||||
mode: 'auto' | 'fixed';
|
||||
width: number;
|
||||
height: number;
|
||||
frozenRows: number;
|
||||
frozenCols: number;
|
||||
}
|
||||
|
||||
export function defaultTableView(): TableView {
|
||||
return { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 };
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW_H = 26;
|
||||
|
||||
/** A table object placed on a sheet canvas. */
|
||||
export interface TableObject {
|
||||
kind: 'table';
|
||||
id: string;
|
||||
/** document-unique name; the qualifier used in formulas (`Umsatz!B2`) */
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** column index -> pixel width (only non-default widths are stored) */
|
||||
colWidths: Record<number, number>;
|
||||
/** default row height (px); unset = DEFAULT_ROW_H */
|
||||
rowHeight?: number;
|
||||
/** row index -> pixel height override */
|
||||
rowHeights?: Record<number, number>;
|
||||
/** "col,row" -> raw user input (formula string or literal). Empty cells absent. */
|
||||
cells: Record<string, string>;
|
||||
/** "col,row" -> explicit cell formatting */
|
||||
formats?: Record<string, CellFormat>;
|
||||
/** sizing + frozen rows/cols */
|
||||
view: TableView;
|
||||
/** number-format locale override; falls back to the sheet, then the document */
|
||||
locale?: NumberLocale;
|
||||
/** object background colour override (grid surface) */
|
||||
bg?: string;
|
||||
/** object default text colour override */
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A chart object placed on a sheet canvas. Freely positioned and resized. */
|
||||
export interface ChartObject {
|
||||
kind: 'chart';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
view: 'bar' | 'line';
|
||||
sourceTableId: string | null;
|
||||
locale?: NumberLocale;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A free-floating formatted text box, a companion to tables and charts. */
|
||||
export interface TextObject {
|
||||
kind: 'text';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
align: HAlign;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject;
|
||||
|
||||
export interface Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
/** array order == z-order; the last element renders in front */
|
||||
objects: SheetObject[];
|
||||
locale?: NumberLocale;
|
||||
/** canvas backdrop colour; unset = themed default */
|
||||
background?: string;
|
||||
/** default text colour for this sheet's grids/labels; unset = themed default */
|
||||
foreground?: string;
|
||||
/** show the canvas dot-grid; unset / true = shown */
|
||||
showGrid?: boolean;
|
||||
/** per-sheet canvas zoom (0.4 – 2); unset = 1 */
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
tableId: string;
|
||||
/** primary rectangle; `focus` is the active cell (formula bar / keyboard) */
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
/** additional rectangles for a discontiguous multi-selection */
|
||||
ranges?: SelectionRange[];
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
activeSheetId: string;
|
||||
activeObjectId: string | null;
|
||||
selection: SelectionState | null;
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Design tokens. Light palette on :root, dark overrides below.
|
||||
* A manual [data-theme] on <html> wins over prefers-color-scheme.
|
||||
* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fbfbfd;
|
||||
--header-bg: #f0f0f3;
|
||||
--text: #1d1d1f;
|
||||
--text-dim: #6e6e73;
|
||||
--border: #d9d9de;
|
||||
--grid-line: #e6e6ea;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.14);
|
||||
--accent-header: rgba(10, 132, 255, 0.22);
|
||||
--danger: #ff3b30;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 6px 20px rgba(0, 0, 0, 0.06);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
|
||||
/* chart series -- consistent across bar & line */
|
||||
--series-1: #0a84ff;
|
||||
--series-2: #30d158;
|
||||
--series-3: #ff9f0a;
|
||||
--series-4: #ff375f;
|
||||
--series-5: #bf5af2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--bg: #1c1c1e;
|
||||
--surface: #2c2c2e;
|
||||
--surface-2: #262628;
|
||||
--header-bg: #333336;
|
||||
--text: #f5f5f7;
|
||||
--text-dim: #a1a1a6;
|
||||
--border: #3a3a3d;
|
||||
--grid-line: #38383b;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--accent-header: rgba(10, 132, 255, 0.32);
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #1c1c1e;
|
||||
--surface: #2c2c2e;
|
||||
--surface-2: #262628;
|
||||
--header-bg: #333336;
|
||||
--text: #f5f5f7;
|
||||
--text-dim: #a1a1a6;
|
||||
--border: #3a3a3d;
|
||||
--grid-line: #38383b;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--accent-header: rgba(10, 132, 255, 0.32);
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden; /* the app manages its own scroll containers */
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
Reference in New Issue
Block a user