first commit
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user