diff --git a/README.md b/README.md index 2687f76..b37d40f 100644 --- a/README.md +++ b/README.md @@ -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 mit demselben Tabellennamen). Frei positionierbar (Ziehen an der Kopfleiste). Zwei Darstellungen: - - **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar; der SE-Griff - skaliert die Spaltenbreiten proportional. - - **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. + - **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar. 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. Während des Ziehens zeigt ein gestricheltes + Vorschau-Rechteck die Zielgröße (die eigentliche Änderung ist teuer — + ein voller Neuaufbau der Engine — und committet daher erst beim + Loslassen, nicht bei jeder Mausbewegung); dann wird auf die kleinste + Größe geklemmt, die den vorhandenen Inhalt noch vollständig zeigt. + - **Feste Größe** — ein fester, unabhängig wählbarer **Pixel-Viewport** + mit frei wählbarer Zahl **fixierter Kopfzeilen und fixierter Spalten** + (`position: sticky` gegen den Scroll-Container) — für eine große + Tabelle, die nur einen kleinen Ausschnitt auf dem Canvas belegen soll, + mit internem Scrollen für den Rest. Der SE-Griff zieht hier **nur den + 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 an der Unterkante des Zeilenkopfs (Standard + je Zeile). - **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 entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste 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`) implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests. diff --git a/src/components/CanvasObject.vue b/src/components/CanvasObject.vue index ff1b4bc..8da8095 100644 --- a/src/components/CanvasObject.vue +++ b/src/components/CanvasObject.vue @@ -3,8 +3,16 @@ * Draggable / resizable wrapper for a table or chart object on the sheet canvas. * * - 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 - * columns (Numbers-style); + * - 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 @@ -62,12 +70,16 @@ function onMoveUp() { // ---- SE resize -------------------------------------------------- let rs: { px: number; py: number; w: number; h: number } | null = null; -const contentEl = ref(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 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). +// 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; @@ -95,10 +107,12 @@ function rowsForHeight(t: TableObject, targetPx: number): number { } 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). */ +/** 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; @@ -121,11 +135,12 @@ function lastNonEmptyCol(t: TableObject): number { return max; } -/** How the SE handle behaves for this object. */ -const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => { +/** 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') return 'free'; - return o.view.mode === 'fixed' ? 'table-viewport' : 'table-columns'; + return o.view.mode === 'fixed' ? 'viewport' : 'grid-size'; }); function onResizeDown(e: MouseEvent) { @@ -133,7 +148,6 @@ function onResizeDown(e: MouseEvent) { e.stopPropagation(); emit('select'); const o = props.object; - const rect = contentEl.value?.getBoundingClientRect(); let w: number; let h: number; if (o.kind === 'chart' || o.kind === 'text') { @@ -143,8 +157,9 @@ function onResizeDown(e: MouseEvent) { w = o.view.width; h = o.view.height; } else { - w = (rect?.width ?? 300) / props.zoom; - h = (rect?.height ?? 200) / props.zoom; + const size = fittingSize(o); + w = size.width; + h = size.height; } rs = { px: e.clientX, py: e.clientY, w, h }; document.body.style.userSelect = 'none'; @@ -159,31 +174,31 @@ 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 (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. + } 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 { - const factor = Math.max(0.4, (rs.w + dx) / rs.w); - store.scaleTableColumns(o.id, factor, 104); - rs = { ...rs, px: e.clientX, w: rs.w + dx }; + // "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 === '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); + 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)); - const fresh = store.tableById(o.id); - if (fresh) store.setTableView(o.id, fittingSize(fresh)); } + previewSize.value = null; rs = null; document.body.style.userSelect = ''; window.removeEventListener('mousemove', onResizeMove); @@ -236,12 +251,17 @@ const style = computed(() => { -
+
+
@@ -314,6 +334,16 @@ const style = computed(() => { 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; diff --git a/src/store/document.ts b/src/store/document.ts index bf12157..f977232 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -1381,15 +1381,6 @@ function createStore() { const t = tableById(tableId); 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). */ function rowHeightOf(tableId: string, row: number): number { const t = tableById(tableId); @@ -2241,7 +2232,6 @@ function createStore() { removeLastRow, removeLastColumn, setColWidth, - scaleTableColumns, setRowHeight, setTableRowHeight, rowHeightOf,