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
+75 -1
View File
@@ -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<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. */
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);