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
+64 -34
View File
@@ -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<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 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(() => {
</button>
</div>
<div ref="contentEl" class="content">
<div class="content">
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
<TextBox v-else :obj-id="object.id" />
</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>
</template>
@@ -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;
-10
View File
@@ -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,