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
|
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
|
Viewport**, unabhängig von der Zeilen-/Spaltenzahl (die bleibt exakt wie
|
||||||
sie ist) — dieselben Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s.
|
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
|
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**
|
||||||
|
|||||||
@@ -31,13 +31,25 @@ const appearance = computed(() => {
|
|||||||
});
|
});
|
||||||
const showGrid = computed(() => sheet.value.showGrid !== false);
|
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(() => {
|
const plane = computed(() => {
|
||||||
let w = 1600;
|
let w = 1600;
|
||||||
let h = 1000;
|
let h = 1000;
|
||||||
for (const o of sheet.value.objects) {
|
for (const o of sheet.value.objects) {
|
||||||
const ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
|
let ow: number;
|
||||||
const oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
|
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);
|
w = Math.max(w, o.x + ow + 500);
|
||||||
h = Math.max(h, o.y + oh + 400);
|
h = Math.max(h, o.y + oh + 400);
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-29
@@ -34,11 +34,52 @@ const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
|
|||||||
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
|
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
|
||||||
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
|
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). */
|
/** px from the table's top edge to the start of row `r` (incl. header). */
|
||||||
function rowTop(r: number): number {
|
function rowTop(r: number): number {
|
||||||
let y = headerH.value;
|
return headerH.value + (rowCumHeights.value[r] ?? 0);
|
||||||
for (let i = 0; i < r; i++) y += rowH(i);
|
|
||||||
return y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootStyle = computed(() => {
|
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). */
|
/** px from the table's left edge to the start of column `c` (incl. row header). */
|
||||||
function colLeft(c: number): number {
|
function colLeft(c: number): number {
|
||||||
let x = HEAD_W;
|
return HEAD_W + (colCumWidths.value[c] ?? 0);
|
||||||
for (let i = 0; i < c; i++) x += colWidth(i);
|
|
||||||
return x;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The internal scroll element of a fixed table (null for auto-size tables). */
|
/** The internal scroll element of a fixed table (null for auto-size tables). */
|
||||||
const scrollEl = () =>
|
const scrollEl = () =>
|
||||||
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
|
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> {
|
function cellStyle(c: number, r: number): Record<string, string> {
|
||||||
const s: Record<string, string> = formatStyle(c, r);
|
const s: Record<string, string> = formatStyle(c, r);
|
||||||
if (!isFixed.value) return s;
|
if (!isFixed.value) return s;
|
||||||
@@ -399,6 +511,7 @@ function autoScrollTick() {
|
|||||||
else if (lastPointer.x > r.right - EDGE) dx = S;
|
else if (lastPointer.x > r.right - EDGE) dx = S;
|
||||||
if (dx || dy) {
|
if (dx || dy) {
|
||||||
box.scrollBy(dx, dy);
|
box.scrollBy(dx, dy);
|
||||||
|
scrollTop.value = box.scrollTop; // sync eagerly -- see onScroll's doc comment
|
||||||
const hit = cellFromPoint(lastPointer.x, lastPointer.y);
|
const hit = cellFromPoint(lastPointer.x, lastPointer.y);
|
||||||
if (hit) applyHit(hit);
|
if (hit) applyHit(hit);
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1277,7 @@ onBeforeUnmount(() => {
|
|||||||
onFillDragUp();
|
onFillDragUp();
|
||||||
onResizeUp();
|
onResizeUp();
|
||||||
onRowResizeUp();
|
onRowResizeUp();
|
||||||
|
if (scrollRaf) cancelAnimationFrame(scrollRaf);
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -1184,7 +1298,7 @@ defineExpose({
|
|||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
@contextmenu.prevent="onContextMenu"
|
@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' }">
|
<table class="grid" :style="{ width: totalWidth + 'px' }">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col :style="{ width: HEAD_W + 'px' }" />
|
<col :style="{ width: HEAD_W + 'px' }" />
|
||||||
@@ -1208,48 +1322,54 @@ defineExpose({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }">
|
<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
|
<th
|
||||||
class="row-head"
|
class="row-head"
|
||||||
:data-rowhead="r - 1"
|
:data-rowhead="item.r"
|
||||||
:class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }"
|
:class="{ hot: rowHot(item.r), 'frozen-row': item.r < frozenRows, 'freeze-edge-bottom': item.r === frozenRows - 1 }"
|
||||||
:style="rowHeadStyle(r - 1)"
|
:style="rowHeadStyle(item.r)"
|
||||||
@click="selectRow(r - 1, $event)"
|
@click="selectRow(item.r, $event)"
|
||||||
>
|
>
|
||||||
{{ r }}
|
{{ item.r + 1 }}
|
||||||
<span class="row-resize" @mousedown="onRowResizeDown($event, r - 1)" @click.stop />
|
<span class="row-resize" @mousedown="onRowResizeDown($event, item.r)" @click.stop />
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
v-for="c in table.cols"
|
v-for="c in table.cols"
|
||||||
:key="(c - 1) + ',' + (r - 1)"
|
:key="(c - 1) + ',' + item.r"
|
||||||
:data-c="c - 1"
|
:data-c="c - 1"
|
||||||
:data-r="r - 1"
|
:data-r="item.r"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="{
|
:class="{
|
||||||
'is-active': isActive(c - 1, r - 1),
|
'is-active': isActive(c - 1, item.r),
|
||||||
'in-range': inSelection(c - 1, r - 1),
|
'in-range': inSelection(c - 1, item.r),
|
||||||
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
|
frozen: (c - 1 < frozenCols) || (item.r < frozenRows),
|
||||||
'freeze-edge-right': c - 1 === frozenCols - 1,
|
'freeze-edge-right': c - 1 === frozenCols - 1,
|
||||||
'freeze-edge-bottom': r - 1 === frozenRows - 1,
|
'freeze-edge-bottom': item.r === frozenRows - 1,
|
||||||
}"
|
}"
|
||||||
:style="cellStyle(c - 1, r - 1)"
|
:style="cellStyle(c - 1, item.r)"
|
||||||
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
|
@mousedown="onCellMouseDown($event, c - 1, item.r)"
|
||||||
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, r - 1)"
|
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, item.r)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="cell-content"
|
class="cell-content"
|
||||||
:class="{
|
:class="{
|
||||||
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
|
numeric: store.cellIsNumeric(tableId, c - 1, item.r),
|
||||||
error: store.cellIsError(tableId, c - 1, r - 1),
|
error: store.cellIsError(tableId, c - 1, item.r),
|
||||||
multiline: isMultiline(c - 1, r - 1),
|
multiline: isMultiline(c - 1, item.r),
|
||||||
}"
|
}"
|
||||||
:style="cellContentStyle(r - 1)"
|
:style="cellContentStyle(item.r)"
|
||||||
>
|
>
|
||||||
<template v-if="!(editing && editing.col === c - 1 && editing.row === r - 1)">
|
<template v-if="!(editing && editing.col === c - 1 && editing.row === item.r)">
|
||||||
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
|
{{ store.cellDisplay(tableId, c - 1, item.r) }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1388,6 +1508,11 @@ defineExpose({
|
|||||||
cursor: cell;
|
cursor: cell;
|
||||||
position: relative;
|
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
|
/* 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
|
table-row growth, but a plain block child's max-height + overflow:hidden
|
||||||
does -- see cellContentStyle(). */
|
does -- see cellContentStyle(). */
|
||||||
|
|||||||
Reference in New Issue
Block a user