Größenänderungs-Verhalten der Tabellenmodi vertauscht

Der SE-Griff-Effekt war genau falsch herum zugeordnet: "Automatisch"-
Tabellen (Größe folgt immer dem Inhalt) sollen beim Ziehen die Zeilen-/
Spaltenzahl ändern, "Feste Größe"-Tabellen (expliziter, unabhängig
wählbarer Scroll-Viewport) sollen unverändert nur den Pixel-Viewport
ändern.

- "Automatisch": Ziehen zeigt ein gestricheltes Vorschau-Rechteck (die
  eigentliche Zeilen-/Spaltenänderung ist teuer -- voller Workbook-Rebuild
  -- und committet daher erst beim Loslassen, nicht live). Wachsen fügt
  leere Zeilen/Spalten hinzu, Schrumpfen entfernt sie nie über echten
  Zellinhalt hinweg (gleicher Inhaltsschutz wie zuvor, nur am richtigen
  Modus).
- "Feste Größe": zurück auf reines Pixel-Viewport-Resize, unabhängig von
  der Zeilen-/Spaltenzahl -- das ursprüngliche Verhalten.
- Die dadurch tot gewordene alte "Automatisch"-Funktion (proportionale
  Spaltenbreiten-Skalierung beim Ziehen) entfernt (scaleTableColumns),
  keine andere Stelle rief sie noch auf.

