Größenänderung einer "Feste Größe"-Tabelle steuert Zeilen-/Spaltenzahl

Der SE-Griff einer Tabelle im "Feste Größe"-Modus ändert nicht mehr nur
den scrollbaren Pixel-Viewport, sondern direkt die Zeilen-/Spaltenzahl:
Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern entfernt sie —
aber nie über echten Zellinhalt hinweg (klemmt auf die kleinste Größe,
die den vorhandenen Inhalt noch vollständig zeigt). Live-Vorschau während
des Ziehens bleibt der günstige reine Viewport-Resize (kein Workbook-
Rebuild pro Mausbewegung); die eigentliche Zeilen-/Spaltenänderung
committet erst beim Loslassen. Reine UI-Änderung (CanvasObject.vue),
nutzt die bestehenden setTableRows/setTableCols weiter.

Playwright-live-verifiziert, kein Komponenten-Test-Setup vorhanden
(README dokumentiert die Lücke).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 22:50:27 +02:00
co-authored by Claude Sonnet 5
parent aef8f1349c
commit 76587c1cee
2 changed files with 93 additions and 4 deletions
+18 -3
View File
@@ -34,9 +34,18 @@ scroll- und zoombare Fläche, die **Objekte** trägt:
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 SE-Griff
skaliert die Spaltenbreiten proportional. skaliert die Spaltenbreiten proportional.
- **Feste Größe** — fester Viewport, **intern scrollbar**, mit frei wählbarer - **Feste Größe** — mit frei wählbarer Zahl **fixierter Kopfzeilen und
Zahl **fixierter Kopfzeilen und fixierter Spalten** (`position: sticky` fixierter Spalten** (`position: sticky` gegen den Scroll-Container, falls
gegen den Scroll-Container). Der SE-Griff zieht dann den Viewport auf. der Inhalt doch mal über die Boxgröße hinauswächst — etwa durch Tippen
über den Rand hinaus). Der SE-Griff zieht hier **direkt an Zeilen-/
Spaltenzahl**: Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern
entfernt sie — aber **nie** über echten Zellinhalt hinweg. Beim Loslassen
wird auf die kleinste Größe geklemmt, die den vorhandenen Inhalt noch
vollständig zeigt; die Box passt sich exakt an (kein Leerraum, kein
Scrollen nötig, solange nicht direkt über die Boxgröße hinaus getippt
wird). Die Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s. u.) bleiben
davon unabhängig — reine Pixelmaße, ohne Rückwirkung auf Zeilen-/
Spaltenzahl.
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**
@@ -1028,6 +1037,12 @@ 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-/
Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (`CanvasObject.vue`)
implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
Nur live per Playwright verifiziert (Wachsen, Schrumpfen mit
Inhaltsschutz, deutliches Überschreiten der bestehenden Größe).
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden - **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
+75 -1
View File
@@ -11,7 +11,8 @@
* zoom level. * zoom level.
*/ */
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import type { SheetObject } from '../store/types'; import type { SheetObject, TableObject } from '../store/types';
import { DEFAULT_ROW_H } from '../store/types';
import { useDocument } from '../store/document'; 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';
@@ -63,6 +64,63 @@ function onMoveUp() {
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); const contentEl = ref<HTMLElement | null>(null);
// A "fixed" table's resize handle drags the row/col *count* directly (not
// an independent scrollable viewport, see the module doc) -- these mirror
// TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are duplicated there
// too, same as the existing `104` passed to scaleTableColumns below).
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 -- used
* to re-sync the viewport box after a row/col-count change, since the
* actual result can differ from what was dragged to (a shrink that would
* destroy non-empty content is clamped, see lastNonEmptyRow/Col below). */
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. */ /** How the SE handle behaves for this object. */
const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => { const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => {
const o = props.object; const o = props.object;
@@ -102,6 +160,9 @@ function onResizeMove(e: MouseEvent) {
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 === 'table-viewport') {
// Cheap live preview only (setTableView never rebuilds the workbook) --
// the actual row/col-count change (setTableRows/setTableCols, each a
// full syncWorkbook()) is committed once, on release, in onResizeUp.
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); const factor = Math.max(0.4, (rs.w + dx) / rs.w);
@@ -110,6 +171,19 @@ function onResizeMove(e: MouseEvent) {
} }
} }
function onResizeUp() { function onResizeUp() {
const o = props.object;
if (rs && resizeKind.value === 'table-viewport' && o.kind === 'table') {
// Resizing a "fixed" table now drives its row/col *count* directly
// (Numbers-style: the box IS the grid, no independent scroll viewport)
// instead of only the pixel box -- but never past non-empty content,
// so a shrink can't silently delete data.
const targetCols = colsForWidth(o, o.view.width);
const targetRows = rowsForHeight(o, o.view.height);
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(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));
}
rs = null; rs = null;
document.body.style.userSelect = ''; document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove); window.removeEventListener('mousemove', onResizeMove);