feat: add Angular workspace with demo app and library build

- Restructure into standard Angular library workspace
- Library source in projects/ngx-pretext-table/
- Demo app in src/ using PretextVirtualScrollDirective
- Switch @chenglou/pretext dependency to npm registry (^0.0.4)
- Fix moduleResolution to "bundler" for Angular 17 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 18:29:39 +02:00
parent e8bfdd3154
commit 0994550933
18 changed files with 12179 additions and 19 deletions
+21 -1
View File
@@ -1 +1,21 @@
node_modules
# Compiled output
/dist
/out-tsc
# Node
/node_modules
# Angular cache
/.angular/cache
# IDEs
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# System
.DS_Store
Thumbs.db
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"demo": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/demo",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "1mb", "maximumError": "2mb" },
{ "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "demo:build:production" },
"development": { "buildTarget": "demo:build:development" }
},
"defaultConfiguration": "development"
}
}
},
"ngx-pretext-table": {
"projectType": "library",
"root": "projects/ngx-pretext-table",
"sourceRoot": "projects/ngx-pretext-table/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/ngx-pretext-table/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/ngx-pretext-table/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/ngx-pretext-table/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
}
}
}
}
}
+10893
View File
File diff suppressed because it is too large Load Diff
+29 -18
View File
@@ -1,23 +1,34 @@
{
"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"
"name": "ngx-pretext-table-workspace",
"version": "0.0.0",
"private": true,
"scripts": {
"ng": "ng",
"start": "ng serve demo",
"build": "ng build ngx-pretext-table && ng build demo",
"build:lib": "ng build ngx-pretext-table",
"build:demo": "ng build demo",
"watch": "ng build demo --watch --configuration development"
},
"dependencies": {
"tslib": "^2.3.0"
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@chenglou/pretext": "^0.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"sideEffects": false
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"ng-packagr": "^17.3.0",
"typescript": "~5.4.2"
}
}
@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ngx-pretext-table",
"lib": {
"entryFile": "src/public-api.ts"
}
}
+23
View File
@@ -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,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": ["**/*.spec.ts"]
}
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { DemoComponent } from './demo/demo.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [DemoComponent],
template: `<app-demo />`,
})
export class AppComponent {}
+290
View File
@@ -0,0 +1,290 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
PretextVirtualScrollDirective,
type PretextScrollEvent,
type PretextColumnDef,
} from 'ngx-pretext-table';
interface DemoRow {
id: number;
name: string;
description: string;
tags: string[];
status: string;
}
@Component({
selector: 'app-demo',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, PretextVirtualScrollDirective],
template: `
<div class="demo-header">
<h2>Pretext + Virtual Scroll: Mixed Content Demo</h2>
<p>{{ totalRows | number }} rows — text measured by <strong>pretext</strong>,
inputs/tags use height registry, DOM corrections via ResizeObserver.</p>
<div class="stats">
<span class="stat">Visible: {{ rangeStart }}{{ rangeEnd }}</span>
<span class="stat">Rendered: {{ visibleData.length }} rows</span>
<span class="stat">Total: {{ totalRows | number }}</span>
</div>
</div>
<div pretextVirtualScroll
[data]="allData"
[columns]="pretextColumns"
[font]="font"
[lineHeight]="lineHeight"
[rowPadding]="rowPadding"
[minRowHeight]="44"
[scrollHeight]="'560px'"
[bufferRows]="5"
[measureRows]="true"
(visibleRangeChange)="onVisibleRangeChange($event)"
class="scroll-container">
<table class="pretext-table">
<thead>
<tr>
<th class="col-id">ID</th>
<th class="col-name">Name</th>
<th class="col-desc">Description</th>
<th class="col-tags">Tags</th>
<th class="col-status">Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleData; let i = index"
[attr.data-vs-row]="rangeStart + i"
[style.height.px]="visibleRowHeights[i]">
<td class="col-id">{{ row.id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-desc wrap-cell">{{ row.description }}</td>
<td class="col-tags">
<span class="tag" *ngFor="let tag of row.tags">{{ tag }}</span>
</td>
<td class="col-status">
<select [ngModel]="row.status" class="status-select">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="legend">
<div class="legend-item">
<span class="legend-dot text-dot"></span> Description: pretext text layout
</div>
<div class="legend-item">
<span class="legend-dot fixed-dot"></span> Status: fixed height (36px)
</div>
<div class="legend-item">
<span class="legend-dot compute-dot"></span> Tags: computed from tag count
</div>
<div class="legend-item">
<span class="legend-dot measure-dot"></span> All rows: ResizeObserver correction
</div>
</div>
`,
styles: [`
:host {
display: block;
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.demo-header { margin-bottom: 16px; }
.demo-header h2 { margin: 0 0 8px; font-size: 20px; }
.demo-header p { margin: 0 0 12px; color: #555; font-size: 14px; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; }
.stat {
padding: 4px 10px;
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.scroll-container {
border: 1px solid #dee2e6;
border-radius: 6px;
background: #fff;
}
.pretext-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
}
.pretext-table thead {
position: sticky;
top: 0;
z-index: 1;
background: #f8f9fa;
}
.pretext-table th {
padding: 10px 8px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #333;
border-bottom: 2px solid #dee2e6;
}
.pretext-table td {
padding: 8px;
vertical-align: top;
border-bottom: 1px solid #eee;
color: #444;
}
.pretext-table tr:hover td { background: #f8f9fa; }
.col-id { width: 50px; }
.col-name { width: 130px; }
.col-desc { width: 350px; }
.col-tags { width: 250px; }
.col-status { width: 120px; }
.wrap-cell {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.tag {
display: inline-block;
padding: 2px 8px;
margin: 2px 4px 2px 0;
background: #e8f0fe;
color: #1a73e8;
border-radius: 12px;
font-size: 12px;
white-space: nowrap;
}
.status-select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
background: #fff;
}
.legend {
margin-top: 12px;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 12px;
color: #666;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
}
.text-dot { background: #4caf50; }
.fixed-dot { background: #ff9800; }
.compute-dot { background: #2196f3; }
.measure-dot { background: #9c27b0; }
`],
})
export class DemoComponent implements OnInit {
readonly totalRows = 10_000;
readonly font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
readonly lineHeight = 20;
readonly rowPadding = 16;
allData: DemoRow[] = [];
visibleData: DemoRow[] = [];
visibleRowHeights: number[] = [];
rangeStart = 0;
rangeEnd = 0;
pretextColumns: PretextColumnDef[] = [
{ field: 'id', width: 34 },
{ field: 'name', width: 114 },
{ field: 'description', width: 334 },
{
field: 'tags',
width: 234,
heightMode: {
kind: 'compute',
fn: (tags: string[]) => {
if (!tags || tags.length === 0) return 20;
const tagsPerRow = Math.max(1, Math.floor(234 / 74));
const rows = Math.ceil(tags.length / tagsPerRow);
return rows * 24 + (rows - 1) * 4;
},
},
},
{
field: 'status',
width: 104,
heightMode: { kind: 'fixed', height: 36 },
},
];
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
this.allData = this.generateData(this.totalRows);
}
onVisibleRangeChange(event: PretextScrollEvent): void {
this.visibleData = this.allData.slice(event.start, event.end);
this.visibleRowHeights = event.rowHeights;
this.rangeStart = event.start;
this.rangeEnd = event.end;
this.cdr.markForCheck();
}
private generateData(count: number): DemoRow[] {
const lorem = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.',
'Excepteur sint occaecat cupidatat non proident, sunt in culpa.',
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit.',
'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet.',
'At vero eos et accusamus et iusto odio dignissimos ducimus.',
];
const names = [
'Alice Johnson', 'Bob Smith', 'Charlie Brown', 'Diana Prince',
'Eve Wilson', 'Frank Castle', 'Grace Hopper', 'Henry Ford',
'Iris Chang', 'Jack Ryan', 'Kate Moss', 'Leo Messi',
];
const tagPool = [
'Angular', 'React', 'Vue', 'Svelte', 'TypeScript',
'JavaScript', 'CSS', 'HTML', 'Node.js', 'Python',
'Docker', 'K8s', 'AWS', 'Azure', 'GCP',
];
const statuses = ['active', 'inactive', 'pending'];
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: names[i % names.length],
description: Array.from({ length: 1 + (i % 5) }, (_, j) =>
lorem[(i + j) % lorem.length]).join(' '),
tags: Array.from({ length: i % 7 }, (_, j) =>
tagPool[(i + j * 3) % tagPool.length]),
status: statuses[i % statuses.length],
}));
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ngx-pretext-table Demo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch(err => console.error(err));
+10
View File
@@ -0,0 +1,10 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
background: #fafafa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"paths": {
"ngx-pretext-table": ["./dist/ngx-pretext-table"]
},
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}