diff --git a/README.md b/README.md index fc63f9c..fba9513 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,18 @@ scroll- und zoombare Fläche, die **Objekte** trägt: Kopfleiste). Zwei Darstellungen: - **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar; der SE-Griff skaliert die Spaltenbreiten proportional. - - **Feste Größe** — fester Viewport, **intern scrollbar**, mit frei wählbarer - Zahl **fixierter Kopfzeilen und fixierter Spalten** (`position: sticky` - gegen den Scroll-Container). Der SE-Griff zieht dann den Viewport auf. + - **Feste Größe** — mit frei wählbarer Zahl **fixierter Kopfzeilen und + fixierter Spalten** (`position: sticky` gegen den Scroll-Container, falls + 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 an der Unterkante des Zeilenkopfs (Standard + je Zeile). - **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 entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste 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 nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der diff --git a/src/components/CanvasObject.vue b/src/components/CanvasObject.vue index 5b1d284..ff1b4bc 100644 --- a/src/components/CanvasObject.vue +++ b/src/components/CanvasObject.vue @@ -11,7 +11,8 @@ * zoom level. */ 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 TableGrid from './TableGrid.vue'; import ChartView from './ChartView.vue'; @@ -63,6 +64,63 @@ function onMoveUp() { let rs: { px: number; py: number; w: number; h: number } | null = null; const contentEl = ref(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. */ const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => { 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); else if (o.kind === 'text') store.resizeText(o.id, rs.w + dx, rs.h + dy); } 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 }); } else { const factor = Math.max(0.4, (rs.w + dx) / rs.w); @@ -110,6 +171,19 @@ function onResizeMove(e: MouseEvent) { } } 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; document.body.style.userSelect = ''; window.removeEventListener('mousemove', onResizeMove);