Files
numolus/src/components/CanvasObject.vue
T
wkhandClaude Sonnet 5 a59fda25b0 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>
2026-09-09 10:07:56 +02:00

374 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 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
* depends on the view mode (see `resizeKind` below):
* - "Automatisch" (`mode: 'auto'`, size always == content): dragging
* drives the row/col *count* directly -- growing adds empty rows/
* cols, shrinking removes them, never past real content;
* - "Feste Größe" (`mode: 'fixed'`, an explicit scrollable viewport):
* dragging only resizes that pixel viewport, independent of row/col
* count -- the original behaviour, since a fixed-size table's whole
* point is a box smaller than its (possibly much larger) content,
* scrolling internally.
* - name is editable via double-click, delete via the × button.
*
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
* zoom level.
*/
import { computed, nextTick, ref } from 'vue';
import type { SheetObject, TableObject } from '../store/types';
import { DEFAULT_ROW_H } from '../store/types';
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 }>();
const store = useDocument();
const renaming = ref(false);
const nameDraft = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
function startRename() {
renaming.value = true;
nameDraft.value = props.object.name;
nextTick(() => nameInput.value?.select());
}
function commitRename() {
store.renameObject(props.object.id, nameDraft.value);
renaming.value = false;
}
// ---- drag to move -------------------------------------------------
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
function onMoveDown(e: MouseEvent) {
if (e.button !== 0) return;
emit('select');
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMoveMove);
window.addEventListener('mouseup', onMoveUp);
}
function onMoveMove(e: MouseEvent) {
if (!drag) return;
const dx = (e.clientX - drag.px) / props.zoom;
const dy = (e.clientY - drag.py) / props.zoom;
store.moveObject(props.object.id, drag.ox + dx, drag.oy + dy);
}
function onMoveUp() {
drag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMoveMove);
window.removeEventListener('mouseup', onMoveUp);
}
// ---- SE resize --------------------------------------------------
let rs: { px: number; py: number; w: number; h: number } | null = null;
/** Ghost outline shown *only* while dragging an "Automatisch" table's
* handle (see onResizeMove) -- the real row/col-count change is expensive
* (a full syncWorkbook() per call) so it commits once, on release; this is
* purely a lightweight visual preview in the meantime, touching neither
* the store nor the table's actual DOM. */
const previewSize = ref<{ w: number; h: number } | null>(null);
// An "Automatisch" table's resize handle drags the row/col *count* directly
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
// duplicated there too).
const HEAD_W = 44;
const DEFAULT_W = 104;
/** How many columns fit in `targetPx`, at least 1. */
function colsForWidth(t: TableObject, targetPx: number): number {
let w = HEAD_W;
let c = 0;
while (true) {
const next = w + (t.colWidths[c] ?? DEFAULT_W);
if (next > targetPx && c > 0) break;
w = next;
c++;
}
return Math.max(1, c);
}
/** How many rows fit in `targetPx`, at least 1. */
function rowsForHeight(t: TableObject, targetPx: number): number {
let h = t.rowHeight ?? DEFAULT_ROW_H;
let r = 0;
while (true) {
const next = h + store.rowHeightOf(t.id, r);
if (next > targetPx && r > 0) break;
h = next;
r++;
}
return Math.max(1, r);
}
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
* "Automatisch" table has no stored viewport size of its own (it always
* renders at its natural content size), so this is the reference point a
* resize drag starts from; no explicit "resync" is needed afterwards
* either, since the DOM already re-renders at the new natural size the
* moment `setTableRows`/`setTableCols` changes `rows`/`cols`. */
function fittingSize(t: TableObject): { width: number; height: number } {
let width = HEAD_W;
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
let height = t.rowHeight ?? DEFAULT_ROW_H;
for (let r = 0; r < t.rows; r++) height += store.rowHeightOf(t.id, r);
return { width, height };
}
/** The last row/col index holding a real (non-empty) cell, or -1 -- a
* row/col-count shrink must never go below this, or it would silently
* delete data. `t.cells` never stores an empty-string entry (an emptied
* cell removes its key, see setCellInput), so every present key is real. */
function lastNonEmptyRow(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[1]));
return max;
}
function lastNonEmptyCol(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[0]));
return max;
}
/** How the SE handle behaves for this object -- see the module doc for the
* "Automatisch" vs. "Feste Größe" split. */
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
const o = props.object;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
});
function onResizeDown(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
emit('select');
const o = props.object;
let w: number;
let h: number;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
w = o.width;
h = o.height;
} else if (o.view.mode === 'fixed') {
w = o.view.width;
h = o.view.height;
} else {
const size = fittingSize(o);
w = size.width;
h = size.height;
}
rs = { px: e.clientX, py: e.clientY, w, h };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onResizeMove);
window.addEventListener('mouseup', onResizeUp);
}
function onResizeMove(e: MouseEvent) {
if (!rs) return;
const dx = (e.clientX - rs.px) / props.zoom;
const dy = (e.clientY - rs.py) / props.zoom;
const o = props.object;
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
// update on every mousemove is fine, same as it always was.
store.setTableView(o.id, { width: rs.w + dx, height: rs.h + dy });
} else {
// "Automatisch": dragging drives row/col *count* -- but that's a full
// syncWorkbook() per call, too expensive for every mousemove, so only a
// lightweight ghost-box preview updates live here (see the template);
// the real change commits once, on release, in onResizeUp.
previewSize.value = { w: Math.max(80, rs.w + dx), h: Math.max(52, rs.h + dy) };
}
}
function onResizeUp() {
const o = props.object;
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
// Never past non-empty content, so a hard shrink can't silently delete
// data -- it just settles at the smallest size that still shows
// everything, however far past that the drag actually went.
const targetCols = colsForWidth(o, previewSize.value.w);
const targetRows = rowsForHeight(o, previewSize.value.h);
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
}
previewSize.value = null;
rs = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove);
window.removeEventListener('mouseup', onResizeUp);
}
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' || o.kind === 'button') {
base.width = o.width + 'px';
base.height = o.height + 'px';
}
return base;
});
</script>
<template>
<div
class="canvas-object"
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
:style="style"
@mousedown="emit('select')"
>
<div class="chrome" @mousedown.stop="onMoveDown">
<input
v-if="renaming"
ref="nameInput"
v-model="nameDraft"
class="name-edit"
@mousedown.stop
@blur="commitRename"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="renaming = false"
/>
<span v-else class="name" @dblclick.stop="startRename">
{{ icon }} {{ object.name }}
</span>
<button
class="del"
title="Objekt löschen"
@mousedown.stop
@click.stop="store.deleteObject(object.id)"
>
×
</button>
</div>
<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-if="object.kind === 'text'" :obj-id="object.id" />
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
</div>
<div
v-if="previewSize"
class="resize-ghost"
:style="{ width: previewSize.w + 'px', height: previewSize.h + 'px' }"
/>
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
</div>
</template>
<style scoped>
.canvas-object {
position: absolute;
background: var(--surface);
border-radius: 10px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.canvas-object.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
}
.canvas-object.flex {
display: flex;
flex-direction: column;
}
.chrome {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 4px 0 10px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
border-radius: 10px 10px 0 0;
cursor: grab;
font-size: 11.5px;
color: var(--text-dim);
user-select: none;
}
.chrome:active {
cursor: grabbing;
}
.name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name-edit {
flex: 1;
font: inherit;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 1px 4px;
}
.del {
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 15px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
padding: 0 4px;
}
.del:hover {
background: var(--danger);
color: #fff;
}
.content {
overflow: hidden;
border-radius: 0 0 10px 10px;
}
.canvas-object.flex .content {
flex: 1;
min-height: 0;
}
.resize-ghost {
position: absolute;
top: 27px;
left: 0;
border: 2px dashed var(--accent);
border-radius: 0 0 8px 8px;
background: var(--accent-weak);
pointer-events: none;
z-index: 7;
}
.rs-handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--accent);
border: 2px solid var(--surface);
cursor: nwse-resize;
z-index: 8;
}
</style>