339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { Injectable, OnDestroy } from '@angular/core';
|
|
import { prepare, layout, clearCache } from '@chenglou/pretext';
|
|
import type { PreparedText, LayoutResult } from '@chenglou/pretext';
|
|
|
|
// ─── Column Definitions ──────────────────────────────────────────────
|
|
|
|
/** Height calculation strategy per column */
|
|
export type ColumnHeightMode =
|
|
| { kind: 'text' }
|
|
| { kind: 'fixed'; height: number }
|
|
| { kind: 'compute'; fn: (value: any, row: any) => number }
|
|
| { kind: 'skip' };
|
|
|
|
export interface PretextColumnDef {
|
|
/** Data field name on the row object */
|
|
field: string;
|
|
/** Available text width in px (column width minus horizontal padding) */
|
|
width: number;
|
|
/** Optional per-column CSS font shorthand override */
|
|
font?: string;
|
|
/**
|
|
* Height mode for this column. Default: 'text' (pretext measures).
|
|
* - { kind: 'text' } → pretext layout (default)
|
|
* - { kind: 'fixed', height: 36 } → fixed px (inputs, buttons)
|
|
* - { kind: 'compute', fn: ... } → custom function (chips, tags)
|
|
* - { kind: 'skip' } → ignore for height calculation
|
|
*/
|
|
heightMode?: ColumnHeightMode;
|
|
/** Extra height added after text/component measurement (e.g. button below text) */
|
|
extraHeight?: number;
|
|
}
|
|
|
|
// ─── Position Index ──────────────────────────────────────────────────
|
|
|
|
export interface PositionIndex {
|
|
tops: number[];
|
|
bottoms: number[];
|
|
totalHeight: number;
|
|
}
|
|
|
|
export interface VisibleRange {
|
|
start: number;
|
|
/** Exclusive */
|
|
end: number;
|
|
}
|
|
|
|
// ─── Cell Cache ──────────────────────────────────────────────────────
|
|
|
|
export interface CellCache {
|
|
prepared: PreparedText | null; // null for non-text columns
|
|
text: string;
|
|
font: string;
|
|
}
|
|
|
|
// ─── Height Source Tracking ──────────────────────────────────────────
|
|
|
|
export type HeightSource = 'estimated' | 'measured';
|
|
|
|
export interface RowHeightEntry {
|
|
height: number;
|
|
source: HeightSource;
|
|
}
|
|
|
|
// ─── Service ─────────────────────────────────────────────────────────
|
|
|
|
@Injectable()
|
|
export class PretextRowHeightService implements OnDestroy {
|
|
private cellCaches: CellCache[][] = [];
|
|
private heightEntries: RowHeightEntry[] = [];
|
|
private positions: PositionIndex = { tops: [], bottoms: [], totalHeight: 0 };
|
|
private gap: number = 0;
|
|
|
|
ngOnDestroy(): void {
|
|
this.cellCaches = [];
|
|
this.heightEntries = [];
|
|
clearCache();
|
|
}
|
|
|
|
// ── Phase 1: Prepare ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Prepare all cell text for measurement via canvas.
|
|
* Non-text columns (fixed, compute, skip) get a null prepared entry.
|
|
*/
|
|
prepareRows(
|
|
data: Record<string, any>[],
|
|
columns: PretextColumnDef[],
|
|
defaultFont: string,
|
|
): CellCache[][] {
|
|
const caches: CellCache[][] = [];
|
|
|
|
for (const row of data) {
|
|
const rowCache: CellCache[] = [];
|
|
for (const col of columns) {
|
|
const mode = col.heightMode ?? { kind: 'text' };
|
|
const text = String(row[col.field] ?? '');
|
|
const font = col.font ?? defaultFont;
|
|
|
|
if (mode.kind === 'text') {
|
|
rowCache.push({ prepared: prepare(text, font), text, font });
|
|
} else {
|
|
rowCache.push({ prepared: null, text, font });
|
|
}
|
|
}
|
|
caches.push(rowCache);
|
|
}
|
|
|
|
this.cellCaches = caches;
|
|
return caches;
|
|
}
|
|
|
|
// ── Phase 2: Estimate Heights ───────────────────────────────────
|
|
|
|
/**
|
|
* Calculate estimated pixel height of each row.
|
|
* Uses pretext for text columns, fixed/compute for others.
|
|
* Preserves previously measured (DOM-corrected) heights.
|
|
*/
|
|
estimateRowHeights(
|
|
data: Record<string, any>[],
|
|
cache: CellCache[][],
|
|
columns: PretextColumnDef[],
|
|
lineHeight: number,
|
|
verticalPadding: number,
|
|
minRowHeight: number = 0,
|
|
): RowHeightEntry[] {
|
|
const entries: RowHeightEntry[] = new Array(cache.length);
|
|
|
|
for (let r = 0; r < cache.length; r++) {
|
|
// If we already have a DOM measurement, keep it
|
|
if (this.heightEntries[r]?.source === 'measured') {
|
|
entries[r] = this.heightEntries[r];
|
|
continue;
|
|
}
|
|
|
|
let maxCellHeight = 0;
|
|
const rowCache = cache[r];
|
|
const rowData = data[r];
|
|
|
|
for (let c = 0; c < columns.length; c++) {
|
|
const col = columns[c];
|
|
const mode = col.heightMode ?? { kind: 'text' };
|
|
let cellHeight = 0;
|
|
|
|
switch (mode.kind) {
|
|
case 'text': {
|
|
const cell = rowCache[c];
|
|
if (cell.prepared) {
|
|
const result: LayoutResult = layout(cell.prepared, col.width, lineHeight);
|
|
cellHeight = result.height;
|
|
}
|
|
break;
|
|
}
|
|
case 'fixed':
|
|
cellHeight = mode.height;
|
|
break;
|
|
case 'compute':
|
|
cellHeight = mode.fn(rowData[col.field], rowData);
|
|
break;
|
|
case 'skip':
|
|
continue;
|
|
}
|
|
|
|
cellHeight += col.extraHeight ?? 0;
|
|
|
|
if (cellHeight > maxCellHeight) {
|
|
maxCellHeight = cellHeight;
|
|
}
|
|
}
|
|
|
|
entries[r] = {
|
|
height: Math.max(minRowHeight, maxCellHeight + verticalPadding),
|
|
source: 'estimated',
|
|
};
|
|
}
|
|
|
|
this.heightEntries = entries;
|
|
return entries;
|
|
}
|
|
|
|
// ── Phase 3: DOM Correction ─────────────────────────────────────
|
|
|
|
/**
|
|
* Report a DOM-measured height for a specific row.
|
|
* Returns true if the height changed significantly (>1px).
|
|
*/
|
|
reportMeasuredHeight(index: number, measuredHeight: number): boolean {
|
|
if (index < 0 || index >= this.heightEntries.length) return false;
|
|
|
|
const current = this.heightEntries[index];
|
|
const diff = Math.abs(current.height - measuredHeight);
|
|
|
|
if (diff > 1) {
|
|
this.heightEntries[index] = { height: measuredHeight, source: 'measured' };
|
|
return true;
|
|
}
|
|
|
|
// Mark as measured even if close — prevents re-estimation on relayout
|
|
if (current.source !== 'measured') {
|
|
this.heightEntries[index] = { height: current.height, source: 'measured' };
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Batch report measured heights. Returns indices that changed significantly.
|
|
*/
|
|
reportMeasuredHeights(measurements: { index: number; height: number }[]): number[] {
|
|
const changed: number[] = [];
|
|
for (const m of measurements) {
|
|
if (this.reportMeasuredHeight(m.index, m.height)) {
|
|
changed.push(m.index);
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Clear all DOM measurements, reverting to estimated heights.
|
|
* Useful after data changes or sorting.
|
|
*/
|
|
clearMeasurements(): void {
|
|
for (let i = 0; i < this.heightEntries.length; i++) {
|
|
if (this.heightEntries[i]?.source === 'measured') {
|
|
this.heightEntries[i].source = 'estimated';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Position Index ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build cumulative position arrays from current height entries.
|
|
*/
|
|
buildPositionIndex(gap: number = 0): PositionIndex {
|
|
this.gap = gap;
|
|
const entries = this.heightEntries;
|
|
const n = entries.length;
|
|
const tops = new Array<number>(n);
|
|
const bottoms = new Array<number>(n);
|
|
let y = 0;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
tops[i] = y;
|
|
y += entries[i].height;
|
|
bottoms[i] = y;
|
|
y += gap;
|
|
}
|
|
|
|
this.positions = { tops, bottoms, totalHeight: y - (n > 0 ? gap : 0) };
|
|
return this.positions;
|
|
}
|
|
|
|
/**
|
|
* Rebuild positions starting from a specific index.
|
|
* More efficient than full rebuild after a single height correction.
|
|
*/
|
|
rebuildPositionsFrom(fromIndex: number): PositionIndex {
|
|
const entries = this.heightEntries;
|
|
const n = entries.length;
|
|
const { tops, bottoms } = this.positions;
|
|
|
|
if (fromIndex <= 0) return this.buildPositionIndex(this.gap);
|
|
|
|
let y = bottoms[fromIndex - 1] + this.gap;
|
|
|
|
for (let i = fromIndex; i < n; i++) {
|
|
tops[i] = y;
|
|
y += entries[i].height;
|
|
bottoms[i] = y;
|
|
y += this.gap;
|
|
}
|
|
|
|
this.positions.totalHeight = y - (n > 0 ? this.gap : 0);
|
|
return this.positions;
|
|
}
|
|
|
|
// ── Query ───────────────────────────────────────────────────────
|
|
|
|
getPositions(): PositionIndex {
|
|
return this.positions;
|
|
}
|
|
|
|
getRowHeight(index: number): number {
|
|
return this.heightEntries[index]?.height ?? 0;
|
|
}
|
|
|
|
getRowHeights(start: number, end: number): number[] {
|
|
return this.heightEntries.slice(start, end).map(e => e.height);
|
|
}
|
|
|
|
getHeightSource(index: number): HeightSource {
|
|
return this.heightEntries[index]?.source ?? 'estimated';
|
|
}
|
|
|
|
/** Count of rows that have been DOM-measured */
|
|
getMeasuredCount(): number {
|
|
return this.heightEntries.filter(e => e.source === 'measured').length;
|
|
}
|
|
|
|
/**
|
|
* Binary search for visible row range. O(log n).
|
|
*/
|
|
findVisibleRange(
|
|
scrollTop: number,
|
|
viewportHeight: number,
|
|
buffer: number = 5,
|
|
): VisibleRange {
|
|
const { tops, bottoms } = this.positions;
|
|
const n = tops.length;
|
|
|
|
if (n === 0) return { start: 0, end: 0 };
|
|
|
|
const minY = scrollTop;
|
|
const maxY = scrollTop + viewportHeight;
|
|
|
|
let lo = 0, hi = n;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (bottoms[mid] <= minY) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
const firstVisible = lo;
|
|
|
|
lo = firstVisible;
|
|
hi = n;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (tops[mid] < maxY) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
|
|
return {
|
|
start: Math.max(0, firstVisible - buffer),
|
|
end: Math.min(n, lo + buffer),
|
|
};
|
|
}
|
|
}
|