Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions src/components/chat/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
simulateFocus,
simulateInput,
simulateKeyboard,
suppressResizeObserverLoopError,
} from '../common/utils.spec.js';
import { simulateFileUpload } from '../file-input/file-input.spec.js';
import IgcInputComponent from '../input/input.js';
Expand All @@ -33,16 +34,7 @@ import type {
describe('Chat', () => {
before(() => {
defineComponents(IgcChatComponent, IgcInputComponent);

// Suppress ResizeObserver loop errors that can occur during tests from
// the underlying igc-textarea component. These errors do not affect the tests and are not actionable.
const errorHandler = window.onerror;
window.onerror = (message, ...args) => {
if (typeof message === 'string' && message.match(/ResizeObserver loop/)) {
return true;
}
return errorHandler ? errorHandler(message, ...args) : false;
};
suppressResizeObserverLoopError();
});

const textInputTemplate = (text: string) => html`
Expand Down
12 changes: 12 additions & 0 deletions src/components/common/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,15 @@ export function compareStyles(
export function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) {
expect(toCalendarDay(a).equalTo(toCalendarDay(b))).to.be.true;
}

export function suppressResizeObserverLoopError(): void {
// Suppress ResizeObserver loop errors that can occur during tests.
// These are benign and do not affect test correctness.
const errorHandler = window.onerror;
window.onerror = (message, ...args) => {
if (typeof message === 'string' && message.match(/ResizeObserver loop/)) {
return true;
}
return errorHandler ? errorHandler(message, ...args) : false;
};
}
223 changes: 223 additions & 0 deletions src/components/virtualization/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* Probes the browser for the maximum scrollable coordinate it supports.
*/
function getMaxBrowserSizeProbePx(doc: Document): number {
const div = doc.createElement('div');
div.style.position = 'absolute';
div.style.top = `${Number.MAX_SAFE_INTEGER}px`;
doc.body.appendChild(div);
const size = Math.abs(div.getBoundingClientRect().top);
doc.body.removeChild(div);
return size;
}

/**
* Builds a prefix sums array from the given sizes array.
* The prefix sums array has one more element than the sizes array,
* where the first element is 0 and each subsequent element is the sum of all previous sizes.
* This allows for efficient calculation of the total size up to any index in the sizes array.
*/
function buildPrefixSums(sizes: readonly number[]): number[] {
const sums = new Array<number>(sizes.length + 1);
sums[0] = 0;
for (let i = 0; i < sizes.length; i++) {
sums[i + 1] = sums[i] + sizes[i];
}
return sums;
}

/**
* Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target.
* This is used to efficiently determine how many items can fit within a given scroll position.
* The function returns the index of the last item that fits within the target scroll position.
* If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit.
*/
function binarySearchPrefixSums(
prefixSums: readonly number[],
target: number
): number {
let low = 0;
let high = prefixSums.length - 1;

while (low < high) {
const mid = (low + high + 1) >> 1;
if (prefixSums[mid] <= target) {
low = mid;
} else {
high = mid - 1;
}
}

return Math.max(0, low - 1);
}

/**
* Describes the currently visible (and over-scanned) range of items.
*/
export interface VisibleRange {
/** Index of the first rendered item (inclusive) */
startIndex: number;
/** Index of the last rendered item (inclusive) */
endIndex: number;
}

/**
* Pure scroll-math engine for a single axis of virtual scrolling.
*
* All size state is held as plain arrays. Consumers can register an
* `onSizeChange` callback to react whenever item sizes or the item count
* changes (e.g. to trigger a Lit `requestUpdate()`).
*/
export class VirtualScrollEngine {
private _maxBrowserSize = Number.POSITIVE_INFINITY;

/**
* The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the
* maximum DOM coordinate the browser supports; `1` otherwise.
* Used to map virtual scroll positions to DOM scroll positions.
*/
private _virtualRatio = 1;

/** Per-item measured or estimated sizes in px. */
private _itemSizes: number[] = [];

/** Cached prefix sums, rebuilt lazily when `_prefixSumsDirty` is set. */
private _prefixSums: number[] = [0];
private _prefixSumsDirty = false;

/**
* Called whenever item sizes or the item count change.
* Assign a callback (e.g. `() => this.requestUpdate()`) to react to size updates.
*/
public onSizeChange: (() => void) | null = null;

/**
* Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1].
*/
public get prefixSums(): number[] {
if (this._prefixSumsDirty) {
this._prefixSums = buildPrefixSums(this._itemSizes);
this._prefixSumsDirty = false;
}
return this._prefixSums;
}

/** Total virtual size of all items in px. */
public get totalSize(): number {
const pSum = this.prefixSums;
return pSum[pSum.length - 1] ?? 0;
}

/** Actual DOM space size (clamped to the maximum browser size) */
public get domSize(): number {
return this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize;
}

/**
* Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly.
*/
public initMaxBrowserSize(doc: Document): void {
this._maxBrowserSize = getMaxBrowserSizeProbePx(doc);
this._updateVirtualRatio();
}

/**
* Grows or shrinks the internal sizes array to `length`.
* New entries are filled with `estimatedSize`.
* Existing measured sizes are preserved.
*/
public resize(length: number, estimatedSize: number): void {
const current = this._itemSizes;
if (length === current.length) return;

if (length > current.length) {
current.push(...new Array(length - current.length).fill(estimatedSize));
} else {
current.length = length;
}
this._invalidate();
this.onSizeChange?.();
}

/**
* Records the measured DOM size for a single item.
*/
public measureItem(index: number, size: number): void {
if (index < 0 || index >= this._itemSizes.length) return;
if (this._itemSizes[index] === size) return;

this._itemSizes[index] = size;
this._invalidate();
this.onSizeChange?.();
}

/**
* Returns the DOM scroll offset in pixels that brings item at `index` into view
* at the leading edge of the viewport.
*/
public getScrollOffsetForIndex(index: number): number {
const pSums = this.prefixSums;
if (index <= 0) return 0;

const clamped = Math.min(index, pSums.length - 1);
const virtualOffset = pSums[clamped];
return virtualOffset / this._virtualRatio;
}

/** Returns the item index at the given DOM scroll position. */
public getIndexAtScroll(scrollPosition: number): number {
const virtualPosition = scrollPosition * this._virtualRatio;
const pSum = this.prefixSums;
if (virtualPosition <= 0 || pSum.length <= 1) return 0;

return binarySearchPrefixSums(pSum, virtualPosition);
}

/**
* Returns the visible + over-scanned item range for the given scroll state.
*/
public getVisibleRange(
scrollPosition: number,
viewportSize: number,
overScan: number,
totalItems: number
): VisibleRange {
if (totalItems === 0 || viewportSize <= 0) {
return { startIndex: 0, endIndex: -1 };
}

const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan);
const endScrollPosition = scrollPosition + viewportSize;
const endRaw = this.getIndexAtScroll(endScrollPosition);
const end = Math.min(totalItems - 1, endRaw + overScan);

return { startIndex: start, endIndex: end };
}

/**
* Returns the CSS `translateY` / `translateX` value (px) to apply to the
* absolutely-positioned content wrapper.
*
* The content wrapper is `position: absolute; top: 0; left: 0` inside a
* track element that is `totalSize` px tall/wide. Translating it to
* `getContentPosition(startIndex)` places the first rendered item exactly
* at its virtual scroll position within the track.
*/
public getContentPosition(index: number): number {
return this.getScrollOffsetForIndex(index);
}

private _invalidate(): void {
this._prefixSumsDirty = true;
this._updateVirtualRatio();
}

private _updateVirtualRatio(): void {
const totalSize = this.totalSize;
this._virtualRatio =
this._maxBrowserSize === Number.POSITIVE_INFINITY ||
totalSize <= this._maxBrowserSize
? 1
: totalSize / this._maxBrowserSize;
}
}
54 changes: 54 additions & 0 deletions src/components/virtualization/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Context for the item template in the virtual scroll component.
* Provides the item data, its index, and utility properties for template rendering.
*/
export class VirtualScrollItemContext<T> {
/** The current item in the virtual scroll */
public value: T;
/** The index of the current item */
public index: number;
/** The total number of items */
public count: number;

constructor(value: T, index: number, count: number) {
this.value = value;
this.index = index;
this.count = count;
}

/** Whether the current item is the first item */
public get isFirst(): boolean {
return this.index === 0;
}

/** Whether the current item is the last item */
public get isLast(): boolean {
return this.index === this.count - 1;
}
}

/** Snapshot of the currently rendered virtual window */
export interface VirtualScrollState {
/** The index of the first item currently rendered in the viewport. */
startIndex: number;
/** The index of the last item currently rendered in the viewport (inclusive). */
endIndex: number;
/** The size of the viewport in pixels. */
viewportSize: number;
/** The total size of the virtual scroll content in pixels. */
totalSize: number;
}

/**
* Request for more data to be loaded in the virtual scroll, typically emitted when the user scrolls near the end of the currently loaded items.
* The consumer of the virtual scroll component can listen to this event and load more data as needed.
*/
export interface VirtualScrollDataRequest {
/**
* The first index that does not yet have data.
* Append at least `(endIndex - startIndex + 1)` more items starting here.
*/
startIndex: number;
/** Number of items being requested. */
count: number;
}
Loading
Loading