diff --git a/README.md b/README.md
index 55d8b6d..2e94dcf 100644
--- a/README.md
+++ b/README.md
@@ -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-`
` 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**
diff --git a/src/components/SheetCanvas.vue b/src/components/SheetCanvas.vue
index 5dec28f..7dbddc9 100644
--- a/src/components/SheetCanvas.vue
+++ b/src/components/SheetCanvas.vue
@@ -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);
}
diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue
index 4562e00..b18e4d3 100644
--- a/src/components/TableGrid.vue
+++ b/src/components/TableGrid.vue
@@ -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(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(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 ``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 `` 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(() => {
+ 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 {
const s: Record = 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"
>
-