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
+98 -1
View File
@@ -390,6 +390,73 @@ Beispiel mit.
ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu
getippten `:`-Bereichsangabe kombiniert werden. getippten `:`-Bereichsangabe kombiniert werden.
### Makros
Ein **USP gegenüber gewöhnlichen Tabellenkalkulationen**: Zellen, Zeilen/
Spalten, Tabellen und Blätter dokumentweit **per Skript adressieren und
verändern** — Struktur *und* Inhalt — statt nur per Formel zu lesen. Anders
als eine Formel läuft ein Makro **außerhalb der Neuberechnung**: es wird nie
aus einer Zelle heraus aufgerufen, sondern immer **explizit ausgelöst** —
über **„▶ Ausführen"** in der neuen **„▶ Makros"**-Symbolleisten-Ansicht
oder per Klick auf ein **Button-Objekt** auf der Fläche (einfügbar über das
Symbol neben „Text einfügen"; welches Makro der Klick auslöst, wird im
Inspektor **oder** per Rechtsklick auf den Button direkt gewählt). Diese
Trennung ist bewusst: eine Formel, die als Nebeneffekt andere Zellen
verändert, würde die Neuberechnungsreihenfolge undefiniert machen (genau
das vermeidet dieses Feature) — ein Makro ist stattdessen dieselbe Art
Mutation, die auch ein Klick in der UI auslösen würde, nur mit
`FOR`/`IF`/`RETURN`-Kontrollfluss verkettet (dieselbe Sprache wie „Mit
Schleife"-Funktionen, s. o.).
**Elf Schreib-Aktionen**, adressiert mit der gewohnten
`Blatt!Tabelle::Zelle`-Syntax:
| Aktion | Wirkung |
|---|---|
| `SETVALUE(Ziel; Wert)` | schreibt `Wert` in eine Zelle oder — als Ziel ein Bereich — denselben Wert in jede Zelle darin (wie Excels `Strg+Enter`). Beginnt der (ggf. per `&` zusammengesetzte) Text mit `=`, wird er wie getippt zur echten Formel. |
| `INSERTROW`/`DELETEROW`/`INSERTCOL`/`DELETECOL(TabellenRef; Index)` | dieselbe dokumentweite Formel-Referenz-Umschreibung wie das manuelle Einfügen/Löschen (s. o.) |
| `ADDTABLE(BlattRef; Name)`, `RENAMETABLE`/`DELETETABLE(TabellenRef)` | Tabellen anlegen/umbenennen/löschen |
| `ADDSHEET(Name)`, `RENAMESHEET`/`DELETESHEET(BlattRef)` | Blätter anlegen/umbenennen/löschen |
`TabellenRef`/`BlattRef` sind Text (`"Umsatz"` oder `"Blatt2!Kosten"`,
identisch zur Formel-Qualifizierung aufgelöst) — auch berechenbar, z. B.
`ADDTABLE("Blatt1"; "Kopie_" & COUNT(Umsatz!A:A))`. Nur `SETVALUE`s erstes
Argument ist ein echter Zell-/Bereichsbezug (Literal, Parameter,
Schleifenvariable); alle anderen Argumente sind normale Ausdrücke.
- **Jeder Zell-/Bereichsbezug muss die Tabelle nennen** (`Umsatz::A1`, nie
bloß `A1`) — anders als eine Zelle hat ein Makro keine „Heimat-Tabelle",
die ein unqualifizierter Bezug meinen könnte. Beim Speichern geprüft, mit
klarer Fehlermeldung.
- **`RETURN`** beendet die Ausführung vorzeitig (z. B. um eine Schleife
früh zu verlassen); ein `RETURN "text"` wird zur Erfolgsmeldung im Toast
statt des generischen „Makro ausgeführt.".
- **Ein ganzer Lauf zählt als ein Undo-Schritt** — läuft er synchron durch
(immer der Fall), fasst die bestehende, zeitbasierte Undo/Redo-Historie
ihn automatisch zu einem `Strg`/`Cmd`+`Z` zusammen, ohne eigene
Transaktionslogik.
- **Ein Fehler mitten im Lauf bricht sofort ab** (unbekannte Tabelle,
fehlerhafter Wert, …) und zeigt die Ursache als Fehler-Toast — bereits
ausgeführte Schritte bleiben stehen (kein automatisches Rollback,
bewusst; per Undo rückgängig machbar).
- **Eigenes, kleineres Schrittbudget** (1.000 statt der 10.000 einer
gewöhnlichen `FOR`-Schleife) — eine Makro-Schleife kann pro Durchlauf
eine volle Neuberechnung auslösen (bei einer Struktur-Aktion), ein zu
großzügiges Budget wäre dort ein echtes Einfrier-Risiko.
- **Kein Makro ruft ein anderes Makro auf** (keine solche Aktion existiert)
— eliminiert jedes Makro-triggert-Makro-Zyklus-Risiko von vornherein.
- Eine im Makro aufgerufene **benutzerdefinierte Funktion** funktioniert
normal (berechnet einen Wert), kann aber selbst nie schreiben — ihr
Funktionskörper wird immer ohne Schreib-Rechte geparst.
**Bewusst ohne** (Stand jetzt): Makros haben keine Parameter (ein Klick
übergibt keine Argumente); mehrere Struktur-Aktionen in einer Schleife
lösen weiterhin je eine eigene Neuberechnung aus statt gebündelt einmal am
Ende (das kleinere Schrittbudget hält das Restrisiko klein, spätere
Optimierung möglich); ein `TabellenRef`-String wird am *ersten* `!`
gesplittet (ein Tabellenname mit eigenem `!` darin ist so nicht
adressierbar).
### Zeilen & Spalten einfügen/löschen ### Zeilen & Spalten einfügen/löschen
Rechtsklick auf einen **Zeilen-** oder **Spaltenkopf** öffnet ein Kontextmenü Rechtsklick auf einen **Zeilen-** oder **Spaltenkopf** öffnet ein Kontextmenü
@@ -1083,11 +1150,40 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
`=1+2`→3, Locale-Zahlen. `=1+2`→3, Locale-Zahlen.
- **Makros** — Grammatik (`parser.spec.ts`): `NAME(args)` an Anweisungs-
position nur mit `allowWrites` (nie in einem gewöhnlichen Funktionskörper),
`;`-Argumente inkl. übersprungener Slots, Zuweisung bleibt eindeutig
unterscheidbar. Interpreter (`macros.spec.ts`): `SETVALUE`-Zielauflösung
auf Literal/Bereich/Schleifenvariable, `RETURN` als Kurzschluss inkl.
String-Nachricht, Fall-Through → `null`, alle elf Aktionen gegen einen
Mock-`MacroWriteHost`, das kleinere Makro-Schrittbudget greift bei einer
strukturell-schweren Schleife, ein verschachtelter Aufruf einer
benutzerdefinierten Funktion in einem `SETVALUE`-Wert funktioniert und
kann selbst nicht schreiben. Store-Ebene (`document.spec.ts`): Validierung
(unqualifizierte Referenz abgelehnt, Namenskollision, reservierte
Aktionsnamen als lokale Variable abgelehnt), alle elf Aktionen live gegen
ein echtes Dokument inkl. dokumentweiter Referenz-Umschreibung bei
`INSERTROW`, **der wichtigste Test**: `SETVALUE` → `INSERTROW` → `SETVALUE`
in einem Lauf beweist, dass kein Host-Zugriff nach der strukturellen
Aktion auf einer veralteten Workbook-Referenz hängen bleibt, ein ganzer
synchroner Lauf ist genau ein Undo-Schritt, `DELETESHEET` verweigert das
letzte Blatt als echten Fehlschlag statt stillem No-op, `ADDTABLE` auf ein
*anderes* Blatt lässt die aktive Ansicht/Auswahl unangetastet (reiner
Seiteneffekt-Fix, live per Playwright gefunden), JSON-Round-Trip.
--- ---
## Bekannte Grenzen ## Bekannte Grenzen
- **Makros — UI-Ebene** (`ButtonView.vue`, `MacrosModal.vue`, der Button-
Zweig in `CanvasObject.vue`/`SettingsPanel.vue`) rein UI-seitig
implementiert, ohne eigene Unit-Tests — wie bei Tabellen-Größenänderung/
Abhängigkeiten-Panel/Ctrl+A oben, nur live per Playwright verifiziert
(Makro anlegen, per Listen-Button und per Button-Objekt ausführen,
Rechtsklick-Zuweisung, Fehlerfall zeigt einen Fehler-Toast statt
abzustürzen). Die Makro-*Sprache* selbst (Grammatik, Interpreter,
Store-Aktionen) ist dagegen vollständig durch Engine-/Store-Tests
abgedeckt (s. „Tests" unten).
- **Zeilen/Spalten einfügen/löschen** (s. „Zeilen & Spalten einfügen/löschen" - **Zeilen/Spalten einfügen/löschen** (s. „Zeilen & Spalten einfügen/löschen"
oben) bewusst ohne: Mehrfachauswahl (immer genau eine Zeile/Spalte pro oben) bewusst ohne: Mehrfachauswahl (immer genau eine Zeile/Spalte pro
Aufruf), automatisches Nachziehen von `CellFormat`-Regeln über die Aufruf), automatisches Nachziehen von `CellFormat`-Regeln über die
@@ -1180,7 +1276,8 @@ src/
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy) io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject, components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
# TableGrid, ChartView, TextBox, SheetTabs, StatusBar, TheLogo, … # TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
# StatusBar, TheLogo, …
numolus-logo.svg # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug numolus-logo.svg # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
config/brand.ts # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste config/brand.ts # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
composables/ # Theme, Toasts composables/ # Theme, Toasts
+18
View File
@@ -6,6 +6,7 @@ import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue'; import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue'; import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue'; import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
import TheLogo from './TheLogo.vue'; import TheLogo from './TheLogo.vue';
import ToolbarIcon from './ToolbarIcon.vue'; import ToolbarIcon from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand'; import { WICOM, WICOM_SOCIAL } from '../config/brand';
@@ -32,6 +33,7 @@ const fileMenuOpen = ref(false);
const aboutOpen = ref(false); const aboutOpen = ref(false);
const functionsOpen = ref(false); const functionsOpen = ref(false);
const namedRangesOpen = ref(false); const namedRangesOpen = ref(false);
const macrosOpen = ref(false);
const busy = ref(false); const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -54,6 +56,10 @@ function insertText() {
store.addText(store.doc.activeSheetId); store.addText(store.doc.activeSheetId);
toasts.info('Textfeld eingefügt.'); toasts.info('Textfeld eingefügt.');
} }
function insertButton() {
store.addButton(store.doc.activeSheetId);
toasts.info('Button eingefügt.');
}
// ---- CSV export dialog ------------------------------------------- // ---- CSV export dialog -------------------------------------------
const csvExportOpen = ref(false); const csvExportOpen = ref(false);
@@ -245,6 +251,9 @@ function onReset() {
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText"> <button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
<ToolbarIcon name="text" /> <ToolbarIcon name="text" />
</button> </button>
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
<ToolbarIcon name="button" />
</button>
</div> </div>
<div class="tgroup accent"> <div class="tgroup accent">
@@ -264,6 +273,14 @@ function onReset() {
> >
<ToolbarIcon name="tag" /> <ToolbarIcon name="tag" />
</button> </button>
<button
class="ibtn"
title="Makros"
aria-label="Makros"
@click="macrosOpen = true"
>
<ToolbarIcon name="macro" />
</button>
</div> </div>
<div class="spacer" /> <div class="spacer" />
@@ -360,6 +377,7 @@ function onReset() {
<!-- Benutzerdefinierte Funktionen --> <!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" /> <CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" /> <NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<!-- Über / Impressum --> <!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false"> <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"> <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 header strip and the frame border move the object;
* - the SE handle resizes a chart/text freely; for a table, what it does * - 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 TableGrid from './TableGrid.vue';
import ChartView from './ChartView.vue'; import ChartView from './ChartView.vue';
import TextBox from './TextBox.vue'; import TextBox from './TextBox.vue';
import ButtonView from './ButtonView.vue';
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>(); const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
const emit = defineEmits<{ (e: 'select'): void }>(); const emit = defineEmits<{ (e: 'select'): void }>();
@@ -139,7 +141,7 @@ function lastNonEmptyCol(t: TableObject): number {
* "Automatisch" vs. "Feste Größe" split. */ * "Automatisch" vs. "Feste Größe" split. */
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => { const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
const o = props.object; 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'; return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
}); });
@@ -150,7 +152,7 @@ function onResizeDown(e: MouseEvent) {
const o = props.object; const o = props.object;
let w: number; let w: number;
let h: number; let h: number;
if (o.kind === 'chart' || o.kind === 'text') { if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
w = o.width; w = o.width;
h = o.height; h = o.height;
} else if (o.view.mode === 'fixed') { } else if (o.view.mode === 'fixed') {
@@ -174,6 +176,7 @@ function onResizeMove(e: MouseEvent) {
if (resizeKind.value === 'free') { if (resizeKind.value === 'free') {
if (o.kind === 'chart') store.resizeChart(o.id, rs.w + dx, rs.h + dy); 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 === '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') { } else if (resizeKind.value === 'viewport') {
// "Feste Größe": pure pixel-viewport resize, independent of row/col // "Feste Größe": pure pixel-viewport resize, independent of row/col
// count -- cheap (setTableView never rebuilds the workbook), so a live // count -- cheap (setTableView never rebuilds the workbook), so a live
@@ -205,14 +208,24 @@ function onResizeUp() {
window.removeEventListener('mouseup', onResizeUp); window.removeEventListener('mouseup', onResizeUp);
} }
const icon = computed(() => const icon = computed(() => {
props.object.kind === 'table' ? '▦' : props.object.kind === 'chart' ? '▤' : '¶', switch (props.object.kind) {
); case 'table':
return '▦';
case 'chart':
return '▤';
case 'button':
return '▶';
case 'text':
default:
return '¶';
}
});
const style = computed(() => { const style = computed(() => {
const o = props.object; const o = props.object;
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' }; 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.width = o.width + 'px';
base.height = o.height + 'px'; base.height = o.height + 'px';
} }
@@ -223,7 +236,7 @@ const style = computed(() => {
<template> <template>
<div <div
class="canvas-object" 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" :style="style"
@mousedown="emit('select')" @mousedown="emit('select')"
> >
@@ -254,7 +267,8 @@ const style = computed(() => {
<div class="content"> <div class="content">
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" /> <TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
<ChartView v-else-if="object.kind === 'chart'" :obj-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>
<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 chart = computed(() => (obj.value?.kind === 'chart' ? obj.value : null));
const text = computed(() => (obj.value?.kind === 'text' ? 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 sheet = computed(() => store.activeSheet.value);
const num = (e: Event) => Number((e.target as HTMLInputElement).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> <button class="danger" @click="store.deleteObject(text.id)">Textfeld löschen</button>
</template> </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> <p v-else class="muted">Ein Objekt auf der Fläche anklicken, um es hier zu bearbeiten.</p>
</section> </section>
+12 -1
View File
@@ -13,7 +13,9 @@ export type ToolbarIconName =
| 'table' | 'table'
| 'chart' | 'chart'
| 'text' | 'text'
| 'tag'; | 'tag'
| 'button'
| 'macro';
defineProps<{ name: ToolbarIconName }>(); defineProps<{ name: ToolbarIconName }>();
</script> </script>
@@ -76,6 +78,15 @@ defineProps<{ name: ToolbarIconName }>();
<polygon points="4,5 14,5 20,11 14,17 4,17" /> <polygon points="4,5 14,5 20,11 14,17 4,17" />
<circle cx="9" cy="9" r="1.6" fill="currentColor" /> <circle cx="9" cy="9" r="1.6" fill="currentColor" />
</template> </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> </svg>
</template> </template>
+55 -15
View File
@@ -9,6 +9,7 @@
*/ */
import type { import type {
ButtonObject,
CellCoord, CellCoord,
CellFormat, CellFormat,
ChartObject, 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 { function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
const d = defaultTableView(); const d = defaultTableView();
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto'; const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
@@ -333,20 +352,41 @@ function normaliseObject(o: SheetObject): SheetObject {
bg: colorOrUndef(o.bg), bg: colorOrUndef(o.bg),
}; };
} }
return { if (o.kind === 'button') {
kind: 'chart', return {
id: o.id, kind: 'button',
name: o.name, id: o.id,
x: Math.max(0, Math.round(o.x)), name: o.name,
y: Math.max(0, Math.round(o.y)), x: Math.max(0, Math.round(o.x)),
width: Math.max(200, Math.round(o.width)), y: Math.max(0, Math.round(o.y)),
height: Math.max(140, Math.round(o.height)), width: Math.max(60, Math.round(o.width)),
view: o.view, height: Math.max(28, Math.round(o.height)),
sourceTableId: o.sourceTableId ?? null, label: String(o.label ?? ''),
locale: localeOrUndef(o.locale), macroId: typeof o.macroId === 'string' ? o.macroId : null,
bg: colorOrUndef(o.bg), bg: colorOrUndef(o.bg),
fg: colorOrUndef(o.fg), 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 { 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.'); if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
sheetIds.add(s.id); sheetIds.add(s.id);
for (const o of s.objects) { 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.'); throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.'); if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
objectIds.add(o.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 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', () => { it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
const s = fresh(); const s = fresh();
const before = s.doc.sheets.length; const before = s.doc.sheets.length;
+71 -3
View File
@@ -58,6 +58,7 @@ import {
type NumberLocale, type NumberLocale,
} from './format'; } from './format';
import type { import type {
ButtonObject,
CellCoord, CellCoord,
CellFormat, CellFormat,
CellGraphNode, CellGraphNode,
@@ -862,6 +863,44 @@ function createStore() {
o.height = Math.max(32, Math.round(height)); 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) { function renameObject(objId: string, name: string) {
const o = objectById(objId); const o = objectById(objId);
const clean = name.trim(); const clean = name.trim();
@@ -1318,6 +1357,24 @@ function createStore() {
const macro = doc.macros.find((m) => m.id === macroId); const macro = doc.macros.find((m) => m.id === macroId);
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' }; 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 = { const macroHost: EngineHost = {
// Never actually consulted: validateMacro rejects every unqualified // Never actually consulted: validateMacro rejects every unqualified
// cell/range reference at save time, so evalScalar's 'cell' case // 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.' }; return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
} catch (e) { } catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(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) --- // ---- 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 { function effectiveLocale(objId: string | null): NumberLocale {
const o = objId ? objectById(objId) : undefined; 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 sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined; const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc; if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
@@ -1468,7 +1532,7 @@ function createStore() {
} }
function setObjectLocale(objId: string, l: string | null) { function setObjectLocale(objId: string, l: string | null) {
const o = objectById(objId); 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; if (l && isNumberLocale(l)) o.locale = l;
else delete o.locale; else delete o.locale;
} }
@@ -2419,6 +2483,10 @@ function createStore() {
setTextContent, setTextContent,
setTextFormat, setTextFormat,
resizeText, resizeText,
addButton,
setButtonLabel,
setButtonMacroId,
resizeButton,
renameObject, renameObject,
deleteObject, deleteObject,
moveObject, moveObject,
+26 -1
View File
@@ -122,7 +122,32 @@ export interface TextObject {
bg?: string; 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 { export interface Sheet {
id: string; id: string;