Makros: UI (Button-Objekt, Verwaltungspanel, Symbolleiste)

Zweiter von zwei Commits (Fundament in e670f13). Neues, frei platzierbares
Button-Objekt auf der Fläche, das per Klick ein zugewiesenes Makro
ausführt (Zuweisung im Inspektor oder per Rechtsklick direkt am Button).
Neues "▶ Makros"-Panel (Liste + Formular, dritter "▶ Ausführen"-Button pro
Zeile) für Anlegen/Bearbeiten/Löschen/Testlauf ohne Canvas-Button.

Beim Live-Testen gefunden und behoben: ADDTABLE/ADDSHEET navigieren als
Nebeneffekt zur eingefügten Tabelle/zum eingefügten Blatt (richtig für
einen Symbolleisten-Klick, überraschend für einen Makro-Lauf im
Hintergrund) — runMacro() sichert und stellt jetzt aktives Blatt/Objekt/
Auswahl nach dem Lauf wieder her. Nebenbei zwei bestehende
"impliziter-Else"-Fehler behoben, die mit einer vierten Objektart zu
echten Fehlerquellen geworden wären (io/json.ts normaliseObject,
CanvasObject.vue Render-Dispatch).

Vollständig live per Playwright End-zu-Ende verifiziert: Makro über das
echte Formular angelegt, per Listen-Button und per Button-Objekt
ausgeführt, Rechtsklick-Zuweisung geprüft, ein absichtlich fehlerhaftes
Makro zeigt einen Fehler-Toast statt abzustürzen — keine Konsolenfehler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 10:07:56 +02:00
co-authored by Claude Sonnet 5
parent e670f13077
commit a59fda25b0
11 changed files with 759 additions and 30 deletions
+18
View File
@@ -6,6 +6,7 @@ import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
import TheLogo from './TheLogo.vue';
import ToolbarIcon from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
@@ -32,6 +33,7 @@ const fileMenuOpen = ref(false);
const aboutOpen = ref(false);
const functionsOpen = ref(false);
const namedRangesOpen = ref(false);
const macrosOpen = ref(false);
const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
@@ -54,6 +56,10 @@ function insertText() {
store.addText(store.doc.activeSheetId);
toasts.info('Textfeld eingefügt.');
}
function insertButton() {
store.addButton(store.doc.activeSheetId);
toasts.info('Button eingefügt.');
}
// ---- CSV export dialog -------------------------------------------
const csvExportOpen = ref(false);
@@ -245,6 +251,9 @@ function onReset() {
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
<ToolbarIcon name="text" />
</button>
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
<ToolbarIcon name="button" />
</button>
</div>
<div class="tgroup accent">
@@ -264,6 +273,14 @@ function onReset() {
>
<ToolbarIcon name="tag" />
</button>
<button
class="ibtn"
title="Makros"
aria-label="Makros"
@click="macrosOpen = true"
>
<ToolbarIcon name="macro" />
</button>
</div>
<div class="spacer" />
@@ -360,6 +377,7 @@ function onReset() {
<!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* A clickable canvas button object -- one of the two ways to trigger a
* macro (the other is the "▶ Ausführen" row in MacrosModal.vue). Caption
* (`label`) and colours are edited in the inspector (SettingsPanel.vue);
* which macro it runs is assignable both there and from this component's
* own right-click menu, for a quicker "build a button, wire it up, done"
* loop without leaving the canvas.
*/
import { computed, reactive } from 'vue';
import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts';
import ContextMenu from './ContextMenu.vue';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const toasts = useToasts();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'button' ? o : undefined;
});
function run() {
const o = obj.value;
if (!o) return;
if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) {
toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.');
return;
}
const result = store.runMacro(o.macroId);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
}
const boxStyle = computed(() => {
const o = obj.value;
if (!o) return {};
return {
color: o.fg || 'var(--accent)',
background: o.bg || 'var(--accent-weak)',
} as Record<string, string>;
});
// ---- right-click: run / (re)assign a macro without opening the inspector ----
const menu = reactive({ open: false, x: 0, y: 0 });
function onContextMenu(e: MouseEvent) {
e.preventDefault();
menu.x = e.clientX;
menu.y = e.clientY;
menu.open = true;
}
const menuItems = computed(() => {
const o = obj.value;
if (!o) return [];
const items: { label: string; action: () => void }[] = [];
if (o.macroId) {
items.push({ label: '▶ Makro ausführen', action: run });
}
for (const m of store.doc.macros) {
items.push({
label: `${m.id === o.macroId ? '✓ ' : ''}Makro: ${m.name}`,
action: () => store.setButtonMacroId(o.id, m.id),
});
}
if (store.doc.macros.length === 0) {
items.push({ label: 'Kein Makro vorhanden — erst in „▶ Makros" anlegen', action: () => {} });
}
if (o.macroId) {
items.push({ label: 'Zuweisung entfernen', action: () => store.setButtonMacroId(o.id, null) });
}
return items;
});
</script>
<template>
<div
v-if="obj"
class="btnbox"
:style="boxStyle"
@click="run"
@contextmenu="onContextMenu"
>
<span class="label">{{ obj.label || 'Button' }}</span>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
</div>
</template>
<style scoped>
.btnbox {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
font-weight: 600;
font-size: 14px;
text-align: center;
cursor: pointer;
user-select: none;
}
.btnbox:hover {
filter: brightness(1.06);
}
.btnbox:active {
filter: brightness(0.96);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+23 -9
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
/**
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
* Draggable / resizable wrapper for a table, chart, text or button object on
* the sheet canvas.
*
* - the header strip and the frame border move the object;
* - the SE handle resizes a chart/text freely; for a table, what it does
@@ -25,6 +26,7 @@ import { useDocument } from '../store/document';
import TableGrid from './TableGrid.vue';
import ChartView from './ChartView.vue';
import TextBox from './TextBox.vue';
import ButtonView from './ButtonView.vue';
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
const emit = defineEmits<{ (e: 'select'): void }>();
@@ -139,7 +141,7 @@ function lastNonEmptyCol(t: TableObject): number {
* "Automatisch" vs. "Feste Größe" split. */
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
const o = props.object;
if (o.kind === 'chart' || o.kind === 'text') return 'free';
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
});
@@ -150,7 +152,7 @@ function onResizeDown(e: MouseEvent) {
const o = props.object;
let w: number;
let h: number;
if (o.kind === 'chart' || o.kind === 'text') {
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
w = o.width;
h = o.height;
} else if (o.view.mode === 'fixed') {
@@ -174,6 +176,7 @@ function onResizeMove(e: MouseEvent) {
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 (o.kind === 'button') store.resizeButton(o.id, rs.w + dx, rs.h + dy);
} else if (resizeKind.value === 'viewport') {
// "Feste Größe": pure pixel-viewport resize, independent of row/col
// count -- cheap (setTableView never rebuilds the workbook), so a live
@@ -205,14 +208,24 @@ function onResizeUp() {
window.removeEventListener('mouseup', onResizeUp);
}
const icon = computed(() =>
props.object.kind === 'table' ? '▦' : props.object.kind === 'chart' ? '▤' : '¶',
);
const icon = computed(() => {
switch (props.object.kind) {
case 'table':
return '▦';
case 'chart':
return '▤';
case 'button':
return '▶';
case 'text':
default:
return '¶';
}
});
const style = computed(() => {
const o = props.object;
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
if (o.kind === 'chart' || o.kind === 'text') {
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
base.width = o.width + 'px';
base.height = o.height + 'px';
}
@@ -223,7 +236,7 @@ const style = computed(() => {
<template>
<div
class="canvas-object"
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' }"
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
:style="style"
@mousedown="emit('select')"
>
@@ -254,7 +267,8 @@ const style = computed(() => {
<div 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" />
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
</div>
<div
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
/**
* "▶ Makros" -- the exclusive UI for creating, viewing, editing, deleting
* and test-running the document's macros (store/document.ts's runMacro(),
* engine/parser.ts's parseMacroBlock()). Mirrors CustomFunctionsModal.vue's
* list/edit-form shape, minus the parameter list and expression/block
* toggle a macro doesn't have -- a macro is always the same write-capable
* statement language, parameterless (triggered by a click, no arguments).
*/
import { reactive, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
const toasts = useToasts();
type Mode = 'list' | 'edit';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', body: '' });
const formError = ref<string | null>(null);
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
form.name = '';
form.body = '';
formError.value = null;
mode.value = 'edit';
}
function openEdit(id: string) {
const m = store.doc.macros.find((x) => x.id === id);
if (!m) return;
editingId.value = id;
form.name = m.name;
form.body = m.body;
formError.value = null;
mode.value = 'edit';
}
function save() {
const result = editingId.value
? store.updateMacro(editingId.value, form.name, form.body)
: store.addMacro(form.name, form.body);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeMacro(id: string, name: string) {
if (confirm(`Makro "${name}" löschen? Buttons, die es zugewiesen haben, verlieren die Zuweisung.`)) {
store.deleteMacro(id);
}
}
function runMacro(id: string) {
const result = store.runMacro(id);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:title="mode === 'list' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.macros.length" class="hint">
Noch keine Makros definiert. Mit „+ Neues Makro" eines anlegen — danach über „▶" hier
oder über einen Button auf der Fläche auslösbar.
</p>
<ul v-else class="fn-list">
<li v-for="m in store.doc.macros" :key="m.id" class="fn-row">
<code class="fn-sig">{{ m.name }}</code>
<span class="fn-actions">
<button class="mini run" title="Ausführen" @click="runMacro(m.id)">▶ Ausführen</button>
<button class="mini" @click="openEdit(m.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<template v-else>
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. Quartalsabschluss" />
</label>
<label class="row body-row">
Aktionen
<textarea
v-model="form.body"
class="formula-input"
rows="7"
placeholder="INSERTROW(&quot;Umsatz&quot;; 1)&#10;SETVALUE(Umsatz::A2; &quot;Neu&quot;)"
/>
</label>
<p class="hint">
Dieselbe Sprache wie „Mit Schleife"-Funktionen (<strong>Zuweisungen</strong>,
<strong>FOR</strong>/<strong>IF/ELSE</strong>, <code>RETURN</code> als vorzeitiger
Abbruch — ein <code>RETURN "text"</code> wird die Erfolgsmeldung), plus elf
<strong>Schreib-Aktionen</strong>:
<code>SETVALUE(Ziel; Wert)</code>,
<code>INSERTROW</code>/<code>DELETEROW</code>/<code>INSERTCOL</code>/<code>DELETECOL(Tabelle; Index)</code>,
<code>ADDTABLE(Blatt; Name)</code>, <code>RENAMETABLE</code>/<code>DELETETABLE(Tabelle)</code>,
<code>ADDSHEET(Name)</code>, <code>RENAMESHEET</code>/<code>DELETESHEET(Blatt)</code>.
Tabellen/Blätter werden als Text benannt (<code>"Umsatz"</code> oder
<code>"Blatt2!Kosten"</code>). <strong>Jeder Zell-/Bereichsbezug muss die Tabelle
nennen</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code> — ein Makro hat
anders als eine Zelle keine eigene Tabelle. Beispiel:
<code>SETVALUE(Umsatz::B2; Umsatz::B2 + 1) INSERTROW("Umsatz"; 0)</code>.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</template>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neues Makro</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.fn-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.fn-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.fn-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.run {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.mini.run:hover {
background: var(--accent);
color: #fff;
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'] {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.body-row .formula-input {
min-height: 30px;
max-height: 40vh;
}
.formula-input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
+59
View File
@@ -21,6 +21,7 @@ const table = computed(() =>
);
const chart = computed(() => (obj.value?.kind === 'chart' ? obj.value : null));
const text = computed(() => (obj.value?.kind === 'text' ? obj.value : null));
const button = computed(() => (obj.value?.kind === 'button' ? obj.value : null));
const sheet = computed(() => store.activeSheet.value);
const num = (e: Event) => Number((e.target as HTMLInputElement).value);
@@ -352,6 +353,64 @@ function resetDoc() {
<button class="danger" @click="store.deleteObject(text.id)">Textfeld löschen</button>
</template>
<template v-else-if="button">
<label class="f">
<span>Name</span>
<input :value="button.name" @change="store.renameObject(button.id, str($event))" />
</label>
<label class="f">
<span>Beschriftung</span>
<input :value="button.label" @change="store.setButtonLabel(button.id, str($event))" />
</label>
<label class="f">
<span>Makro</span>
<select :value="button.macroId ?? ''" @change="store.setButtonMacroId(button.id, str($event) || null)">
<option value="">— keins —</option>
<option v-for="m in store.doc.macros" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</label>
<p v-if="store.doc.macros.length === 0" class="muted">
Noch kein Makro vorhanden — über „▶ Makros" in der Symbolleiste anlegen.
</p>
<label class="f">
<span>Textfarbe</span>
<span class="color">
<input type="color" :value="button.fg ?? '#0a84ff'" @input="store.setObjectTextColor(button.id, str($event))" />
<button class="mini" title="Standard" @click="store.setObjectTextColor(button.id, null)">↺</button>
<em v-if="!button.fg">Design</em>
</span>
</label>
<label class="f">
<span>Hintergrund</span>
<span class="color">
<input type="color" :value="button.bg ?? '#e8f2ff'" @input="store.setObjectBg(button.id, str($event))" />
<button class="mini" title="Ohne" @click="store.setObjectBg(button.id, null)">↺</button>
<em v-if="!button.bg">Design</em>
</span>
</label>
<div class="grid2">
<label class="f">
<span>Breite (px)</span>
<input type="number" min="60" step="10" :value="button.width" @change="store.resizeButton(button.id, num($event), button.height)" />
</label>
<label class="f">
<span>Höhe (px)</span>
<input type="number" min="28" step="10" :value="button.height" @change="store.resizeButton(button.id, button.width, num($event))" />
</label>
</div>
<div class="grid2">
<label class="f">
<span>Position X</span>
<input type="number" min="0" step="4" :value="button.x" @change="store.moveObject(button.id, num($event), button.y)" />
</label>
<label class="f">
<span>Position Y</span>
<input type="number" min="0" step="4" :value="button.y" @change="store.moveObject(button.id, button.x, num($event))" />
</label>
</div>
<button class="danger" @click="store.deleteObject(button.id)">Button löschen</button>
</template>
<p v-else class="muted">Ein Objekt auf der Fläche anklicken, um es hier zu bearbeiten.</p>
</section>
+12 -1
View File
@@ -13,7 +13,9 @@ export type ToolbarIconName =
| 'table'
| 'chart'
| 'text'
| 'tag';
| 'tag'
| 'button'
| 'macro';
defineProps<{ name: ToolbarIconName }>();
</script>
@@ -76,6 +78,15 @@ defineProps<{ name: ToolbarIconName }>();
<polygon points="4,5 14,5 20,11 14,17 4,17" />
<circle cx="9" cy="9" r="1.6" fill="currentColor" />
</template>
<template v-else-if="name === 'button'">
<rect x="3" y="7" width="18" height="10" rx="3" />
<polygon points="10,10.5 10,13.5 13,12" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'macro'">
<polygon points="6,4 20,12 6,20" fill="currentColor" stroke="none" />
</template>
</svg>
</template>
+55 -15
View File
@@ -9,6 +9,7 @@
*/
import type {
ButtonObject,
CellCoord,
CellFormat,
ChartObject,
@@ -277,6 +278,24 @@ function isTextObject(x: unknown): x is TextObject {
);
}
function isButtonObject(x: unknown): x is ButtonObject {
const o = x as ButtonObject;
return (
!!o &&
o.kind === 'button' &&
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.label === 'string'
// macroId is intentionally NOT checked against a real macro here --
// same lenient "dangling id renders as unassigned" convention as a
// chart's sourceTableId, see ButtonObject's doc comment.
);
}
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
const d = defaultTableView();
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
@@ -333,20 +352,41 @@ function normaliseObject(o: SheetObject): SheetObject {
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),
};
if (o.kind === 'button') {
return {
kind: 'button',
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(60, Math.round(o.width)),
height: Math.max(28, Math.round(o.height)),
label: String(o.label ?? ''),
macroId: typeof o.macroId === 'string' ? o.macroId : null,
bg: colorOrUndef(o.bg),
fg: colorOrUndef(o.fg),
};
}
if (o.kind === 'chart') {
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),
};
}
// Every SheetObject kind is handled above -- reachable only if a new kind
// is added to the union without a branch here (a compile-time error at
// the call site would already catch that; this is the runtime backstop).
throw new JsonImportError('Unbekannte Objektart.');
}
export function validateDocument(x: unknown): DocumentState {
@@ -369,7 +409,7 @@ export function validateDocument(x: unknown): DocumentState {
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))
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o) && !isButtonObject(o))
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
objectIds.add(o.id);
+16
View File
@@ -1524,6 +1524,22 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
});
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', () => {
const s = fresh();
const startSheetId = s.doc.activeSheetId;
const startSelection = s.doc.selection;
const otherSheetId = s.addSheet('Ziel');
s.setActiveSheet(startSheetId); // addSheet() itself also navigates -- go back
expect(s.doc.activeSheetId).toBe(startSheetId);
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
expect(s.doc.selection).toEqual(startSelection);
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened
});
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
const s = fresh();
const before = s.doc.sheets.length;
+71 -3
View File
@@ -58,6 +58,7 @@ import {
type NumberLocale,
} from './format';
import type {
ButtonObject,
CellCoord,
CellFormat,
CellGraphNode,
@@ -862,6 +863,44 @@ function createStore() {
o.height = Math.max(32, Math.round(height));
}
// ---- buttons (canvas objects that run a macro on click) ---------------
function addButton(sheetId: string, pos?: { x: number; y: number }): string {
const sheet = sheetById(sheetId);
if (!sheet) return '';
const id = uid('btn');
const p = pos ?? nextCascade(sheet);
const button: ButtonObject = {
kind: 'button',
id,
name: uniqueName('Button', objectNames()),
x: Math.max(0, Math.round(p.x)),
y: Math.max(0, Math.round(p.y)),
width: 140,
height: 44,
label: 'Button',
macroId: null,
};
sheet.objects.push(button);
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
return id;
}
function setButtonLabel(objId: string, label: string) {
const o = objectById(objId);
if (o?.kind === 'button') o.label = label;
}
function setButtonMacroId(objId: string, macroId: string | null) {
const o = objectById(objId);
if (o?.kind === 'button') o.macroId = macroId;
}
function resizeButton(objId: string, width: number, height: number) {
const o = objectById(objId);
if (o?.kind !== 'button') return;
o.width = Math.max(60, Math.round(width));
o.height = Math.max(28, Math.round(height));
}
function renameObject(objId: string, name: string) {
const o = objectById(objId);
const clean = name.trim();
@@ -1318,6 +1357,24 @@ function createStore() {
const macro = doc.macros.find((m) => m.id === macroId);
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
// addTable/addSheet/... are UI-oriented primitives that also navigate
// the user to what they just inserted -- exactly right for a toolbar
// click, but a surprising side effect for a macro run in the
// background (e.g. a button click on "Analyse" silently switching the
// view to some other sheet an ADDTABLE deep in the script happened to
// touch). Found live: running a macro containing ADDTABLE("Blatt 2";
// ...) yanked the active sheet to "Blatt 2". Snapshotting + restoring
// the view state (unless the macro deleted it out from under us) keeps
// the mutation but undoes only its incidental navigation.
const savedSheetId = doc.activeSheetId;
const savedObjectId = doc.activeObjectId;
const savedSelection = doc.selection ? { ...doc.selection } : null;
const restoreView = () => {
if (sheetById(savedSheetId)) doc.activeSheetId = savedSheetId;
doc.activeObjectId = savedObjectId && objectById(savedObjectId) ? savedObjectId : null;
doc.selection = savedSelection && objectById(savedSelection.tableId) ? savedSelection : null;
};
const macroHost: EngineHost = {
// Never actually consulted: validateMacro rejects every unqualified
// cell/range reference at save time, so evalScalar's 'cell' case
@@ -1386,6 +1443,8 @@ function createStore() {
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) };
} finally {
restoreView();
}
}
@@ -1446,10 +1505,15 @@ function createStore() {
}
// ---- number-format locale (object -> sheet -> document cascade) ---
/** Effective locale for a canvas object (table or chart). */
/** Effective locale for a canvas object (table or chart) -- text/button
* objects have no `locale` field of their own (nothing they show is
* locale-formatted), so they fall straight through to the sheet/document
* cascade. */
function effectiveLocale(objId: string | null): NumberLocale {
const o = objId ? objectById(objId) : undefined;
if (o && o.kind !== 'text' && o.locale && isNumberLocale(o.locale)) return o.locale;
if (o && (o.kind === 'table' || o.kind === 'chart') && o.locale && isNumberLocale(o.locale)) {
return o.locale;
}
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
@@ -1468,7 +1532,7 @@ function createStore() {
}
function setObjectLocale(objId: string, l: string | null) {
const o = objectById(objId);
if (!o || o.kind === 'text') return;
if (!o || (o.kind !== 'table' && o.kind !== 'chart')) return;
if (l && isNumberLocale(l)) o.locale = l;
else delete o.locale;
}
@@ -2419,6 +2483,10 @@ function createStore() {
setTextContent,
setTextFormat,
resizeText,
addButton,
setButtonLabel,
setButtonMacroId,
resizeButton,
renameObject,
deleteObject,
moveObject,
+26 -1
View File
@@ -122,7 +122,32 @@ export interface TextObject {
bg?: string;
}
export type SheetObject = TableObject | ChartObject | TextObject;
/** A clickable canvas object that runs an assigned macro (see `Macro`
* below) when clicked -- the second of the two trigger UIs the macro
* feature offers, alongside the "▶ Ausführen" button in the Makros panel.
* Freely positioned/resized like a chart or text box, no intrinsic size. */
export interface ButtonObject {
kind: 'button';
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
/** button caption, independent of `name` (the canvas-object label shown
* in its chrome/rename-on-dblclick) */
label: string;
/** which macro a click runs; `null` = unassigned (shows a hint instead
* of running anything). A dangling id (its macro was deleted, or an
* imported document's macro wasn't carried along) is treated the same
* as `null` -- the same lenient convention as a chart's dangling
* `sourceTableId`. */
macroId: string | null;
bg?: string;
fg?: string;
}
export type SheetObject = TableObject | ChartObject | TextObject | ButtonObject;
export interface Sheet {
id: string;