first commit

This commit is contained in:
2026-04-08 23:12:46 +02:00
commit e8bfdd3154
10 changed files with 919 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
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),
};
}
}