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:
@@ -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
|
eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter
|
||||||
mit demselben Tabellennamen). Frei positionierbar (Ziehen an der
|
mit demselben Tabellennamen). Frei positionierbar (Ziehen an der
|
||||||
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
|
||||||
skaliert die Spaltenbreiten proportional.
|
SE-Griff zieht hier **direkt an Zeilen-/Spaltenzahl**: Vergrößern fügt
|
||||||
- **Feste Größe** — mit frei wählbarer Zahl **fixierter Kopfzeilen und
|
leere Zeilen/Spalten hinzu, Verkleinern entfernt sie — aber **nie** über
|
||||||
fixierter Spalten** (`position: sticky` gegen den Scroll-Container, falls
|
echten Zellinhalt hinweg. Während des Ziehens zeigt ein gestricheltes
|
||||||
der Inhalt doch mal über die Boxgröße hinauswächst — etwa durch Tippen
|
Vorschau-Rechteck die Zielgröße (die eigentliche Änderung ist teuer —
|
||||||
über den Rand hinaus). Der SE-Griff zieht hier **direkt an Zeilen-/
|
ein voller Neuaufbau der Engine — und committet daher erst beim
|
||||||
Spaltenzahl**: Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern
|
Loslassen, nicht bei jeder Mausbewegung); dann wird auf die kleinste
|
||||||
entfernt sie — aber **nie** über echten Zellinhalt hinweg. Beim Loslassen
|
Größe geklemmt, die den vorhandenen Inhalt noch vollständig zeigt.
|
||||||
wird auf die kleinste Größe geklemmt, die den vorhandenen Inhalt noch
|
- **Feste Größe** — ein fester, unabhängig wählbarer **Pixel-Viewport**
|
||||||
vollständig zeigt; die Box passt sich exakt an (kein Leerraum, kein
|
mit frei wählbarer Zahl **fixierter Kopfzeilen und fixierter Spalten**
|
||||||
Scrollen nötig, solange nicht direkt über die Boxgröße hinaus getippt
|
(`position: sticky` gegen den Scroll-Container) — für eine große
|
||||||
wird). Die Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s. u.) bleiben
|
Tabelle, die nur einen kleinen Ausschnitt auf dem Canvas belegen soll,
|
||||||
davon unabhängig — reine Pixelmaße, ohne Rückwirkung auf Zeilen-/
|
mit internem Scrollen für den Rest. Der SE-Griff zieht hier **nur den
|
||||||
Spaltenzahl.
|
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
|
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**
|
||||||
@@ -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
|
`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-/
|
- **Größenänderung einer „Automatisch"-Tabelle** (SE-Griff → Zeilen-/
|
||||||
Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (`CanvasObject.vue`)
|
Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (`CanvasObject.vue`)
|
||||||
implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein
|
implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein
|
||||||
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
|
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
|
||||||
|
|||||||
@@ -3,8 +3,16 @@
|
|||||||
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
|
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
|
||||||
*
|
*
|
||||||
* - the header strip and the frame border move the object;
|
* - 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
|
* - the SE handle resizes a chart/text freely; for a table, what it does
|
||||||
* columns (Numbers-style);
|
* 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.
|
* - name is editable via double-click, delete via the × button.
|
||||||
*
|
*
|
||||||
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
|
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
|
||||||
@@ -62,12 +70,16 @@ function onMoveUp() {
|
|||||||
|
|
||||||
// ---- SE resize --------------------------------------------------
|
// ---- SE resize --------------------------------------------------
|
||||||
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);
|
/** 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 "Automatisch" table's resize handle drags the row/col *count* directly
|
||||||
// an independent scrollable viewport, see the module doc) -- these mirror
|
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
|
||||||
// TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are duplicated there
|
// duplicated there too).
|
||||||
// too, same as the existing `104` passed to scaleTableColumns below).
|
|
||||||
const HEAD_W = 44;
|
const HEAD_W = 44;
|
||||||
const DEFAULT_W = 104;
|
const DEFAULT_W = 104;
|
||||||
|
|
||||||
@@ -95,10 +107,12 @@ function rowsForHeight(t: TableObject, targetPx: number): number {
|
|||||||
}
|
}
|
||||||
return Math.max(1, r);
|
return Math.max(1, r);
|
||||||
}
|
}
|
||||||
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- used
|
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
|
||||||
* to re-sync the viewport box after a row/col-count change, since the
|
* "Automatisch" table has no stored viewport size of its own (it always
|
||||||
* actual result can differ from what was dragged to (a shrink that would
|
* renders at its natural content size), so this is the reference point a
|
||||||
* destroy non-empty content is clamped, see lastNonEmptyRow/Col below). */
|
* 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 } {
|
function fittingSize(t: TableObject): { width: number; height: number } {
|
||||||
let width = HEAD_W;
|
let width = HEAD_W;
|
||||||
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_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;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How the SE handle behaves for this object. */
|
/** How the SE handle behaves for this object -- see the module doc for the
|
||||||
const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => {
|
* "Automatisch" vs. "Feste Größe" split. */
|
||||||
|
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
|
||||||
const o = props.object;
|
const o = props.object;
|
||||||
if (o.kind === 'chart' || o.kind === 'text') return 'free';
|
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) {
|
function onResizeDown(e: MouseEvent) {
|
||||||
@@ -133,7 +148,6 @@ function onResizeDown(e: MouseEvent) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
emit('select');
|
emit('select');
|
||||||
const o = props.object;
|
const o = props.object;
|
||||||
const rect = contentEl.value?.getBoundingClientRect();
|
|
||||||
let w: number;
|
let w: number;
|
||||||
let h: number;
|
let h: number;
|
||||||
if (o.kind === 'chart' || o.kind === 'text') {
|
if (o.kind === 'chart' || o.kind === 'text') {
|
||||||
@@ -143,8 +157,9 @@ function onResizeDown(e: MouseEvent) {
|
|||||||
w = o.view.width;
|
w = o.view.width;
|
||||||
h = o.view.height;
|
h = o.view.height;
|
||||||
} else {
|
} else {
|
||||||
w = (rect?.width ?? 300) / props.zoom;
|
const size = fittingSize(o);
|
||||||
h = (rect?.height ?? 200) / props.zoom;
|
w = size.width;
|
||||||
|
h = size.height;
|
||||||
}
|
}
|
||||||
rs = { px: e.clientX, py: e.clientY, w, h };
|
rs = { px: e.clientX, py: e.clientY, w, h };
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
@@ -159,31 +174,31 @@ function onResizeMove(e: MouseEvent) {
|
|||||||
if (resizeKind.value === 'free') {
|
if (resizeKind.value === 'free') {
|
||||||
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 === 'viewport') {
|
||||||
// Cheap live preview only (setTableView never rebuilds the workbook) --
|
// "Feste Größe": pure pixel-viewport resize, independent of row/col
|
||||||
// the actual row/col-count change (setTableRows/setTableCols, each a
|
// count -- cheap (setTableView never rebuilds the workbook), so a live
|
||||||
// full syncWorkbook()) is committed once, on release, in onResizeUp.
|
// update on every mousemove is fine, same as it always was.
|
||||||
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);
|
// "Automatisch": dragging drives row/col *count* -- but that's a full
|
||||||
store.scaleTableColumns(o.id, factor, 104);
|
// syncWorkbook() per call, too expensive for every mousemove, so only a
|
||||||
rs = { ...rs, px: e.clientX, w: rs.w + dx };
|
// 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() {
|
function onResizeUp() {
|
||||||
const o = props.object;
|
const o = props.object;
|
||||||
if (rs && resizeKind.value === 'table-viewport' && o.kind === 'table') {
|
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
|
||||||
// Resizing a "fixed" table now drives its row/col *count* directly
|
// Never past non-empty content, so a hard shrink can't silently delete
|
||||||
// (Numbers-style: the box IS the grid, no independent scroll viewport)
|
// data -- it just settles at the smallest size that still shows
|
||||||
// instead of only the pixel box -- but never past non-empty content,
|
// everything, however far past that the drag actually went.
|
||||||
// so a shrink can't silently delete data.
|
const targetCols = colsForWidth(o, previewSize.value.w);
|
||||||
const targetCols = colsForWidth(o, o.view.width);
|
const targetRows = rowsForHeight(o, previewSize.value.h);
|
||||||
const targetRows = rowsForHeight(o, o.view.height);
|
|
||||||
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
|
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
|
||||||
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(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;
|
rs = null;
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
window.removeEventListener('mousemove', onResizeMove);
|
window.removeEventListener('mousemove', onResizeMove);
|
||||||
@@ -236,12 +251,17 @@ const style = computed(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="contentEl" class="content">
|
<div class="content">
|
||||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
|
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
|
||||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||||
<TextBox v-else :obj-id="object.id" />
|
<TextBox v-else :obj-id="object.id" />
|
||||||
</div>
|
</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 v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -314,6 +334,16 @@ const style = computed(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.rs-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
|
|||||||
@@ -1381,15 +1381,6 @@ function createStore() {
|
|||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
|
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). */
|
/** Height of one row (per-row override, else the table default). */
|
||||||
function rowHeightOf(tableId: string, row: number): number {
|
function rowHeightOf(tableId: string, row: number): number {
|
||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
@@ -2241,7 +2232,6 @@ function createStore() {
|
|||||||
removeLastRow,
|
removeLastRow,
|
||||||
removeLastColumn,
|
removeLastColumn,
|
||||||
setColWidth,
|
setColWidth,
|
||||||
scaleTableColumns,
|
|
||||||
setRowHeight,
|
setRowHeight,
|
||||||
setTableRowHeight,
|
setTableRowHeight,
|
||||||
rowHeightOf,
|
rowHeightOf,
|
||||||
|
|||||||
Reference in New Issue
Block a user