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:
+21
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10893
File diff suppressed because it is too large
Load Diff
+29
-18
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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],
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent).catch(err => console.error(err));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user