From e8bfdd31543624634fdde4d6a56c8a773cb383b9 Mon Sep 17 00:00:00 2001 From: alpacamannn Date: Wed, 8 Apr 2026 23:12:46 +0200 Subject: [PATCH] first commit --- .gitignore | 1 + README.md | 110 ++++++ ng-package.json | 7 + package.json | 23 ++ src/lib/pretext-row-height.service.ts | 338 +++++++++++++++++ src/lib/pretext-virtual-scroll.directive.ts | 383 ++++++++++++++++++++ src/public-api.ts | 19 + tsconfig.lib.json | 14 + tsconfig.lib.prod.json | 10 + tsconfig.spec.json | 14 + 10 files changed, 919 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ng-package.json create mode 100644 package.json create mode 100644 src/lib/pretext-row-height.service.ts create mode 100644 src/lib/pretext-virtual-scroll.directive.ts create mode 100644 src/public-api.ts create mode 100644 tsconfig.lib.json create mode 100644 tsconfig.lib.prod.json create mode 100644 tsconfig.spec.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf61182 --- /dev/null +++ b/README.md @@ -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: ` +
+ + + + + {{ row.name }} + {{ row.description }} + + + +
+ `, +}) +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 diff --git a/ng-package.json b/ng-package.json new file mode 100644 index 0000000..2f1e218 --- /dev/null +++ b/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ngx-pretext-table", + "lib": { + "entryFile": "src/public-api.ts" + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c859043 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/src/lib/pretext-row-height.service.ts b/src/lib/pretext-row-height.service.ts new file mode 100644 index 0000000..4e1cbf6 --- /dev/null +++ b/src/lib/pretext-row-height.service.ts @@ -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[], + columns: PretextColumnDef[], + defaultFont: string, + ): CellCache[][] { + const caches: CellCache[][] = []; + + for (const row of data) { + const rowCache: CellCache[] = []; + for (const col of columns) { + const mode = col.heightMode ?? { kind: 'text' }; + const text = String(row[col.field] ?? ''); + const font = col.font ?? defaultFont; + + if (mode.kind === 'text') { + rowCache.push({ prepared: prepare(text, font), text, font }); + } else { + rowCache.push({ prepared: null, text, font }); + } + } + caches.push(rowCache); + } + + this.cellCaches = caches; + return caches; + } + + // ── Phase 2: Estimate Heights ─────────────────────────────────── + + /** + * Calculate estimated pixel height of each row. + * Uses pretext for text columns, fixed/compute for others. + * Preserves previously measured (DOM-corrected) heights. + */ + estimateRowHeights( + data: Record[], + cache: CellCache[][], + columns: PretextColumnDef[], + lineHeight: number, + verticalPadding: number, + minRowHeight: number = 0, + ): RowHeightEntry[] { + const entries: RowHeightEntry[] = new Array(cache.length); + + for (let r = 0; r < cache.length; r++) { + // If we already have a DOM measurement, keep it + if (this.heightEntries[r]?.source === 'measured') { + entries[r] = this.heightEntries[r]; + continue; + } + + let maxCellHeight = 0; + const rowCache = cache[r]; + const rowData = data[r]; + + for (let c = 0; c < columns.length; c++) { + const col = columns[c]; + const mode = col.heightMode ?? { kind: 'text' }; + let cellHeight = 0; + + switch (mode.kind) { + case 'text': { + const cell = rowCache[c]; + if (cell.prepared) { + const result: LayoutResult = layout(cell.prepared, col.width, lineHeight); + cellHeight = result.height; + } + break; + } + case 'fixed': + cellHeight = mode.height; + break; + case 'compute': + cellHeight = mode.fn(rowData[col.field], rowData); + break; + case 'skip': + continue; + } + + cellHeight += col.extraHeight ?? 0; + + if (cellHeight > maxCellHeight) { + maxCellHeight = cellHeight; + } + } + + entries[r] = { + height: Math.max(minRowHeight, maxCellHeight + verticalPadding), + source: 'estimated', + }; + } + + this.heightEntries = entries; + return entries; + } + + // ── Phase 3: DOM Correction ───────────────────────────────────── + + /** + * Report a DOM-measured height for a specific row. + * Returns true if the height changed significantly (>1px). + */ + reportMeasuredHeight(index: number, measuredHeight: number): boolean { + if (index < 0 || index >= this.heightEntries.length) return false; + + const current = this.heightEntries[index]; + const diff = Math.abs(current.height - measuredHeight); + + if (diff > 1) { + this.heightEntries[index] = { height: measuredHeight, source: 'measured' }; + return true; + } + + // Mark as measured even if close — prevents re-estimation on relayout + if (current.source !== 'measured') { + this.heightEntries[index] = { height: current.height, source: 'measured' }; + } + return false; + } + + /** + * Batch report measured heights. Returns indices that changed significantly. + */ + reportMeasuredHeights(measurements: { index: number; height: number }[]): number[] { + const changed: number[] = []; + for (const m of measurements) { + if (this.reportMeasuredHeight(m.index, m.height)) { + changed.push(m.index); + } + } + return changed; + } + + /** + * Clear all DOM measurements, reverting to estimated heights. + * Useful after data changes or sorting. + */ + clearMeasurements(): void { + for (let i = 0; i < this.heightEntries.length; i++) { + if (this.heightEntries[i]?.source === 'measured') { + this.heightEntries[i].source = 'estimated'; + } + } + } + + // ── Position Index ────────────────────────────────────────────── + + /** + * Build cumulative position arrays from current height entries. + */ + buildPositionIndex(gap: number = 0): PositionIndex { + this.gap = gap; + const entries = this.heightEntries; + const n = entries.length; + const tops = new Array(n); + const bottoms = new Array(n); + let y = 0; + + for (let i = 0; i < n; i++) { + tops[i] = y; + y += entries[i].height; + bottoms[i] = y; + y += gap; + } + + this.positions = { tops, bottoms, totalHeight: y - (n > 0 ? gap : 0) }; + return this.positions; + } + + /** + * Rebuild positions starting from a specific index. + * More efficient than full rebuild after a single height correction. + */ + rebuildPositionsFrom(fromIndex: number): PositionIndex { + const entries = this.heightEntries; + const n = entries.length; + const { tops, bottoms } = this.positions; + + if (fromIndex <= 0) return this.buildPositionIndex(this.gap); + + let y = bottoms[fromIndex - 1] + this.gap; + + for (let i = fromIndex; i < n; i++) { + tops[i] = y; + y += entries[i].height; + bottoms[i] = y; + y += this.gap; + } + + this.positions.totalHeight = y - (n > 0 ? this.gap : 0); + return this.positions; + } + + // ── Query ─────────────────────────────────────────────────────── + + getPositions(): PositionIndex { + return this.positions; + } + + getRowHeight(index: number): number { + return this.heightEntries[index]?.height ?? 0; + } + + getRowHeights(start: number, end: number): number[] { + return this.heightEntries.slice(start, end).map(e => e.height); + } + + getHeightSource(index: number): HeightSource { + return this.heightEntries[index]?.source ?? 'estimated'; + } + + /** Count of rows that have been DOM-measured */ + getMeasuredCount(): number { + return this.heightEntries.filter(e => e.source === 'measured').length; + } + + /** + * Binary search for visible row range. O(log n). + */ + findVisibleRange( + scrollTop: number, + viewportHeight: number, + buffer: number = 5, + ): VisibleRange { + const { tops, bottoms } = this.positions; + const n = tops.length; + + if (n === 0) return { start: 0, end: 0 }; + + const minY = scrollTop; + const maxY = scrollTop + viewportHeight; + + let lo = 0, hi = n; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (bottoms[mid] <= minY) lo = mid + 1; + else hi = mid; + } + const firstVisible = lo; + + lo = firstVisible; + hi = n; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (tops[mid] < maxY) lo = mid + 1; + else hi = mid; + } + + return { + start: Math.max(0, firstVisible - buffer), + end: Math.min(n, lo + buffer), + }; + } +} diff --git a/src/lib/pretext-virtual-scroll.directive.ts b/src/lib/pretext-virtual-scroll.directive.ts new file mode 100644 index 0000000..06f06bd --- /dev/null +++ b/src/lib/pretext-virtual-scroll.directive.ts @@ -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[] = []; + @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(); + + // ── 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 = new Set(); + + constructor( + private el: ElementRef, + 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(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), + }); + } +} diff --git a/src/public-api.ts b/src/public-api.ts new file mode 100644 index 0000000..ddc3be3 --- /dev/null +++ b/src/public-api.ts @@ -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'; diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..543fd47 --- /dev/null +++ b/tsconfig.lib.json @@ -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" + ] +} diff --git a/tsconfig.lib.prod.json b/tsconfig.lib.prod.json new file mode 100644 index 0000000..06de549 --- /dev/null +++ b/tsconfig.lib.prod.json @@ -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" + } +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..ce7048b --- /dev/null +++ b/tsconfig.spec.json @@ -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" + ] +}