vue-tsc/eslint/build sauber, Playwright-live-verifiziert (Automatisch:
Wachsen 12×8→19×11, hartes Schrumpfen klemmt auf 6×5 mit erhaltenem
Inhalt; Feste Größe: Zeilen-/Spaltenzahl bleibt bei Viewport-Resize
unverändert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 23:26:54 +02:00
co-authored by Claude Sonnet 5
parent 8a7fafcd7a
commit 3594b12615
3 changed files with 81 additions and 59 deletions
+17 -15
View File
@@ -32,20 +32,22 @@ scroll- und zoombare Fläche, die **Objekte** trägt:
eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter
mit demselben Tabellennamen). Frei positionierbar (Ziehen an der mit demselben Tabellennamen). Frei positionierbar (Ziehen an der
Kopfleiste). Zwei Darstellungen: Kopfleiste). Zwei Darstellungen:
- **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar; der SE-Griff - **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar. Der
skaliert die Spaltenbreiten proportional. SE-Griff zieht hier **direkt an Zeilen-/Spaltenzahl**: Vergrößern fügt
- **Feste Größe** — mit frei wählbarer Zahl **fixierter Kopfzeilen und leere Zeilen/Spalten hinzu, Verkleinern entfernt sie — aber **nie** über
fixierter Spalten** (`position: sticky` gegen den Scroll-Container, falls echten Zellinhalt hinweg. Während des Ziehens zeigt ein gestricheltes
der Inhalt doch mal über die Boxgröße hinauswächst — etwa durch Tippen Vorschau-Rechteck die Zielgröße (die eigentliche Änderung ist teuer —
über den Rand hinaus). Der SE-Griff zieht hier **direkt an Zeilen-/ ein voller Neuaufbau der Engine — und committet daher erst beim
Spaltenzahl**: Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern Loslassen, nicht bei jeder Mausbewegung); dann wird auf die kleinste
entfernt sie — aber **nie** über echten Zellinhalt hinweg. Beim Loslassen Größe geklemmt, die den vorhandenen Inhalt noch vollständig zeigt.
wird auf die kleinste Größe geklemmt, die den vorhandenen Inhalt noch - **Feste Größe** — ein fester, unabhängig wählbarer **Pixel-Viewport**
vollständig zeigt; die Box passt sich exakt an (kein Leerraum, kein mit frei wählbarer Zahl **fixierter Kopfzeilen und fixierter Spalten**
Scrollen nötig, solange nicht direkt über die Boxgröße hinaus getippt (`position: sticky` gegen den Scroll-Container) — für eine große
wird). Die Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s. u.) bleiben Tabelle, die nur einen kleinen Ausschnitt auf dem Canvas belegen soll,
davon unabhängig — reine Pixelmaße, ohne Rückwirkung auf Zeilen-/ mit internem Scrollen für den Rest. Der SE-Griff zieht hier **nur den
Spaltenzahl. Viewport**, unabhängig von der Zeilen-/Spaltenzahl (die bleibt exakt wie
sie ist) — dieselben Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s.
u.) steuern denselben Wert.
Spaltenbreite per Drag am Spaltenkopf (ab **24 px**), **Zeilenhöhe** per Drag Spaltenbreite per Drag am Spaltenkopf (ab **24 px**), **Zeilenhöhe** per Drag
an der Unterkante des Zeilenkopfs (Standard + je Zeile). an der Unterkante des Zeilenkopfs (Standard + je Zeile).
- **Diagramm-Objekt** — Chart.js (Balken/Linie), frei positionier- **und** - **Diagramm-Objekt** — Chart.js (Balken/Linie), frei positionier- **und**
@@ -1081,7 +1083,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
`OFFSET`/`INDEX`-Äquivalent — ein `#REF`, das aus einer gelöschten Zeile `OFFSET`/`INDEX`-Äquivalent — ein `#REF`, das aus einer gelöschten Zeile
entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste
Zelle"-Heuristik. Zelle"-Heuristik.
- **Größenänderung einer „Feste Größe"-Tabelle** (SE-Griff → Zeilen-/ - **Größenänderung einer „Automatisch"-Tabelle** (SE-Griff → Zeilen-/
Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (`CanvasObject.vue`) Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (`CanvasObject.vue`)
implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests. Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
+64 -34
View File
@@ -3,8 +3,16 @@
* Draggable / resizable wrapper for a table or chart object on the sheet canvas. * Draggable / resizable wrapper for a table or chart 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 (free w/h) or proportionally scales a table's * - the SE handle resizes a chart/text freely; for a table, what it does
* columns (Numbers-style); * 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. * - name is editable via double-click, delete via the × button.
* *
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any * All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
@@ -62,12 +70,16 @@ function onMoveUp() {
// ---- SE resize -------------------------------------------------- // ---- SE resize --------------------------------------------------
let rs: { px: number; py: number; w: number; h: number } | null = null; let rs: { px: number; py: number; w: number; h: number } | null = null;
const contentEl = ref<HTMLElement | 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);
// A "fixed" table's resize handle drags the row/col *count* directly (not // An "Automatisch" table's resize handle drags the row/col *count* directly
// an independent scrollable viewport, see the module doc) -- these mirror // -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
// TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are duplicated there // duplicated there too).
// too, same as the existing `104` passed to scaleTableColumns below).
const HEAD_W = 44; const HEAD_W = 44;
const DEFAULT_W = 104; const DEFAULT_W = 104;
@@ -95,10 +107,12 @@ function rowsForHeight(t: TableObject, targetPx: number): number {
} }
return Math.max(1, r); return Math.max(1, r);
} }
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- used /** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
* to re-sync the viewport box after a row/col-count change, since the * "Automatisch" table has no stored viewport size of its own (it always
* actual result can differ from what was dragged to (a shrink that would * renders at its natural content size), so this is the reference point a
* destroy non-empty content is clamped, see lastNonEmptyRow/Col below). */ * 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 } { function fittingSize(t: TableObject): { width: number; height: number } {
let width = HEAD_W; let width = HEAD_W;
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W; for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
@@ -121,11 +135,12 @@ function lastNonEmptyCol(t: TableObject): number {
return max; return max;
} }
/** How the SE handle behaves for this object. */ /** How the SE handle behaves for this object -- see the module doc for the
const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => { * "Automatisch" vs. "Feste Größe" split. */
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') return 'free';
return o.view.mode === 'fixed' ? 'table-viewport' : 'table-columns'; return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
}); });
function onResizeDown(e: MouseEvent) { function onResizeDown(e: MouseEvent) {
@@ -133,7 +148,6 @@ function onResizeDown(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
emit('select'); emit('select');
const o = props.object; const o = props.object;
const rect = contentEl.value?.getBoundingClientRect();
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') {
@@ -143,8 +157,9 @@ function onResizeDown(e: MouseEvent) {
w = o.view.width; w = o.view.width;
h = o.view.height; h = o.view.height;
} else { } else {
w = (rect?.width ?? 300) / props.zoom; const size = fittingSize(o);
h = (rect?.height ?? 200) / props.zoom; w = size.width;
h = size.height;
} }
rs = { px: e.clientX, py: e.clientY, w, h }; rs = { px: e.clientX, py: e.clientY, w, h };
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
@@ -159,31 +174,31 @@ 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 (resizeKind.value === 'table-viewport') { } else if (resizeKind.value === 'viewport') {
// Cheap live preview only (setTableView never rebuilds the workbook) -- // "Feste Größe": pure pixel-viewport resize, independent of row/col
// the actual row/col-count change (setTableRows/setTableCols, each a // count -- cheap (setTableView never rebuilds the workbook), so a live
// full syncWorkbook()) is committed once, on release, in onResizeUp. // update on every mousemove is fine, same as it always was.
store.setTableView(o.id, { width: rs.w + dx, height: rs.h + dy }); store.setTableView(o.id, { width: rs.w + dx, height: rs.h + dy });
} else { } else {
const factor = Math.max(0.4, (rs.w + dx) / rs.w); // "Automatisch": dragging drives row/col *count* -- but that's a full
store.scaleTableColumns(o.id, factor, 104); // syncWorkbook() per call, too expensive for every mousemove, so only a
rs = { ...rs, px: e.clientX, w: rs.w + dx }; // 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() { function onResizeUp() {
const o = props.object; const o = props.object;
if (rs && resizeKind.value === 'table-viewport' && o.kind === 'table') { if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
// Resizing a "fixed" table now drives its row/col *count* directly // Never past non-empty content, so a hard shrink can't silently delete
// (Numbers-style: the box IS the grid, no independent scroll viewport) // data -- it just settles at the smallest size that still shows
// instead of only the pixel box -- but never past non-empty content, // everything, however far past that the drag actually went.
// so a shrink can't silently delete data. const targetCols = colsForWidth(o, previewSize.value.w);
const targetCols = colsForWidth(o, o.view.width); const targetRows = rowsForHeight(o, previewSize.value.h);
const targetRows = rowsForHeight(o, o.view.height);
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1)); store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1)); store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
const fresh = store.tableById(o.id);
if (fresh) store.setTableView(o.id, fittingSize(fresh));
} }
previewSize.value = null;
rs = null; rs = null;
document.body.style.userSelect = ''; document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove); window.removeEventListener('mousemove', onResizeMove);
@@ -236,12 +251,17 @@ const style = computed(() => {
</button> </button>
</div> </div>
<div ref="contentEl" 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 :obj-id="object.id" />
</div> </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 v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
</div> </div>
</template> </template>
@@ -314,6 +334,16 @@ const style = computed(() => {
flex: 1; flex: 1;
min-height: 0; 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 { .rs-handle {
position: absolute; position: absolute;
right: -5px; right: -5px;
-10
View File
@@ -1381,15 +1381,6 @@ function createStore() {
const t = tableById(tableId); const t = tableById(tableId);
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width)); if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
} }
/** Numbers-style proportional resize: scale every column width by `factor`. */
function scaleTableColumns(tableId: string, factor: number, base: number) {
const t = tableById(tableId);
if (!t) return;
for (let c = 0; c < t.cols; c++) {
const cur = t.colWidths[c] ?? base;
t.colWidths[c] = Math.max(MIN_COL_W, Math.round(cur * factor));
}
}
/** Height of one row (per-row override, else the table default). */ /** Height of one row (per-row override, else the table default). */
function rowHeightOf(tableId: string, row: number): number { function rowHeightOf(tableId: string, row: number): number {
const t = tableById(tableId); const t = tableById(tableId);
@@ -2241,7 +2232,6 @@ function createStore() {
removeLastRow, removeLastRow,
removeLastColumn, removeLastColumn,
setColWidth, setColWidth,
scaleTableColumns,
setRowHeight, setRowHeight,
setTableRowHeight, setTableRowHeight,
rowHeightOf, rowHeightOf,