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:
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -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,6 +352,22 @@ function normaliseObject(o: SheetObject): SheetObject {
|
|||||||
bg: colorOrUndef(o.bg),
|
bg: colorOrUndef(o.bg),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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 {
|
return {
|
||||||
kind: 'chart',
|
kind: 'chart',
|
||||||
id: o.id,
|
id: o.id,
|
||||||
@@ -347,6 +382,11 @@ function normaliseObject(o: SheetObject): SheetObject {
|
|||||||
bg: colorOrUndef(o.bg),
|
bg: colorOrUndef(o.bg),
|
||||||
fg: colorOrUndef(o.fg),
|
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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user