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:
@@ -6,6 +6,7 @@ import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import NamedRangesModal from './NamedRangesModal.vue';
|
||||
import MacrosModal from './MacrosModal.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import ToolbarIcon from './ToolbarIcon.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
@@ -32,6 +33,7 @@ const fileMenuOpen = ref(false);
|
||||
const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const namedRangesOpen = ref(false);
|
||||
const macrosOpen = ref(false);
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -54,6 +56,10 @@ function insertText() {
|
||||
store.addText(store.doc.activeSheetId);
|
||||
toasts.info('Textfeld eingefügt.');
|
||||
}
|
||||
function insertButton() {
|
||||
store.addButton(store.doc.activeSheetId);
|
||||
toasts.info('Button eingefügt.');
|
||||
}
|
||||
|
||||
// ---- CSV export dialog -------------------------------------------
|
||||
const csvExportOpen = ref(false);
|
||||
@@ -245,6 +251,9 @@ function onReset() {
|
||||
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
|
||||
<ToolbarIcon name="text" />
|
||||
</button>
|
||||
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
|
||||
<ToolbarIcon name="button" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tgroup accent">
|
||||
@@ -264,6 +273,14 @@ function onReset() {
|
||||
>
|
||||
<ToolbarIcon name="tag" />
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Makros"
|
||||
aria-label="Makros"
|
||||
@click="macrosOpen = true"
|
||||
>
|
||||
<ToolbarIcon name="macro" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spacer" />
|
||||
@@ -360,6 +377,7 @@ function onReset() {
|
||||
<!-- Benutzerdefinierte Funktionen -->
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||
|
||||
@@ -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">
|
||||
/**
|
||||
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
|
||||
* Draggable / resizable wrapper for a table, chart, text or button object on
|
||||
* the sheet canvas.
|
||||
*
|
||||
* - the header strip and the frame border move the object;
|
||||
* - the SE handle resizes a chart/text freely; for a table, what it does
|
||||
@@ -25,6 +26,7 @@ import { useDocument } from '../store/document';
|
||||
import TableGrid from './TableGrid.vue';
|
||||
import ChartView from './ChartView.vue';
|
||||
import TextBox from './TextBox.vue';
|
||||
import ButtonView from './ButtonView.vue';
|
||||
|
||||
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
|
||||
const emit = defineEmits<{ (e: 'select'): void }>();
|
||||
@@ -139,7 +141,7 @@ function lastNonEmptyCol(t: TableObject): number {
|
||||
* "Automatisch" vs. "Feste Größe" split. */
|
||||
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
|
||||
const o = props.object;
|
||||
if (o.kind === 'chart' || o.kind === 'text') return 'free';
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
|
||||
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
|
||||
});
|
||||
|
||||
@@ -150,7 +152,7 @@ function onResizeDown(e: MouseEvent) {
|
||||
const o = props.object;
|
||||
let w: number;
|
||||
let h: number;
|
||||
if (o.kind === 'chart' || o.kind === 'text') {
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
w = o.width;
|
||||
h = o.height;
|
||||
} else if (o.view.mode === 'fixed') {
|
||||
@@ -174,6 +176,7 @@ function onResizeMove(e: MouseEvent) {
|
||||
if (resizeKind.value === 'free') {
|
||||
if (o.kind === 'chart') store.resizeChart(o.id, rs.w + dx, rs.h + dy);
|
||||
else if (o.kind === 'text') store.resizeText(o.id, rs.w + dx, rs.h + dy);
|
||||
else if (o.kind === 'button') store.resizeButton(o.id, rs.w + dx, rs.h + dy);
|
||||
} else if (resizeKind.value === 'viewport') {
|
||||
// "Feste Größe": pure pixel-viewport resize, independent of row/col
|
||||
// count -- cheap (setTableView never rebuilds the workbook), so a live
|
||||
@@ -205,14 +208,24 @@ function onResizeUp() {
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
const icon = computed(() =>
|
||||
props.object.kind === 'table' ? '▦' : props.object.kind === 'chart' ? '▤' : '¶',
|
||||
);
|
||||
const icon = computed(() => {
|
||||
switch (props.object.kind) {
|
||||
case 'table':
|
||||
return '▦';
|
||||
case 'chart':
|
||||
return '▤';
|
||||
case 'button':
|
||||
return '▶';
|
||||
case 'text':
|
||||
default:
|
||||
return '¶';
|
||||
}
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
const o = props.object;
|
||||
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
|
||||
if (o.kind === 'chart' || o.kind === 'text') {
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
base.width = o.width + 'px';
|
||||
base.height = o.height + 'px';
|
||||
}
|
||||
@@ -223,7 +236,7 @@ const style = computed(() => {
|
||||
<template>
|
||||
<div
|
||||
class="canvas-object"
|
||||
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' }"
|
||||
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
|
||||
:style="style"
|
||||
@mousedown="emit('select')"
|
||||
>
|
||||
@@ -254,7 +267,8 @@ const style = computed(() => {
|
||||
<div class="content">
|
||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
|
||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||
<TextBox v-else :obj-id="object.id" />
|
||||
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
|
||||
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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 text = computed(() => (obj.value?.kind === 'text' ? obj.value : null));
|
||||
const button = computed(() => (obj.value?.kind === 'button' ? obj.value : null));
|
||||
const sheet = computed(() => store.activeSheet.value);
|
||||
|
||||
const num = (e: Event) => Number((e.target as HTMLInputElement).value);
|
||||
@@ -352,6 +353,64 @@ function resetDoc() {
|
||||
<button class="danger" @click="store.deleteObject(text.id)">Textfeld löschen</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="button">
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="button.name" @change="store.renameObject(button.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Beschriftung</span>
|
||||
<input :value="button.label" @change="store.setButtonLabel(button.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Makro</span>
|
||||
<select :value="button.macroId ?? ''" @change="store.setButtonMacroId(button.id, str($event) || null)">
|
||||
<option value="">— keins —</option>
|
||||
<option v-for="m in store.doc.macros" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="store.doc.macros.length === 0" class="muted">
|
||||
Noch kein Makro vorhanden — über „▶ Makros" in der Symbolleiste anlegen.
|
||||
</p>
|
||||
<label class="f">
|
||||
<span>Textfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="button.fg ?? '#0a84ff'" @input="store.setObjectTextColor(button.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectTextColor(button.id, null)">↺</button>
|
||||
<em v-if="!button.fg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Hintergrund</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="button.bg ?? '#e8f2ff'" @input="store.setObjectBg(button.id, str($event))" />
|
||||
<button class="mini" title="Ohne" @click="store.setObjectBg(button.id, null)">↺</button>
|
||||
<em v-if="!button.bg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Breite (px)</span>
|
||||
<input type="number" min="60" step="10" :value="button.width" @change="store.resizeButton(button.id, num($event), button.height)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Höhe (px)</span>
|
||||
<input type="number" min="28" step="10" :value="button.height" @change="store.resizeButton(button.id, button.width, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Position X</span>
|
||||
<input type="number" min="0" step="4" :value="button.x" @change="store.moveObject(button.id, num($event), button.y)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Position Y</span>
|
||||
<input type="number" min="0" step="4" :value="button.y" @change="store.moveObject(button.id, button.x, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="danger" @click="store.deleteObject(button.id)">Button löschen</button>
|
||||
</template>
|
||||
|
||||
<p v-else class="muted">Ein Objekt auf der Fläche anklicken, um es hier zu bearbeiten.</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export type ToolbarIconName =
|
||||
| 'table'
|
||||
| 'chart'
|
||||
| 'text'
|
||||
| 'tag';
|
||||
| 'tag'
|
||||
| 'button'
|
||||
| 'macro';
|
||||
|
||||
defineProps<{ name: ToolbarIconName }>();
|
||||
</script>
|
||||
@@ -76,6 +78,15 @@ defineProps<{ name: ToolbarIconName }>();
|
||||
<polygon points="4,5 14,5 20,11 14,17 4,17" />
|
||||
<circle cx="9" cy="9" r="1.6" fill="currentColor" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'button'">
|
||||
<rect x="3" y="7" width="18" height="10" rx="3" />
|
||||
<polygon points="10,10.5 10,13.5 13,12" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'macro'">
|
||||
<polygon points="6,4 20,12 6,20" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
+55
-15
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ButtonObject,
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
@@ -277,6 +278,24 @@ function isTextObject(x: unknown): x is TextObject {
|
||||
);
|
||||
}
|
||||
|
||||
function isButtonObject(x: unknown): x is ButtonObject {
|
||||
const o = x as ButtonObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'button' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.label === 'string'
|
||||
// macroId is intentionally NOT checked against a real macro here --
|
||||
// same lenient "dangling id renders as unassigned" convention as a
|
||||
// chart's sourceTableId, see ButtonObject's doc comment.
|
||||
);
|
||||
}
|
||||
|
||||
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
|
||||
const d = defaultTableView();
|
||||
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
|
||||
@@ -333,20 +352,41 @@ function normaliseObject(o: SheetObject): SheetObject {
|
||||
bg: colorOrUndef(o.bg),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'chart',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(200, Math.round(o.width)),
|
||||
height: Math.max(140, Math.round(o.height)),
|
||||
view: o.view,
|
||||
sourceTableId: o.sourceTableId ?? null,
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
if (o.kind === 'button') {
|
||||
return {
|
||||
kind: 'button',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(60, Math.round(o.width)),
|
||||
height: Math.max(28, Math.round(o.height)),
|
||||
label: String(o.label ?? ''),
|
||||
macroId: typeof o.macroId === 'string' ? o.macroId : null,
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'chart') {
|
||||
return {
|
||||
kind: 'chart',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(200, Math.round(o.width)),
|
||||
height: Math.max(140, Math.round(o.height)),
|
||||
view: o.view,
|
||||
sourceTableId: o.sourceTableId ?? null,
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
// Every SheetObject kind is handled above -- reachable only if a new kind
|
||||
// is added to the union without a branch here (a compile-time error at
|
||||
// the call site would already catch that; this is the runtime backstop).
|
||||
throw new JsonImportError('Unbekannte Objektart.');
|
||||
}
|
||||
|
||||
export function validateDocument(x: unknown): DocumentState {
|
||||
@@ -369,7 +409,7 @@ export function validateDocument(x: unknown): DocumentState {
|
||||
if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
|
||||
sheetIds.add(s.id);
|
||||
for (const o of s.objects) {
|
||||
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o))
|
||||
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o) && !isButtonObject(o))
|
||||
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||
objectIds.add(o.id);
|
||||
|
||||
@@ -1524,6 +1524,22 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
|
||||
});
|
||||
|
||||
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', () => {
|
||||
const s = fresh();
|
||||
const startSheetId = s.doc.activeSheetId;
|
||||
const startSelection = s.doc.selection;
|
||||
const otherSheetId = s.addSheet('Ziel');
|
||||
s.setActiveSheet(startSheetId); // addSheet() itself also navigates -- go back
|
||||
expect(s.doc.activeSheetId).toBe(startSheetId);
|
||||
|
||||
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string;
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
|
||||
expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
|
||||
expect(s.doc.selection).toEqual(startSelection);
|
||||
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened
|
||||
});
|
||||
|
||||
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
|
||||
const s = fresh();
|
||||
const before = s.doc.sheets.length;
|
||||
|
||||
+71
-3
@@ -58,6 +58,7 @@ import {
|
||||
type NumberLocale,
|
||||
} from './format';
|
||||
import type {
|
||||
ButtonObject,
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
CellGraphNode,
|
||||
@@ -862,6 +863,44 @@ function createStore() {
|
||||
o.height = Math.max(32, Math.round(height));
|
||||
}
|
||||
|
||||
// ---- buttons (canvas objects that run a macro on click) ---------------
|
||||
function addButton(sheetId: string, pos?: { x: number; y: number }): string {
|
||||
const sheet = sheetById(sheetId);
|
||||
if (!sheet) return '';
|
||||
const id = uid('btn');
|
||||
const p = pos ?? nextCascade(sheet);
|
||||
const button: ButtonObject = {
|
||||
kind: 'button',
|
||||
id,
|
||||
name: uniqueName('Button', objectNames()),
|
||||
x: Math.max(0, Math.round(p.x)),
|
||||
y: Math.max(0, Math.round(p.y)),
|
||||
width: 140,
|
||||
height: 44,
|
||||
label: 'Button',
|
||||
macroId: null,
|
||||
};
|
||||
sheet.objects.push(button);
|
||||
doc.activeObjectId = id;
|
||||
doc.activeSheetId = sheetId;
|
||||
bump();
|
||||
return id;
|
||||
}
|
||||
function setButtonLabel(objId: string, label: string) {
|
||||
const o = objectById(objId);
|
||||
if (o?.kind === 'button') o.label = label;
|
||||
}
|
||||
function setButtonMacroId(objId: string, macroId: string | null) {
|
||||
const o = objectById(objId);
|
||||
if (o?.kind === 'button') o.macroId = macroId;
|
||||
}
|
||||
function resizeButton(objId: string, width: number, height: number) {
|
||||
const o = objectById(objId);
|
||||
if (o?.kind !== 'button') return;
|
||||
o.width = Math.max(60, Math.round(width));
|
||||
o.height = Math.max(28, Math.round(height));
|
||||
}
|
||||
|
||||
function renameObject(objId: string, name: string) {
|
||||
const o = objectById(objId);
|
||||
const clean = name.trim();
|
||||
@@ -1318,6 +1357,24 @@ function createStore() {
|
||||
const macro = doc.macros.find((m) => m.id === macroId);
|
||||
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
|
||||
|
||||
// addTable/addSheet/... are UI-oriented primitives that also navigate
|
||||
// the user to what they just inserted -- exactly right for a toolbar
|
||||
// click, but a surprising side effect for a macro run in the
|
||||
// background (e.g. a button click on "Analyse" silently switching the
|
||||
// view to some other sheet an ADDTABLE deep in the script happened to
|
||||
// touch). Found live: running a macro containing ADDTABLE("Blatt 2";
|
||||
// ...) yanked the active sheet to "Blatt 2". Snapshotting + restoring
|
||||
// the view state (unless the macro deleted it out from under us) keeps
|
||||
// the mutation but undoes only its incidental navigation.
|
||||
const savedSheetId = doc.activeSheetId;
|
||||
const savedObjectId = doc.activeObjectId;
|
||||
const savedSelection = doc.selection ? { ...doc.selection } : null;
|
||||
const restoreView = () => {
|
||||
if (sheetById(savedSheetId)) doc.activeSheetId = savedSheetId;
|
||||
doc.activeObjectId = savedObjectId && objectById(savedObjectId) ? savedObjectId : null;
|
||||
doc.selection = savedSelection && objectById(savedSelection.tableId) ? savedSelection : null;
|
||||
};
|
||||
|
||||
const macroHost: EngineHost = {
|
||||
// Never actually consulted: validateMacro rejects every unqualified
|
||||
// cell/range reference at save time, so evalScalar's 'cell' case
|
||||
@@ -1386,6 +1443,8 @@ function createStore() {
|
||||
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
||||
} finally {
|
||||
restoreView();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1446,10 +1505,15 @@ function createStore() {
|
||||
}
|
||||
|
||||
// ---- number-format locale (object -> sheet -> document cascade) ---
|
||||
/** Effective locale for a canvas object (table or chart). */
|
||||
/** Effective locale for a canvas object (table or chart) -- text/button
|
||||
* objects have no `locale` field of their own (nothing they show is
|
||||
* locale-formatted), so they fall straight through to the sheet/document
|
||||
* cascade. */
|
||||
function effectiveLocale(objId: string | null): NumberLocale {
|
||||
const o = objId ? objectById(objId) : undefined;
|
||||
if (o && o.kind !== 'text' && o.locale && isNumberLocale(o.locale)) return o.locale;
|
||||
if (o && (o.kind === 'table' || o.kind === 'chart') && o.locale && isNumberLocale(o.locale)) {
|
||||
return o.locale;
|
||||
}
|
||||
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
|
||||
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
|
||||
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
|
||||
@@ -1468,7 +1532,7 @@ function createStore() {
|
||||
}
|
||||
function setObjectLocale(objId: string, l: string | null) {
|
||||
const o = objectById(objId);
|
||||
if (!o || o.kind === 'text') return;
|
||||
if (!o || (o.kind !== 'table' && o.kind !== 'chart')) return;
|
||||
if (l && isNumberLocale(l)) o.locale = l;
|
||||
else delete o.locale;
|
||||
}
|
||||
@@ -2419,6 +2483,10 @@ function createStore() {
|
||||
setTextContent,
|
||||
setTextFormat,
|
||||
resizeText,
|
||||
addButton,
|
||||
setButtonLabel,
|
||||
setButtonMacroId,
|
||||
resizeButton,
|
||||
renameObject,
|
||||
deleteObject,
|
||||
moveObject,
|
||||
|
||||
+26
-1
@@ -122,7 +122,32 @@ export interface TextObject {
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject;
|
||||
/** A clickable canvas object that runs an assigned macro (see `Macro`
|
||||
* below) when clicked -- the second of the two trigger UIs the macro
|
||||
* feature offers, alongside the "▶ Ausführen" button in the Makros panel.
|
||||
* Freely positioned/resized like a chart or text box, no intrinsic size. */
|
||||
export interface ButtonObject {
|
||||
kind: 'button';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** button caption, independent of `name` (the canvas-object label shown
|
||||
* in its chrome/rename-on-dblclick) */
|
||||
label: string;
|
||||
/** which macro a click runs; `null` = unassigned (shows a hint instead
|
||||
* of running anything). A dangling id (its macro was deleted, or an
|
||||
* imported document's macro wasn't carried along) is treated the same
|
||||
* as `null` -- the same lenient convention as a chart's dangling
|
||||
* `sourceTableId`. */
|
||||
macroId: string | null;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject | ButtonObject;
|
||||
|
||||
export interface Sheet {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user