Files
numolus/src/components/SettingsPanel.vue
T
2026-08-29 17:47:12 +02:00

664 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 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>