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" > -
+
@@ -1208,48 +1322,54 @@ defineExpose({ - - -
- {{ r }} - - -
+ + +
@@ -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 's own max-height doesn't cap table-row growth, but a plain block child's max-height + overflow:hidden does -- see cellContentStyle(). */