first commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# ngx-pretext-table
|
||||||
|
|
||||||
|
Variable-height virtual scrolling for PrimeNG `p-table`, powered by [@chenglou/pretext](https://github.com/chenglou/pretext).
|
||||||
|
|
||||||
|
PrimeNG's built-in virtual scroll only supports **fixed row heights** (`virtualScrollItemSize`). This library replaces it with pretext-calculated variable row heights — no DOM measurement, no layout thrashing, O(log n) scroll lookup.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ngx-pretext-table @chenglou/pretext
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import {
|
||||||
|
PretextVirtualScrollDirective,
|
||||||
|
PretextScrollEvent,
|
||||||
|
PretextColumnDef,
|
||||||
|
} from 'ngx-pretext-table';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [TableModule, PretextVirtualScrollDirective],
|
||||||
|
template: `
|
||||||
|
<div pretextVirtualScroll
|
||||||
|
[data]="data"
|
||||||
|
[columns]="columns"
|
||||||
|
[font]="'14px system-ui'"
|
||||||
|
[lineHeight]="20"
|
||||||
|
[rowPadding]="16"
|
||||||
|
[scrollHeight]="'500px'"
|
||||||
|
(visibleRangeChange)="onRange($event)">
|
||||||
|
|
||||||
|
<p-table [value]="visibleData" [scrollable]="false">
|
||||||
|
<ng-template pTemplate="body" let-row let-i="rowIndex">
|
||||||
|
<tr [style.height.px]="rowHeights[i]">
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
<td>{{ row.description }}</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class MyComponent {
|
||||||
|
data = [/* your data */];
|
||||||
|
|
||||||
|
// field + available text width (column width minus padding)
|
||||||
|
columns: PretextColumnDef[] = [
|
||||||
|
{ field: 'name', width: 184 },
|
||||||
|
{ field: 'description', width: 384 },
|
||||||
|
];
|
||||||
|
|
||||||
|
visibleData: any[] = [];
|
||||||
|
rowHeights: number[] = [];
|
||||||
|
|
||||||
|
onRange(e: PretextScrollEvent) {
|
||||||
|
this.visibleData = this.data.slice(e.start, e.end);
|
||||||
|
this.rowHeights = e.rowHeights;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `PretextVirtualScrollDirective`
|
||||||
|
|
||||||
|
| Input | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `data` | `any[]` | required | Full data array |
|
||||||
|
| `columns` | `PretextColumnDef[]` | required | Column field + text width |
|
||||||
|
| `font` | `string` | `'14px system-ui'` | CSS font shorthand |
|
||||||
|
| `lineHeight` | `number` | `20` | Line height in px |
|
||||||
|
| `rowPadding` | `number` | `16` | Vertical padding per row |
|
||||||
|
| `minRowHeight` | `number` | `32` | Minimum row height |
|
||||||
|
| `rowGap` | `number` | `0` | Gap between rows |
|
||||||
|
| `scrollHeight` | `string` | `'400px'` | Viewport height |
|
||||||
|
| `bufferRows` | `number` | `5` | Buffer rows above/below |
|
||||||
|
|
||||||
|
| Output | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `visibleRangeChange` | `PretextScrollEvent` | Emits when visible range changes |
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `scrollToIndex(index, behavior?)` | Scroll to a specific row |
|
||||||
|
|
||||||
|
### `PretextRowHeightService`
|
||||||
|
|
||||||
|
Low-level service for direct control:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cache = service.prepareRows(data, columns, font);
|
||||||
|
const heights = service.calculateRowHeights(cache, columns, lineHeight, padding);
|
||||||
|
const positions = service.buildPositionIndex(heights);
|
||||||
|
const range = service.findVisibleRange(positions, scrollTop, viewportHeight);
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. **Prepare** (once) — `@chenglou/pretext` segments text and measures via canvas
|
||||||
|
2. **Layout** (on resize) — pure arithmetic, ~0.0002ms per cell, no DOM access
|
||||||
|
3. **Scroll** (continuous) — O(log n) binary search in cumulative position arrays
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||||
|
"dest": "../../dist/ngx-pretext-table",
|
||||||
|
"lib": {
|
||||||
|
"entryFile": "src/public-api.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "ngx-pretext-table",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Variable-height virtual scrolling for PrimeNG p-table powered by @chenglou/pretext",
|
||||||
|
"keywords": [
|
||||||
|
"angular",
|
||||||
|
"primeng",
|
||||||
|
"virtual-scroll",
|
||||||
|
"pretext",
|
||||||
|
"table",
|
||||||
|
"variable-row-height"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"@chenglou/pretext": ">=0.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"sideEffects": false
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
Input,
|
||||||
|
ElementRef,
|
||||||
|
NgZone,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
OnChanges,
|
||||||
|
AfterViewInit,
|
||||||
|
SimpleChanges,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
Renderer2,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
PretextRowHeightService,
|
||||||
|
type PretextColumnDef,
|
||||||
|
type VisibleRange,
|
||||||
|
} from './pretext-row-height.service';
|
||||||
|
|
||||||
|
// ─── Events ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PretextScrollEvent {
|
||||||
|
/** First visible row index (inclusive) */
|
||||||
|
start: number;
|
||||||
|
/** Last visible row index (exclusive) */
|
||||||
|
end: number;
|
||||||
|
/** Total scrollable height in px */
|
||||||
|
totalHeight: number;
|
||||||
|
/** Y-offset of the content container */
|
||||||
|
offsetY: number;
|
||||||
|
/** Per-row heights for the visible slice */
|
||||||
|
rowHeights: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row Measurement Attribute ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS selector for rows inside the scroll container.
|
||||||
|
* Each matched element MUST have a `data-row-index` attribute with the
|
||||||
|
* global row index (not the slice-local index).
|
||||||
|
*/
|
||||||
|
const ROW_SELECTOR = '[data-vs-row]';
|
||||||
|
|
||||||
|
// ─── Directive ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[pretextVirtualScroll]',
|
||||||
|
standalone: true,
|
||||||
|
providers: [PretextRowHeightService],
|
||||||
|
})
|
||||||
|
export class PretextVirtualScrollDirective implements OnInit, AfterViewInit, OnDestroy, OnChanges {
|
||||||
|
// ── Inputs ────────────────────────────────────────────────────
|
||||||
|
@Input({ required: true }) data: Record<string, any>[] = [];
|
||||||
|
@Input({ required: true }) columns: PretextColumnDef[] = [];
|
||||||
|
@Input() font: string = '14px system-ui';
|
||||||
|
@Input() lineHeight: number = 20;
|
||||||
|
@Input() rowPadding: number = 16;
|
||||||
|
@Input() minRowHeight: number = 32;
|
||||||
|
@Input() rowGap: number = 0;
|
||||||
|
@Input() scrollHeight: string = '400px';
|
||||||
|
@Input() bufferRows: number = 5;
|
||||||
|
/** Enable DOM measurement correction on rendered rows. Default true. */
|
||||||
|
@Input() measureRows: boolean = true;
|
||||||
|
|
||||||
|
// ── Outputs ───────────────────────────────────────────────────
|
||||||
|
@Output() visibleRangeChange = new EventEmitter<PretextScrollEvent>();
|
||||||
|
|
||||||
|
// ── Internal state ────────────────────────────────────────────
|
||||||
|
private cellCaches: any[][] = [];
|
||||||
|
private currentRange: VisibleRange = { start: 0, end: 0 };
|
||||||
|
|
||||||
|
private scrollListener: (() => void) | null = null;
|
||||||
|
private containerResizeObserver: ResizeObserver | null = null;
|
||||||
|
private rowResizeObserver: ResizeObserver | null = null;
|
||||||
|
private rowMutationObserver: MutationObserver | null = null;
|
||||||
|
private spacerEl: HTMLElement | null = null;
|
||||||
|
private contentEl: HTMLElement | null = null;
|
||||||
|
private rafId: number = 0;
|
||||||
|
private lastScrollTop: number = -1;
|
||||||
|
private initialized: boolean = false;
|
||||||
|
private observedRows: Set<Element> = new Set();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private el: ElementRef<HTMLElement>,
|
||||||
|
private zone: NgZone,
|
||||||
|
private renderer: Renderer2,
|
||||||
|
private heightService: PretextRowHeightService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.setupDOM();
|
||||||
|
this.bindScrollListener();
|
||||||
|
this.bindContainerResizeObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
// setTimeout (macrotask) ensures we run after Angular's dev-mode
|
||||||
|
// ExpressionChanged check, avoiding NG0100 errors.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.recalculate();
|
||||||
|
this.initialized = true;
|
||||||
|
if (this.measureRows) {
|
||||||
|
this.setupRowMeasurement();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (!this.initialized) return;
|
||||||
|
|
||||||
|
const dataChanged = changes['data'] || changes['columns'] || changes['font'];
|
||||||
|
const layoutChanged = changes['lineHeight'] || changes['rowPadding']
|
||||||
|
|| changes['minRowHeight'] || changes['rowGap'];
|
||||||
|
|
||||||
|
if (dataChanged) {
|
||||||
|
this.heightService.clearMeasurements();
|
||||||
|
this.recalculate();
|
||||||
|
} else if (layoutChanged) {
|
||||||
|
this.heightService.clearMeasurements();
|
||||||
|
this.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['scrollHeight'] && !changes['scrollHeight'].firstChange) {
|
||||||
|
this.renderer.setStyle(this.el.nativeElement, 'height', this.scrollHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['measureRows']) {
|
||||||
|
if (this.measureRows) this.setupRowMeasurement();
|
||||||
|
else this.teardownRowMeasurement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollListener?.();
|
||||||
|
this.containerResizeObserver?.disconnect();
|
||||||
|
this.teardownRowMeasurement();
|
||||||
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
scrollToIndex(index: number, behavior: ScrollBehavior = 'auto'): void {
|
||||||
|
const positions = this.heightService.getPositions();
|
||||||
|
if (index < 0 || index >= positions.tops.length) return;
|
||||||
|
this.el.nativeElement.scrollTo({ top: positions.tops[index], behavior });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force re-measurement of all visible rows on next frame */
|
||||||
|
remeasure(): void {
|
||||||
|
this.heightService.clearMeasurements();
|
||||||
|
this.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM Setup ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private setupDOM(): void {
|
||||||
|
const host = this.el.nativeElement;
|
||||||
|
|
||||||
|
this.renderer.setStyle(host, 'overflow', 'auto');
|
||||||
|
this.renderer.setStyle(host, 'position', 'relative');
|
||||||
|
this.renderer.setStyle(host, 'height', this.scrollHeight);
|
||||||
|
|
||||||
|
this.spacerEl = this.renderer.createElement('div');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'position', 'absolute');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'top', '0');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'left', '0');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'width', '100%');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'height', '0px');
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'pointer-events', 'none');
|
||||||
|
this.renderer.appendChild(host, this.spacerEl);
|
||||||
|
|
||||||
|
this.contentEl = this.renderer.createElement('div');
|
||||||
|
this.renderer.setStyle(this.contentEl, 'position', 'relative');
|
||||||
|
this.renderer.setStyle(this.contentEl, 'will-change', 'transform');
|
||||||
|
|
||||||
|
const children = Array.from(host.childNodes).filter(n => n !== this.spacerEl);
|
||||||
|
for (const child of children) {
|
||||||
|
this.renderer.appendChild(this.contentEl, child);
|
||||||
|
}
|
||||||
|
this.renderer.appendChild(host, this.contentEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Height Calculation Pipeline ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full recalculation: prepare text + estimate heights + build positions.
|
||||||
|
* Called when data or columns change.
|
||||||
|
*/
|
||||||
|
private recalculate(): void {
|
||||||
|
if (!this.data?.length || !this.columns?.length) {
|
||||||
|
this.updateSpacer(0);
|
||||||
|
this.emitRange({ start: 0, end: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Prepare text via canvas (one-time)
|
||||||
|
this.cellCaches = this.heightService.prepareRows(this.data, this.columns, this.font);
|
||||||
|
|
||||||
|
// Phase 2 + 3
|
||||||
|
this.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-estimate heights + rebuild positions.
|
||||||
|
* Called on resize or layout param changes.
|
||||||
|
*/
|
||||||
|
private relayout(): void {
|
||||||
|
if (!this.cellCaches.length) return;
|
||||||
|
|
||||||
|
// Phase 2: Estimate heights (pretext + fixed + compute)
|
||||||
|
this.heightService.estimateRowHeights(
|
||||||
|
this.data, this.cellCaches, this.columns,
|
||||||
|
this.lineHeight, this.rowPadding, this.minRowHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build position index
|
||||||
|
const positions = this.heightService.buildPositionIndex(this.rowGap);
|
||||||
|
this.updateSpacer(positions.totalHeight);
|
||||||
|
this.updateVisibleRange(this.el.nativeElement.scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: DOM Row Measurement ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up MutationObserver to detect new rows, and ResizeObserver
|
||||||
|
* to measure their actual heights.
|
||||||
|
*/
|
||||||
|
private setupRowMeasurement(): void {
|
||||||
|
if (!this.contentEl || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
// ResizeObserver: fires when a row's height changes
|
||||||
|
this.rowResizeObserver = new ResizeObserver((entries) => {
|
||||||
|
const measurements: { index: number; height: number }[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const el = entry.target as HTMLElement;
|
||||||
|
const indexAttr = el.getAttribute('data-vs-row');
|
||||||
|
if (indexAttr == null) continue;
|
||||||
|
|
||||||
|
const index = parseInt(indexAttr, 10);
|
||||||
|
if (isNaN(index)) continue;
|
||||||
|
|
||||||
|
const height = entry.borderBoxSize?.[0]?.blockSize
|
||||||
|
?? entry.contentRect.height;
|
||||||
|
|
||||||
|
if (height > 0) {
|
||||||
|
measurements.push({ index, height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measurements.length > 0) {
|
||||||
|
const changed = this.heightService.reportMeasuredHeights(measurements);
|
||||||
|
if (changed.length > 0) {
|
||||||
|
// Rebuild from the earliest changed row
|
||||||
|
const earliest = Math.min(...changed);
|
||||||
|
this.heightService.rebuildPositionsFrom(earliest);
|
||||||
|
const positions = this.heightService.getPositions();
|
||||||
|
this.updateSpacer(positions.totalHeight);
|
||||||
|
|
||||||
|
// Re-emit range with corrected heights
|
||||||
|
this.zone.run(() => this.emitRange(this.currentRange));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// MutationObserver: detect when rows are added/removed from DOM
|
||||||
|
this.rowMutationObserver = new MutationObserver(() => {
|
||||||
|
this.syncRowObservation();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rowMutationObserver.observe(this.contentEl!, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial scan
|
||||||
|
this.syncRowObservation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync which row elements are being observed by ResizeObserver.
|
||||||
|
*/
|
||||||
|
private syncRowObservation(): void {
|
||||||
|
if (!this.contentEl || !this.rowResizeObserver) return;
|
||||||
|
|
||||||
|
const currentRowEls = Array.from(this.contentEl.querySelectorAll(ROW_SELECTOR));
|
||||||
|
const currentRows = new Set<Element>(currentRowEls);
|
||||||
|
|
||||||
|
// Unobserve removed rows
|
||||||
|
for (const el of this.observedRows) {
|
||||||
|
if (!currentRows.has(el)) {
|
||||||
|
this.rowResizeObserver.unobserve(el);
|
||||||
|
this.observedRows.delete(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new rows
|
||||||
|
for (const el of currentRows) {
|
||||||
|
if (!this.observedRows.has(el)) {
|
||||||
|
this.rowResizeObserver.observe(el);
|
||||||
|
this.observedRows.add(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownRowMeasurement(): void {
|
||||||
|
this.rowResizeObserver?.disconnect();
|
||||||
|
this.rowMutationObserver?.disconnect();
|
||||||
|
this.rowResizeObserver = null;
|
||||||
|
this.rowMutationObserver = null;
|
||||||
|
this.observedRows.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll Handling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private bindScrollListener(): void {
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const scrollTop = (event.target as HTMLElement).scrollTop;
|
||||||
|
if (scrollTop === this.lastScrollTop) return;
|
||||||
|
this.lastScrollTop = scrollTop;
|
||||||
|
|
||||||
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = requestAnimationFrame(() => this.updateVisibleRange(scrollTop));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.nativeElement.addEventListener('scroll', handler, { passive: true });
|
||||||
|
this.scrollListener = () => this.el.nativeElement.removeEventListener('scroll', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindContainerResizeObserver(): void {
|
||||||
|
if (typeof ResizeObserver === 'undefined') return;
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.containerResizeObserver = new ResizeObserver(() => {
|
||||||
|
if (this.initialized) this.relayout();
|
||||||
|
});
|
||||||
|
this.containerResizeObserver.observe(this.el.nativeElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private updateSpacer(totalHeight: number): void {
|
||||||
|
if (this.spacerEl) {
|
||||||
|
this.renderer.setStyle(this.spacerEl, 'height', `${totalHeight}px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVisibleRange(scrollTop: number): void {
|
||||||
|
const viewportHeight = this.el.nativeElement.clientHeight;
|
||||||
|
const range = this.heightService.findVisibleRange(
|
||||||
|
scrollTop, viewportHeight, this.bufferRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (range.start !== this.currentRange.start || range.end !== this.currentRange.end) {
|
||||||
|
this.currentRange = range;
|
||||||
|
this.zone.run(() => this.emitRange(range));
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = this.heightService.getPositions();
|
||||||
|
if (this.contentEl && range.start < positions.tops.length) {
|
||||||
|
const offsetY = positions.tops[range.start] ?? 0;
|
||||||
|
this.renderer.setStyle(this.contentEl, 'transform', `translate3d(0, ${offsetY}px, 0)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRange(range: VisibleRange): void {
|
||||||
|
const positions = this.heightService.getPositions();
|
||||||
|
this.visibleRangeChange.emit({
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
totalHeight: positions.totalHeight,
|
||||||
|
offsetY: positions.tops[range.start] ?? 0,
|
||||||
|
rowHeights: this.heightService.getRowHeights(range.start, range.end),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Public API Surface of ngx-pretext-table
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
PretextRowHeightService,
|
||||||
|
type PretextColumnDef,
|
||||||
|
type ColumnHeightMode,
|
||||||
|
type PositionIndex,
|
||||||
|
type VisibleRange,
|
||||||
|
type CellCache,
|
||||||
|
type RowHeightEntry,
|
||||||
|
type HeightSource,
|
||||||
|
} from './lib/pretext-row-height.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
PretextVirtualScrollDirective,
|
||||||
|
type PretextScrollEvent,
|
||||||
|
} from './lib/pretext-virtual-scroll.directive';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../out-tsc/lib",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.lib.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"compilationMode": "partial"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user