lazy-render-virtual-scroll 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -0
- package/dist/cjs/adapters/react/LazyList.d.ts +12 -0
- package/dist/cjs/adapters/react/LazyList.d.ts.map +1 -0
- package/dist/cjs/adapters/react/useLazyList.d.ts +13 -0
- package/dist/cjs/adapters/react/useLazyList.d.ts.map +1 -0
- package/dist/cjs/core/Engine.d.ts +48 -0
- package/dist/cjs/core/Engine.d.ts.map +1 -0
- package/dist/cjs/core/PrefetchManager.d.ts +13 -0
- package/dist/cjs/core/PrefetchManager.d.ts.map +1 -0
- package/dist/cjs/core/RequestQueue.d.ts +23 -0
- package/dist/cjs/core/RequestQueue.d.ts.map +1 -0
- package/dist/cjs/core/WindowManager.d.ts +20 -0
- package/dist/cjs/core/WindowManager.d.ts.map +1 -0
- package/dist/cjs/core/types.d.ts +20 -0
- package/dist/cjs/core/types.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +435 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/platform/browser/ScrollObserver.d.ts +25 -0
- package/dist/cjs/platform/browser/ScrollObserver.d.ts.map +1 -0
- package/dist/cjs/utils/debounce.d.ts +5 -0
- package/dist/cjs/utils/debounce.d.ts.map +1 -0
- package/dist/cjs/utils/throttle.d.ts +5 -0
- package/dist/cjs/utils/throttle.d.ts.map +1 -0
- package/dist/esm/adapters/react/LazyList.d.ts +12 -0
- package/dist/esm/adapters/react/LazyList.d.ts.map +1 -0
- package/dist/esm/adapters/react/useLazyList.d.ts +13 -0
- package/dist/esm/adapters/react/useLazyList.d.ts.map +1 -0
- package/dist/esm/core/Engine.d.ts +48 -0
- package/dist/esm/core/Engine.d.ts.map +1 -0
- package/dist/esm/core/PrefetchManager.d.ts +13 -0
- package/dist/esm/core/PrefetchManager.d.ts.map +1 -0
- package/dist/esm/core/RequestQueue.d.ts +23 -0
- package/dist/esm/core/RequestQueue.d.ts.map +1 -0
- package/dist/esm/core/WindowManager.d.ts +20 -0
- package/dist/esm/core/WindowManager.d.ts.map +1 -0
- package/dist/esm/core/types.d.ts +20 -0
- package/dist/esm/core/types.d.ts.map +1 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +425 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/platform/browser/ScrollObserver.d.ts +25 -0
- package/dist/esm/platform/browser/ScrollObserver.d.ts.map +1 -0
- package/dist/esm/utils/debounce.d.ts +5 -0
- package/dist/esm/utils/debounce.d.ts.map +1 -0
- package/dist/esm/utils/throttle.d.ts +5 -0
- package/dist/esm/utils/throttle.d.ts.map +1 -0
- package/dist/index.d.ts +181 -0
- package/examples/chat-ui/Chat.jsx +158 -0
- package/examples/infinite-feed/Feed.jsx +97 -0
- package/examples/react-basic/App.jsx +64 -0
- package/package.json +55 -0
- package/rollup.config.js +39 -0
- package/src/adapters/react/LazyList.tsx +92 -0
- package/src/adapters/react/useLazyList.ts +87 -0
- package/src/core/Engine.ts +134 -0
- package/src/core/PrefetchManager.ts +22 -0
- package/src/core/RequestQueue.ts +69 -0
- package/src/core/WindowManager.ts +49 -0
- package/src/core/types.ts +24 -0
- package/src/index.ts +17 -0
- package/src/platform/browser/ScrollObserver.ts +86 -0
- package/src/utils/debounce.ts +19 -0
- package/src/utils/throttle.ts +19 -0
- package/test/engine.test.ts +136 -0
- package/test/prefetchManager.test.ts +99 -0
- package/test/reactAdapter.test.ts +26 -0
- package/test/requestQueue.test.ts +88 -0
- package/test/testRunner.ts +8 -0
- package/test/windowManager.test.ts +98 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { Engine } from '../../core/Engine';
|
|
3
|
+
import { EngineConfig, VisibleRange, FetchMoreCallback } from '../../core/types';
|
|
4
|
+
|
|
5
|
+
interface LazyListConfig extends EngineConfig {
|
|
6
|
+
fetchMore: FetchMoreCallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const useLazyList = (config: LazyListConfig) => {
|
|
10
|
+
const { fetchMore, ...engineConfig } = config;
|
|
11
|
+
const engineRef = useRef<Engine | null>(null);
|
|
12
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
13
|
+
|
|
14
|
+
const [visibleRange, setVisibleRange] = useState<VisibleRange>({ start: 0, end: 0 });
|
|
15
|
+
const [loadedItems, setLoadedItems] = useState<any[]>([]);
|
|
16
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
17
|
+
|
|
18
|
+
// Initialize engine
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
engineRef.current = new Engine(engineConfig);
|
|
21
|
+
engineRef.current.setFetchMoreCallback(fetchMore);
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
if (engineRef.current) {
|
|
25
|
+
engineRef.current.cleanup();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
// Update engine when config changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (engineRef.current) {
|
|
33
|
+
engineRef.current.updateDimensions(
|
|
34
|
+
engineConfig.viewportHeight,
|
|
35
|
+
engineConfig.itemHeight
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}, [engineConfig.viewportHeight, engineConfig.itemHeight]);
|
|
39
|
+
|
|
40
|
+
// Handle scroll events
|
|
41
|
+
const handleScroll = useCallback((scrollTop: number) => {
|
|
42
|
+
if (engineRef.current) {
|
|
43
|
+
engineRef.current.updateScrollPosition(scrollTop);
|
|
44
|
+
|
|
45
|
+
// Update state based on engine
|
|
46
|
+
const state = engineRef.current.getState();
|
|
47
|
+
setVisibleRange(state.visibleRange);
|
|
48
|
+
setIsLoading(state.isLoading);
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// Set container reference
|
|
53
|
+
const setContainerRef = (element: HTMLElement | null) => {
|
|
54
|
+
if (element) {
|
|
55
|
+
containerRef.current = element;
|
|
56
|
+
|
|
57
|
+
// Initialize scroll observer when container is available
|
|
58
|
+
if (typeof window !== 'undefined' && element) {
|
|
59
|
+
// In a real implementation, we would use ScrollObserver here
|
|
60
|
+
// For now, we'll just attach a basic scroll listener
|
|
61
|
+
const handleScrollEvent = () => {
|
|
62
|
+
handleScroll(element.scrollTop);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
element.addEventListener('scroll', handleScrollEvent, { passive: true });
|
|
66
|
+
|
|
67
|
+
// Cleanup
|
|
68
|
+
return () => {
|
|
69
|
+
element.removeEventListener('scroll', handleScrollEvent);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
visibleRange,
|
|
77
|
+
loadedItems,
|
|
78
|
+
isLoading,
|
|
79
|
+
setContainerRef,
|
|
80
|
+
// Helper function to trigger manual refresh
|
|
81
|
+
refresh: () => {
|
|
82
|
+
if (engineRef.current) {
|
|
83
|
+
engineRef.current.updateScrollPosition(containerRef.current?.scrollTop || 0);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { EngineConfig, VisibleRange, FetchMoreCallback, EngineState } from './types';
|
|
2
|
+
import { WindowManager } from './WindowManager';
|
|
3
|
+
import { PrefetchManager } from './PrefetchManager';
|
|
4
|
+
import { RequestQueue } from './RequestQueue';
|
|
5
|
+
|
|
6
|
+
export class Engine {
|
|
7
|
+
private config: EngineConfig;
|
|
8
|
+
private windowManager: WindowManager;
|
|
9
|
+
private prefetchManager: PrefetchManager;
|
|
10
|
+
private requestQueue: RequestQueue;
|
|
11
|
+
|
|
12
|
+
private state: EngineState;
|
|
13
|
+
private fetchMoreCallback: FetchMoreCallback | null = null;
|
|
14
|
+
private totalItems: number;
|
|
15
|
+
|
|
16
|
+
constructor(config: EngineConfig) {
|
|
17
|
+
this.config = {
|
|
18
|
+
...config,
|
|
19
|
+
bufferSize: config.bufferSize || 5
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
this.windowManager = new WindowManager(
|
|
23
|
+
this.config.itemHeight,
|
|
24
|
+
this.config.viewportHeight,
|
|
25
|
+
this.config.bufferSize
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
this.prefetchManager = new PrefetchManager(this.config.bufferSize);
|
|
29
|
+
this.requestQueue = new RequestQueue(1); // Single request at a time
|
|
30
|
+
|
|
31
|
+
this.totalItems = this.config.totalItems || Number.MAX_SAFE_INTEGER;
|
|
32
|
+
|
|
33
|
+
this.state = {
|
|
34
|
+
scrollTop: 0,
|
|
35
|
+
visibleRange: { start: 0, end: 0 },
|
|
36
|
+
loadedItems: 0,
|
|
37
|
+
isLoading: false
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Update scroll position and recalculate visible range
|
|
43
|
+
*/
|
|
44
|
+
updateScrollPosition(scrollTop: number): void {
|
|
45
|
+
this.state.scrollTop = scrollTop;
|
|
46
|
+
this.state.visibleRange = this.windowManager.calculateVisibleRange(scrollTop);
|
|
47
|
+
|
|
48
|
+
// Check if we need to fetch more items
|
|
49
|
+
if (this.shouldFetchMore()) {
|
|
50
|
+
this.fetchMore();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the current visible range
|
|
56
|
+
*/
|
|
57
|
+
getVisibleRange(): VisibleRange {
|
|
58
|
+
return this.state.visibleRange;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if more items should be fetched
|
|
63
|
+
*/
|
|
64
|
+
shouldFetchMore(): boolean {
|
|
65
|
+
if (!this.fetchMoreCallback) return false;
|
|
66
|
+
if (this.state.isLoading) return false;
|
|
67
|
+
if (this.state.loadedItems >= this.totalItems) return false;
|
|
68
|
+
|
|
69
|
+
return this.prefetchManager.shouldPrefetch(
|
|
70
|
+
this.state.visibleRange.end,
|
|
71
|
+
this.state.loadedItems
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fetch more items
|
|
77
|
+
*/
|
|
78
|
+
async fetchMore(): Promise<void> {
|
|
79
|
+
if (!this.fetchMoreCallback || this.state.isLoading) return;
|
|
80
|
+
|
|
81
|
+
this.state.isLoading = true;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const result = await this.requestQueue.add(this.fetchMoreCallback);
|
|
85
|
+
// Assuming the result contains new items
|
|
86
|
+
// In a real implementation, this would update the loaded items count
|
|
87
|
+
this.state.loadedItems += Array.isArray(result) ? result.length : 1;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Error fetching more items:', error);
|
|
90
|
+
} finally {
|
|
91
|
+
this.state.isLoading = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set the fetchMore callback function
|
|
97
|
+
*/
|
|
98
|
+
setFetchMoreCallback(callback: FetchMoreCallback): void {
|
|
99
|
+
this.fetchMoreCallback = callback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Update total items count
|
|
104
|
+
*/
|
|
105
|
+
updateTotalItems(count: number): void {
|
|
106
|
+
this.totalItems = count;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get current engine state
|
|
111
|
+
*/
|
|
112
|
+
getState(): EngineState {
|
|
113
|
+
return { ...this.state };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Update viewport dimensions
|
|
118
|
+
*/
|
|
119
|
+
updateDimensions(viewportHeight: number, itemHeight: number): void {
|
|
120
|
+
this.windowManager.updateViewportHeight(viewportHeight);
|
|
121
|
+
this.windowManager.updateItemHeight(itemHeight);
|
|
122
|
+
|
|
123
|
+
// Recalculate visible range with new dimensions
|
|
124
|
+
this.state.visibleRange = this.windowManager.calculateVisibleRange(this.state.scrollTop);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Cleanup resources
|
|
129
|
+
*/
|
|
130
|
+
cleanup(): void {
|
|
131
|
+
this.requestQueue.clear();
|
|
132
|
+
this.fetchMoreCallback = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class PrefetchManager {
|
|
2
|
+
private bufferSize: number;
|
|
3
|
+
|
|
4
|
+
constructor(bufferSize: number = 5) {
|
|
5
|
+
this.bufferSize = bufferSize;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determine if more items should be fetched based on visible range and loaded items
|
|
10
|
+
*/
|
|
11
|
+
shouldPrefetch(visibleEnd: number, totalLoaded: number): boolean {
|
|
12
|
+
// Simple rule: if visible end is approaching the loaded boundary, fetch more
|
|
13
|
+
return visibleEnd >= totalLoaded - this.bufferSize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Update buffer size if it changes
|
|
18
|
+
*/
|
|
19
|
+
updateBufferSize(size: number): void {
|
|
20
|
+
this.bufferSize = size;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class RequestQueue {
|
|
2
|
+
private queue: Array<() => Promise<any>> = [];
|
|
3
|
+
private processing: boolean = false;
|
|
4
|
+
private maxConcurrent: number;
|
|
5
|
+
|
|
6
|
+
constructor(maxConcurrent: number = 1) {
|
|
7
|
+
this.maxConcurrent = maxConcurrent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Add a request to the queue
|
|
12
|
+
*/
|
|
13
|
+
add(requestFn: () => Promise<any>): Promise<any> {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
this.queue.push(() => requestFn().then(resolve).catch(reject));
|
|
16
|
+
|
|
17
|
+
// Start processing if not already processing
|
|
18
|
+
if (!this.processing) {
|
|
19
|
+
this.processQueue();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Process the queue
|
|
26
|
+
*/
|
|
27
|
+
private async processQueue(): Promise<void> {
|
|
28
|
+
if (this.queue.length === 0) {
|
|
29
|
+
this.processing = false;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.processing = true;
|
|
34
|
+
|
|
35
|
+
// Process up to maxConcurrent requests
|
|
36
|
+
const concurrentRequests = [];
|
|
37
|
+
const count = Math.min(this.maxConcurrent, this.queue.length);
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < count; i++) {
|
|
40
|
+
const requestFn = this.queue.shift();
|
|
41
|
+
if (requestFn) {
|
|
42
|
+
concurrentRequests.push(requestFn());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await Promise.all(concurrentRequests);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Request queue error:', error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Process remaining items
|
|
53
|
+
await this.processQueue();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear the queue
|
|
58
|
+
*/
|
|
59
|
+
clear(): void {
|
|
60
|
+
this.queue = [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the current queue length
|
|
65
|
+
*/
|
|
66
|
+
getLength(): number {
|
|
67
|
+
return this.queue.length;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { VisibleRange } from './types';
|
|
2
|
+
|
|
3
|
+
export class WindowManager {
|
|
4
|
+
private itemHeight: number;
|
|
5
|
+
private viewportHeight: number;
|
|
6
|
+
private bufferSize: number;
|
|
7
|
+
|
|
8
|
+
constructor(itemHeight: number, viewportHeight: number, bufferSize: number = 5) {
|
|
9
|
+
this.itemHeight = itemHeight;
|
|
10
|
+
this.viewportHeight = viewportHeight;
|
|
11
|
+
this.bufferSize = bufferSize;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Calculate the visible range based on scroll position
|
|
16
|
+
*/
|
|
17
|
+
calculateVisibleRange(scrollTop: number): VisibleRange {
|
|
18
|
+
// Calculate how many items fit in the viewport
|
|
19
|
+
const itemsPerViewport = Math.ceil(this.viewportHeight / this.itemHeight);
|
|
20
|
+
|
|
21
|
+
// Calculate the starting index based on scroll position
|
|
22
|
+
const startIndex = Math.floor(scrollTop / this.itemHeight);
|
|
23
|
+
|
|
24
|
+
// Calculate the ending index with buffer
|
|
25
|
+
const endIndex = Math.min(
|
|
26
|
+
startIndex + itemsPerViewport + this.bufferSize,
|
|
27
|
+
Number.MAX_SAFE_INTEGER // Will be limited by total items later
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
start: Math.max(0, startIndex - this.bufferSize),
|
|
32
|
+
end: endIndex
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Update viewport height if it changes
|
|
38
|
+
*/
|
|
39
|
+
updateViewportHeight(height: number): void {
|
|
40
|
+
this.viewportHeight = height;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update item height if it changes
|
|
45
|
+
*/
|
|
46
|
+
updateItemHeight(height: number): void {
|
|
47
|
+
this.itemHeight = height;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared type definitions for lazy-render
|
|
2
|
+
|
|
3
|
+
export interface EngineConfig {
|
|
4
|
+
itemHeight: number;
|
|
5
|
+
viewportHeight: number;
|
|
6
|
+
bufferSize?: number;
|
|
7
|
+
totalItems?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface VisibleRange {
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FetchMoreCallback {
|
|
16
|
+
(): Promise<any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EngineState {
|
|
20
|
+
scrollTop: number;
|
|
21
|
+
visibleRange: VisibleRange;
|
|
22
|
+
loadedItems: number;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Core exports
|
|
2
|
+
export { Engine } from './core/Engine';
|
|
3
|
+
export { WindowManager } from './core/WindowManager';
|
|
4
|
+
export { PrefetchManager } from './core/PrefetchManager';
|
|
5
|
+
export { RequestQueue } from './core/RequestQueue';
|
|
6
|
+
export type { EngineConfig, VisibleRange, FetchMoreCallback, EngineState } from './core/types';
|
|
7
|
+
|
|
8
|
+
// Platform exports
|
|
9
|
+
export { ScrollObserver } from './platform/browser/ScrollObserver';
|
|
10
|
+
|
|
11
|
+
// React adapter exports
|
|
12
|
+
export { useLazyList } from './adapters/react/useLazyList';
|
|
13
|
+
export { LazyList } from './adapters/react/LazyList';
|
|
14
|
+
|
|
15
|
+
// Utility exports
|
|
16
|
+
export { debounce } from './utils/debounce';
|
|
17
|
+
export { throttle } from './utils/throttle';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export class ScrollObserver {
|
|
2
|
+
private container: HTMLElement;
|
|
3
|
+
private callback: (scrollTop: number) => void;
|
|
4
|
+
private options: IntersectionObserverInit;
|
|
5
|
+
private observer: IntersectionObserver | null = null;
|
|
6
|
+
private sentinelElement: HTMLElement | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
container: HTMLElement,
|
|
10
|
+
callback: (scrollTop: number) => void,
|
|
11
|
+
options?: Partial<IntersectionObserverInit>
|
|
12
|
+
) {
|
|
13
|
+
this.container = container;
|
|
14
|
+
this.callback = callback;
|
|
15
|
+
this.options = {
|
|
16
|
+
root: container,
|
|
17
|
+
threshold: [0, 1],
|
|
18
|
+
...options
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start observing scroll events
|
|
24
|
+
*/
|
|
25
|
+
observe(): void {
|
|
26
|
+
// Create a sentinel element at the bottom to detect when user scrolls near the end
|
|
27
|
+
this.sentinelElement = document.createElement('div');
|
|
28
|
+
this.sentinelElement.style.height = '1px'; // Very small element
|
|
29
|
+
this.sentinelElement.setAttribute('data-lazy-sentinel', '');
|
|
30
|
+
|
|
31
|
+
// Add sentinel to the container
|
|
32
|
+
this.container.appendChild(this.sentinelElement);
|
|
33
|
+
|
|
34
|
+
// Create intersection observer to detect when sentinel comes into view
|
|
35
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
36
|
+
entries.forEach(entry => {
|
|
37
|
+
if (entry.isIntersecting) {
|
|
38
|
+
// Trigger callback with current scroll position
|
|
39
|
+
this.callback(this.container.scrollTop);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}, this.options);
|
|
43
|
+
|
|
44
|
+
this.observer.observe(this.sentinelElement);
|
|
45
|
+
|
|
46
|
+
// Also listen to scroll events for continuous updates
|
|
47
|
+
this.container.addEventListener('scroll', this.onScroll, { passive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle scroll events
|
|
52
|
+
*/
|
|
53
|
+
private onScroll = (): void => {
|
|
54
|
+
// Debounced scroll handler to prevent too frequent updates
|
|
55
|
+
this.debounce(() => {
|
|
56
|
+
this.callback(this.container.scrollTop);
|
|
57
|
+
}, 16); // ~60fps
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Debounce function for scroll events
|
|
62
|
+
*/
|
|
63
|
+
private debounce(func: () => void, wait: number): void {
|
|
64
|
+
let timeout: NodeJS.Timeout;
|
|
65
|
+
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
timeout = setTimeout(func, wait);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Disconnect observer and clean up
|
|
72
|
+
*/
|
|
73
|
+
disconnect(): void {
|
|
74
|
+
if (this.observer) {
|
|
75
|
+
this.observer.disconnect();
|
|
76
|
+
this.observer = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.sentinelElement) {
|
|
80
|
+
this.sentinelElement.remove();
|
|
81
|
+
this.sentinelElement = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.container.removeEventListener('scroll', this.onScroll);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debounce function to limit the rate at which a function is called
|
|
3
|
+
*/
|
|
4
|
+
export function debounce<T extends (...args: any[]) => any>(
|
|
5
|
+
func: T,
|
|
6
|
+
wait: number
|
|
7
|
+
): (...args: Parameters<T>) => void {
|
|
8
|
+
let timeout: NodeJS.Timeout | null = null;
|
|
9
|
+
|
|
10
|
+
return function executedFunction(...args: Parameters<T>): void {
|
|
11
|
+
if (timeout) {
|
|
12
|
+
clearTimeout(timeout);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
timeout = setTimeout(() => {
|
|
16
|
+
func.apply(this, args);
|
|
17
|
+
}, wait);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throttle function to limit the rate at which a function is called
|
|
3
|
+
*/
|
|
4
|
+
export function throttle<T extends (...args: any[]) => any>(
|
|
5
|
+
func: T,
|
|
6
|
+
limit: number
|
|
7
|
+
): (...args: Parameters<T>) => void {
|
|
8
|
+
let inThrottle: boolean;
|
|
9
|
+
|
|
10
|
+
return function executedFunction(...args: Parameters<T>): void {
|
|
11
|
+
if (!inThrottle) {
|
|
12
|
+
func.apply(this, args);
|
|
13
|
+
inThrottle = true;
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
inThrottle = false;
|
|
16
|
+
}, limit);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Engine } from '../src/core/Engine';
|
|
2
|
+
|
|
3
|
+
// Simple test runner for Node.js
|
|
4
|
+
function describe(name: string, fn: () => void) {
|
|
5
|
+
console.log(`\nDESCRIBE: ${name}`);
|
|
6
|
+
fn();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function test(name: string, fn: () => void | Promise<void>) {
|
|
10
|
+
console.log(` TEST: ${name}`);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = fn();
|
|
14
|
+
if (result instanceof Promise) {
|
|
15
|
+
return result.then(() => console.log(' PASS'))
|
|
16
|
+
.catch(err => console.error(` FAIL: ${err.message}`));
|
|
17
|
+
} else {
|
|
18
|
+
console.log(' PASS');
|
|
19
|
+
}
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error(` FAIL: ${(err as Error).message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expect(actual: any) {
|
|
26
|
+
return {
|
|
27
|
+
toBe: (expected: any) => {
|
|
28
|
+
if (actual !== expected) {
|
|
29
|
+
throw new Error(`Expected ${actual} to be ${expected}`);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
toBeGreaterThanOrEqual: (expected: number) => {
|
|
33
|
+
if (!(actual >= expected)) {
|
|
34
|
+
throw new Error(`Expected ${actual} to be greater than or equal to ${expected}`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
toBeGreaterThan: (expected: number) => {
|
|
38
|
+
if (!(actual > expected)) {
|
|
39
|
+
throw new Error(`Expected ${actual} to be greater than ${expected}`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
toEqual: (expected: any) => {
|
|
43
|
+
const actualStr = JSON.stringify(actual);
|
|
44
|
+
const expectedStr = JSON.stringify(expected);
|
|
45
|
+
if (actualStr !== expectedStr) {
|
|
46
|
+
throw new Error(`Expected ${actualStr} to equal ${expectedStr}`);
|
|
47
|
+
} else {
|
|
48
|
+
console.log(' PASS');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runTests() {
|
|
55
|
+
console.log('\nRunning Engine tests...\n');
|
|
56
|
+
|
|
57
|
+
describe('Engine', () => {
|
|
58
|
+
let engine: Engine;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
engine = new Engine({
|
|
62
|
+
itemHeight: 50,
|
|
63
|
+
viewportHeight: 200,
|
|
64
|
+
bufferSize: 2
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
engine.cleanup();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should initialize with correct default state', () => {
|
|
73
|
+
const state = engine.getState();
|
|
74
|
+
expect(state.scrollTop).toBe(0);
|
|
75
|
+
expect(state.visibleRange.start).toBe(0);
|
|
76
|
+
expect(state.visibleRange.end).toBeGreaterThanOrEqual(0);
|
|
77
|
+
expect(state.loadedItems).toBe(0);
|
|
78
|
+
expect(state.isLoading).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('should update scroll position and calculate visible range', () => {
|
|
82
|
+
engine.updateScrollPosition(100);
|
|
83
|
+
|
|
84
|
+
const state = engine.getState();
|
|
85
|
+
expect(state.scrollTop).toBe(100);
|
|
86
|
+
// With scroll position 100 and item height 50, start should be around 2
|
|
87
|
+
expect(state.visibleRange.start).toBeGreaterThanOrEqual(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('should determine when to fetch more items', () => {
|
|
91
|
+
// Initially should not need to fetch more since no fetchMore callback is set
|
|
92
|
+
const shouldFetch = engine.shouldFetchMore();
|
|
93
|
+
expect(shouldFetch).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('should update dimensions correctly', () => {
|
|
97
|
+
engine.updateDimensions(300, 60);
|
|
98
|
+
|
|
99
|
+
// Verify that the internal window manager was updated
|
|
100
|
+
// This is tested indirectly by updating scroll and checking range
|
|
101
|
+
engine.updateScrollPosition(150);
|
|
102
|
+
const state = engine.getState();
|
|
103
|
+
expect(state.scrollTop).toBe(150);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('should handle fetchMore callback', async () => {
|
|
107
|
+
let fetchMoreCalled = false;
|
|
108
|
+
const mockFetchMore = async () => {
|
|
109
|
+
fetchMoreCalled = true;
|
|
110
|
+
return [];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
engine.setFetchMoreCallback(mockFetchMore);
|
|
114
|
+
|
|
115
|
+
// Test that the callback is set correctly
|
|
116
|
+
// We'll test this by triggering a condition where fetchMore would be called
|
|
117
|
+
// But for this test, just verify the callback is stored
|
|
118
|
+
// @ts-ignore - accessing private property for testing
|
|
119
|
+
expect(engine.fetchMoreCallback !== null).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Define helper functions
|
|
125
|
+
function beforeEach(fn: () => void) {
|
|
126
|
+
fn();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function afterEach(fn: () => void) {
|
|
130
|
+
fn();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Run the tests
|
|
134
|
+
runTests().then(() => {
|
|
135
|
+
console.log('\nEngine tests completed.');
|
|
136
|
+
});
|