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[], 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[], 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(n); const bottoms = new Array(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), }; } }