scrolling and size of canvas

This commit is contained in:
wkh
2026-09-09 23:13:46 +02:00
parent 1c5e57ceb4
commit a623adf9cc
3 changed files with 198 additions and 52 deletions
+10 -1
View File
@@ -47,7 +47,16 @@ scroll- und zoombare Fläche, die **Objekte** trägt:
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.
u.) steuern denselben Wert. **Zeilen-Virtualisierung**: nur die
Zeilen im sichtbaren Ausschnitt (+ etwas Überhang) landen tatsächlich im
DOM — alles andere ist ein Platzhalter-`<tr>` in passender Höhe, damit
Scrollbar-Größe/-Position stimmen. Macht auch eine Tabelle mit
zehntausenden Zeilen praktikabel (gemessen: `PageDown` auf 50.000
Zeilen ging von ~7s auf ~15ms); nur für **feste** Tabellen (die einzige
Ansicht mit einem echten, begrenzten Scroll-Container) — eine **auto**-
große Tabelle dieser Größe wäre ohnehin ein unbrauchbar hohes
Canvas-Objekt, unabhängig vom DOM. Nur Zeilen, nicht Spalten (in der
Praxis wird eher die Zeilenzahl groß).
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**
+15 -3
View File
@@ -31,13 +31,25 @@ const appearance = computed(() => {
});
const showGrid = computed(() => sheet.value.showGrid !== false);
/** Unscaled plane size: enclose every object with generous margin. */
/** Unscaled plane size: enclose every object with generous margin.
* A `fixed`-viewport table only ever occupies its own `view.width`/
* `view.height` on the canvas (that's the entire point of "feste Größe" --
* a bounded, internally-scrolling box) regardless of how many rows/cols it
* holds; only an `auto`-size table actually grows with its content, so only
* that case falls back to the row/col-count estimate. */
const plane = computed(() => {
let w = 1600;
let h = 1000;
for (const o of sheet.value.objects) {
const ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
const oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
let ow: number;
let oh: number;
if (o.kind === 'table' && o.view.mode === 'fixed') {
ow = o.view.width;
oh = o.view.height;
} else {
ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
}
w = Math.max(w, o.x + ow + 500);
h = Math.max(h, o.y + oh + 400);
}
+173 -48
View File
@@ -34,11 +34,52 @@ const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
/** Prefix sums of row heights (`rowCumHeights[i]` = total height of rows
* `0..i-1`, excluding the header) -- lets `rowTop()` and the row
* virtualization below answer "how tall is everything above row r" and
* "which row is at pixel y" in O(1)/O(log rows) instead of walking from row
* 0 on every call, which used to dominate render cost on large tables (a
* single cell's position requires summing every row above it; multiplied
* across thousands of rendered cells, that's O(rows^2) per render). Only
* recomputed when the row count or any row height actually changes (a Vue
* `computed`, not on every scroll/render). */
const rowCumHeights = computed(() => {
const t = table.value;
const n = t?.rows ?? 0;
const arr = new Array<number>(n + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) arr[i + 1] = arr[i] + rowH(i);
return arr;
});
/** Same idea for columns -- cheap regardless (column counts are small), kept
* for consistency and because `colLeft()` is also called per rendered cell. */
const colCumWidths = computed(() => {
const t = table.value;
const n = t?.cols ?? 0;
const arr = new Array<number>(n + 1);
arr[0] = 0;
for (let i = 0; i < n; i++) arr[i + 1] = arr[i] + colWidth(i);
return arr;
});
/** Largest `i` (0-based row/col index) whose cumulative offset is `<= y`,
* clamped into `[0, cum.length - 2]` -- binary search over a prefix-sum
* array built by `rowCumHeights`/`colCumWidths`. */
function indexAtOffset(cum: number[], y: number): number {
let lo = 0;
let hi = cum.length - 2;
if (hi < 0) return 0;
if (y <= 0) return 0;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (cum[mid] <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** px from the table's top edge to the start of row `r` (incl. header). */
function rowTop(r: number): number {
let y = headerH.value;
for (let i = 0; i < r; i++) y += rowH(i);
return y;
return headerH.value + (rowCumHeights.value[r] ?? 0);
}
const rootStyle = computed(() => {
@@ -85,15 +126,86 @@ const scrollStyle = computed(() =>
/** px from the table's left edge to the start of column `c` (incl. row header). */
function colLeft(c: number): number {
let x = HEAD_W;
for (let i = 0; i < c; i++) x += colWidth(i);
return x;
return HEAD_W + (colCumWidths.value[c] ?? 0);
}
/** The internal scroll element of a fixed table (null for auto-size tables). */
const scrollEl = () =>
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
// ---- row virtualization (fixed-viewport tables only) -----------------
// A large `auto`-size table (intrinsic height, no internal scroll -- the
// surrounding pan/zoom canvas scrolls it instead) still renders every row;
// virtualizing that would mean tracking visibility against a transformed
// canvas ancestor, a much bigger change for a case that's already
// impractical regardless (a 50k-row `auto` table would be a
// million-pixel-tall canvas object). `fixed` mode already has exactly the
// bounded, actually-scrollable container this needs (`.tg-scroll`), and is
// the mode the README already describes as the one for a large table -- so
// virtualization is scoped to it. Only rows are virtualized (not columns):
// row counts are what actually get large in practice.
const OVERSCAN_PX = 300;
// rAF-throttled rather than updated straight from the native 'scroll' event,
// which can fire far more often than once per frame during a fling --
// autoScrollTick() (drag-to-scroll near a fixed table's edge) sets this
// directly instead, since it needs the newly-revealed row rendered before
// its own immediately-following hit-test, not after the next 'scroll' event.
const scrollTop = ref(0);
let scrollRaf = 0;
function onScroll() {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
const box = scrollEl();
if (box) scrollTop.value = box.scrollTop;
});
}
/** [startRow, endRow] (inclusive) of the rows that should actually be
* rendered -- the full range for an `auto` table (unvirtualized, as
* before), or the scrolled-to window (+ overscan) for a `fixed` one. Always
* includes at least the frozen rows, though those are rendered separately
* (see `renderedRows`) regardless of this range. */
const visibleRowRange = computed<[number, number]>(() => {
const t = table.value;
const rows = t?.rows ?? 1;
if (!isFixed.value) return [0, rows - 1];
const cum = rowCumHeights.value;
const top = Math.max(0, scrollTop.value - headerH.value - OVERSCAN_PX);
const bottom = Math.max(0, scrollTop.value - headerH.value + view.value.height + OVERSCAN_PX);
const start = indexAtOffset(cum, top);
const end = Math.min(rows - 1, indexAtOffset(cum, bottom) + 1);
return [start, end];
});
type RowItem =
| { kind: 'row'; r: number; key: string }
| { kind: 'spacer'; height: number; key: string };
/** The actual `<tr>`s to render, in order: the (always-on) frozen rows first,
* then -- for a virtualized table -- a spacer standing in for every hidden
* row above the visible window, the visible rows themselves, and a closing
* spacer for everything hidden below. A plain `<table>` doesn't support
* absolutely-positioned rows, so this (rather than positioning each row by
* pixel offset) is what keeps the scrollbar's size/position correct while
* only a bounded number of rows actually exist in the DOM. */
const renderedRows = computed<RowItem[]>(() => {
const t = table.value;
if (!t) return [];
if (!isFixed.value) {
return Array.from({ length: t.rows }, (_, r) => ({ kind: 'row', r, key: 'r' + r }) as const);
}
const fr = frozenRows.value;
const [start, end] = visibleRowRange.value;
const cum = rowCumHeights.value;
const items: RowItem[] = [];
for (let r = 0; r < fr; r++) items.push({ kind: 'row', r, key: 'r' + r });
const bodyStart = Math.max(start, fr);
const topSpacer = (cum[bodyStart] ?? 0) - (cum[fr] ?? 0);
if (topSpacer > 0) items.push({ kind: 'spacer', height: topSpacer, key: 'top-spacer' });
for (let r = bodyStart; r <= end; r++) items.push({ kind: 'row', r, key: 'r' + r });
const bottomSpacer = (cum[t.rows] ?? 0) - (cum[Math.max(end + 1, fr)] ?? 0);
if (bottomSpacer > 0) items.push({ kind: 'spacer', height: bottomSpacer, key: 'bottom-spacer' });
return items;
});
function cellStyle(c: number, r: number): Record<string, string> {
const s: Record<string, string> = formatStyle(c, r);
if (!isFixed.value) return s;
@@ -399,6 +511,7 @@ function autoScrollTick() {
else if (lastPointer.x > r.right - EDGE) dx = S;
if (dx || dy) {
box.scrollBy(dx, dy);
scrollTop.value = box.scrollTop; // sync eagerly -- see onScroll's doc comment
const hit = cellFromPoint(lastPointer.x, lastPointer.y);
if (hit) applyHit(hit);
}
@@ -1164,6 +1277,7 @@ onBeforeUnmount(() => {
onFillDragUp();
onResizeUp();
onRowResizeUp();
if (scrollRaf) cancelAnimationFrame(scrollRaf);
});
defineExpose({
@@ -1184,7 +1298,7 @@ defineExpose({
@keydown="onKeydown"
@contextmenu.prevent="onContextMenu"
>
<div class="tg-scroll" :style="scrollStyle">
<div class="tg-scroll" :style="scrollStyle" @scroll.passive="onScroll">
<table class="grid" :style="{ width: totalWidth + 'px' }">
<colgroup>
<col :style="{ width: HEAD_W + 'px' }" />
@@ -1208,48 +1322,54 @@ defineExpose({
</tr>
</thead>
<tbody>
<tr v-for="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }">
<th
class="row-head"
:data-rowhead="r - 1"
:class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }"
:style="rowHeadStyle(r - 1)"
@click="selectRow(r - 1, $event)"
>
{{ r }}
<span class="row-resize" @mousedown="onRowResizeDown($event, r - 1)" @click.stop />
</th>
<td
v-for="c in table.cols"
:key="(c - 1) + ',' + (r - 1)"
:data-c="c - 1"
:data-r="r - 1"
class="cell"
:class="{
'is-active': isActive(c - 1, r - 1),
'in-range': inSelection(c - 1, r - 1),
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': r - 1 === frozenRows - 1,
}"
:style="cellStyle(c - 1, r - 1)"
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, r - 1)"
>
<div
class="cell-content"
:class="{
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
multiline: isMultiline(c - 1, r - 1),
}"
:style="cellContentStyle(r - 1)"
<tr v-for="item in renderedRows" :key="item.key" :style="{ height: (item.kind === 'row' ? rowH(item.r) : item.height) + 'px' }">
<template v-if="item.kind === 'row'">
<th
class="row-head"
:data-rowhead="item.r"
:class="{ hot: rowHot(item.r), 'frozen-row': item.r < frozenRows, 'freeze-edge-bottom': item.r === frozenRows - 1 }"
:style="rowHeadStyle(item.r)"
@click="selectRow(item.r, $event)"
>
<template v-if="!(editing && editing.col === c - 1 && editing.row === r - 1)">
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
</template>
</div>
</td>
{{ item.r + 1 }}
<span class="row-resize" @mousedown="onRowResizeDown($event, item.r)" @click.stop />
</th>
<td
v-for="c in table.cols"
:key="(c - 1) + ',' + item.r"
:data-c="c - 1"
:data-r="item.r"
class="cell"
:class="{
'is-active': isActive(c - 1, item.r),
'in-range': inSelection(c - 1, item.r),
frozen: (c - 1 < frozenCols) || (item.r < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': item.r === frozenRows - 1,
}"
:style="cellStyle(c - 1, item.r)"
@mousedown="onCellMouseDown($event, c - 1, item.r)"
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, item.r)"
>
<div
class="cell-content"
:class="{
numeric: store.cellIsNumeric(tableId, c - 1, item.r),
error: store.cellIsError(tableId, c - 1, item.r),
multiline: isMultiline(c - 1, item.r),
}"
:style="cellContentStyle(item.r)"
>
<template v-if="!(editing && editing.col === c - 1 && editing.row === item.r)">
{{ store.cellDisplay(tableId, c - 1, item.r) }}
</template>
</div>
</td>
</template>
<!-- a spacer standing in for the rows currently scrolled out of
view -- keeps the scrollbar's size/position correct without
those rows' cells actually existing in the DOM -->
<td v-else :colspan="table.cols + 1" class="row-spacer" />
</tr>
</tbody>
</table>
@@ -1388,6 +1508,11 @@ defineExpose({
cursor: cell;
position: relative;
}
/* stands in for the rows currently scrolled out of view -- see renderedRows() */
.row-spacer {
padding: 0;
border: none;
}
/* the cell's text, in its own block box: a <td>'s own max-height doesn't cap
table-row growth, but a plain block child's max-height + overflow:hidden
does -- see cellContentStyle(). */