scrolling and size of canvas
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -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
@@ -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(). */
|
||||
|
||||
Reference in New Issue
Block a user