@qorejs/qore 0.6.0
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/dist/index.js +1193 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
- package/src/component.ts +26 -0
- package/src/error.ts +144 -0
- package/src/index.ts +95 -0
- package/src/render.ts +286 -0
- package/src/signal.ts +213 -0
- package/src/ssr.ts +341 -0
- package/src/stream.ts +432 -0
- package/src/utils.ts +116 -0
- package/src/virtual-list.ts +405 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qore Virtual List - High-performance list rendering
|
|
3
|
+
* Windowed rendering for large lists
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { signal, computed, effect } from './signal';
|
|
7
|
+
import { h, render, VNode, Component, For } from './render';
|
|
8
|
+
|
|
9
|
+
export interface VirtualListItem<T> {
|
|
10
|
+
id: string | number;
|
|
11
|
+
data: T;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VirtualListProps<T> {
|
|
16
|
+
items: T[];
|
|
17
|
+
itemHeight: number | ((item: T, index: number) => number);
|
|
18
|
+
containerHeight: number;
|
|
19
|
+
renderItem: (item: T, index: number) => VNode;
|
|
20
|
+
overscan?: number; // Number of items to render outside visible area
|
|
21
|
+
onScroll?: (scrollTop: number) => void;
|
|
22
|
+
onRangeChange?: (startIndex: number, endIndex: number) => void;
|
|
23
|
+
className?: string;
|
|
24
|
+
getKey?: (item: T, index: number) => string | number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VirtualListState {
|
|
28
|
+
scrollTop: number;
|
|
29
|
+
containerHeight: number;
|
|
30
|
+
totalHeight: number;
|
|
31
|
+
visibleStartIndex: number;
|
|
32
|
+
visibleEndIndex: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Virtual List Component
|
|
37
|
+
* Only renders visible items for better performance
|
|
38
|
+
*/
|
|
39
|
+
export function VirtualList<T>(props: VirtualListProps<T>): Component {
|
|
40
|
+
const {
|
|
41
|
+
items,
|
|
42
|
+
itemHeight,
|
|
43
|
+
containerHeight,
|
|
44
|
+
renderItem,
|
|
45
|
+
overscan = 5,
|
|
46
|
+
onScroll,
|
|
47
|
+
onRangeChange,
|
|
48
|
+
className = 'virtual-list',
|
|
49
|
+
getKey = (item, index) => index
|
|
50
|
+
} = props;
|
|
51
|
+
|
|
52
|
+
const isDynamicHeight = typeof itemHeight === 'function';
|
|
53
|
+
|
|
54
|
+
// Scroll position
|
|
55
|
+
const scrollTop = signal(0);
|
|
56
|
+
|
|
57
|
+
// Calculate total height
|
|
58
|
+
const totalHeight = computed(() => {
|
|
59
|
+
if (!isDynamicHeight) {
|
|
60
|
+
return items.length * itemHeight;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let height = 0;
|
|
64
|
+
for (let i = 0; i < items.length; i++) {
|
|
65
|
+
height += itemHeight(items[i], i);
|
|
66
|
+
}
|
|
67
|
+
return height;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Get item height at index
|
|
71
|
+
const getItemHeight = (index: number): number => {
|
|
72
|
+
if (!isDynamicHeight) {
|
|
73
|
+
return itemHeight as number;
|
|
74
|
+
}
|
|
75
|
+
return itemHeight(items[index], index);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Calculate item position (offset from top)
|
|
79
|
+
const getItemOffset = (index: number): number => {
|
|
80
|
+
if (!isDynamicHeight) {
|
|
81
|
+
return index * (itemHeight as number);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let offset = 0;
|
|
85
|
+
for (let i = 0; i < index; i++) {
|
|
86
|
+
offset += getItemHeight(i);
|
|
87
|
+
}
|
|
88
|
+
return offset;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Find the first visible item index
|
|
92
|
+
const findStartIndex = (scrollPos: number): number => {
|
|
93
|
+
if (!isDynamicHeight) {
|
|
94
|
+
return Math.max(0, Math.floor(scrollPos / (itemHeight as number)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let offset = 0;
|
|
98
|
+
for (let i = 0; i < items.length; i++) {
|
|
99
|
+
const height = getItemHeight(i);
|
|
100
|
+
if (offset + height > scrollPos) {
|
|
101
|
+
return i;
|
|
102
|
+
}
|
|
103
|
+
offset += height;
|
|
104
|
+
}
|
|
105
|
+
return items.length - 1;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Find the last visible item index
|
|
109
|
+
const findEndIndex = (startIndex: number, scrollPos: number): number => {
|
|
110
|
+
const viewportEnd = scrollPos + containerHeight;
|
|
111
|
+
|
|
112
|
+
if (!isDynamicHeight) {
|
|
113
|
+
return Math.min(
|
|
114
|
+
items.length - 1,
|
|
115
|
+
Math.ceil(viewportEnd / (itemHeight as number))
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let offset = getItemOffset(startIndex);
|
|
120
|
+
for (let i = startIndex; i < items.length; i++) {
|
|
121
|
+
const height = getItemHeight(i);
|
|
122
|
+
if (offset > viewportEnd) {
|
|
123
|
+
return i;
|
|
124
|
+
}
|
|
125
|
+
offset += height;
|
|
126
|
+
}
|
|
127
|
+
return items.length - 1;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Visible range
|
|
131
|
+
const visibleRange = computed(() => {
|
|
132
|
+
const scrollPos = scrollTop();
|
|
133
|
+
let startIndex = findStartIndex(scrollPos);
|
|
134
|
+
let endIndex = findEndIndex(startIndex, scrollPos);
|
|
135
|
+
|
|
136
|
+
// Add overscan
|
|
137
|
+
startIndex = Math.max(0, startIndex - overscan);
|
|
138
|
+
endIndex = Math.min(items.length - 1, endIndex + overscan);
|
|
139
|
+
|
|
140
|
+
return { startIndex, endIndex };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Notify range change
|
|
144
|
+
effect(() => {
|
|
145
|
+
const { startIndex, endIndex } = visibleRange();
|
|
146
|
+
onRangeChange?.(startIndex, endIndex);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Handle scroll
|
|
150
|
+
const handleScroll = (e: Event) => {
|
|
151
|
+
const target = e.target as HTMLElement;
|
|
152
|
+
const newScrollTop = target.scrollTop;
|
|
153
|
+
scrollTop(newScrollTop);
|
|
154
|
+
onScroll?.(newScrollTop);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Render
|
|
158
|
+
return () => {
|
|
159
|
+
const { startIndex, endIndex } = visibleRange();
|
|
160
|
+
const visibleItems = items.slice(startIndex, endIndex + 1);
|
|
161
|
+
|
|
162
|
+
// Calculate spacer heights
|
|
163
|
+
const topSpacerHeight = getItemOffset(startIndex);
|
|
164
|
+
const bottomSpacerHeight = totalHeight() - getItemOffset(endIndex + 1);
|
|
165
|
+
|
|
166
|
+
return h('div', {
|
|
167
|
+
class: className,
|
|
168
|
+
style: {
|
|
169
|
+
height: containerHeight + 'px',
|
|
170
|
+
overflowY: 'auto',
|
|
171
|
+
position: 'relative'
|
|
172
|
+
},
|
|
173
|
+
onscroll: handleScroll
|
|
174
|
+
}, [
|
|
175
|
+
// Top spacer
|
|
176
|
+
h('div', {
|
|
177
|
+
style: {
|
|
178
|
+
height: topSpacerHeight + 'px',
|
|
179
|
+
flexShrink: 0
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
// Visible items
|
|
184
|
+
...visibleItems.map((item, i) => {
|
|
185
|
+
const index = startIndex + i;
|
|
186
|
+
const key = getKey(item, index);
|
|
187
|
+
const height = getItemHeight(index);
|
|
188
|
+
|
|
189
|
+
return h('div', {
|
|
190
|
+
key: key,
|
|
191
|
+
'data-index': index,
|
|
192
|
+
style: {
|
|
193
|
+
height: height + 'px',
|
|
194
|
+
flexShrink: 0
|
|
195
|
+
}
|
|
196
|
+
}, [
|
|
197
|
+
renderItem(item, index)
|
|
198
|
+
]);
|
|
199
|
+
}),
|
|
200
|
+
|
|
201
|
+
// Bottom spacer
|
|
202
|
+
h('div', {
|
|
203
|
+
style: {
|
|
204
|
+
height: Math.max(0, bottomSpacerHeight) + 'px',
|
|
205
|
+
flexShrink: 0
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
]);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Infinite Scroll Virtual List
|
|
214
|
+
* Supports loading more items as user scrolls
|
|
215
|
+
*/
|
|
216
|
+
export interface InfiniteListProps<T> extends VirtualListProps<T> {
|
|
217
|
+
onLoadMore: () => Promise<T[]>;
|
|
218
|
+
hasMore: boolean;
|
|
219
|
+
loading?: boolean;
|
|
220
|
+
loadingComponent?: VNode;
|
|
221
|
+
endComponent?: VNode;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function InfiniteList<T>(props: InfiniteListProps<T>): Component {
|
|
225
|
+
const {
|
|
226
|
+
items,
|
|
227
|
+
itemHeight,
|
|
228
|
+
containerHeight,
|
|
229
|
+
renderItem,
|
|
230
|
+
onLoadMore,
|
|
231
|
+
hasMore,
|
|
232
|
+
loading = false,
|
|
233
|
+
loadingComponent = h('div', { class: 'loading' }, 'Loading...'),
|
|
234
|
+
endComponent = h('div', { class: 'end' }, 'No more items'),
|
|
235
|
+
...listProps
|
|
236
|
+
} = props;
|
|
237
|
+
|
|
238
|
+
const isLoading = signal(loading);
|
|
239
|
+
const nearEnd = signal(false);
|
|
240
|
+
|
|
241
|
+
// Load more when near end
|
|
242
|
+
effect(() => {
|
|
243
|
+
if (nearEnd() && hasMore && !isLoading()) {
|
|
244
|
+
isLoading(true);
|
|
245
|
+
onLoadMore().finally(() => {
|
|
246
|
+
isLoading(false);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return () => {
|
|
252
|
+
const allItems = [...items];
|
|
253
|
+
|
|
254
|
+
// Add loading indicator
|
|
255
|
+
if (isLoading()) {
|
|
256
|
+
allItems.push({ __loading: true } as any);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Add end message
|
|
260
|
+
if (!hasMore && !isLoading()) {
|
|
261
|
+
allItems.push({ __end: true } as any);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const augmentedRenderItem = (item: any, index: number): VNode => {
|
|
265
|
+
if ((item as any).__loading) {
|
|
266
|
+
return loadingComponent;
|
|
267
|
+
}
|
|
268
|
+
if ((item as any).__end) {
|
|
269
|
+
return endComponent;
|
|
270
|
+
}
|
|
271
|
+
return renderItem(item, index);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return h(VirtualList, {
|
|
275
|
+
...listProps,
|
|
276
|
+
items: allItems,
|
|
277
|
+
itemHeight,
|
|
278
|
+
containerHeight,
|
|
279
|
+
renderItem: augmentedRenderItem,
|
|
280
|
+
onRangeChange: (start, end) => {
|
|
281
|
+
// Trigger load more when near end
|
|
282
|
+
const threshold = Math.floor(allItems.length * 0.8);
|
|
283
|
+
nearEnd(end >= threshold);
|
|
284
|
+
listProps.onRangeChange?.(start, end);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fixed-height virtual list (optimized for uniform item heights)
|
|
292
|
+
*/
|
|
293
|
+
export function FixedVirtualList<T>(props: Omit<VirtualListProps<T>, 'itemHeight'> & {
|
|
294
|
+
itemHeight: number;
|
|
295
|
+
}): Component {
|
|
296
|
+
return VirtualList(props);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Grid-based virtual list for 2D layouts
|
|
301
|
+
*/
|
|
302
|
+
export interface VirtualGridProps<T> {
|
|
303
|
+
items: T[];
|
|
304
|
+
itemWidth: number;
|
|
305
|
+
itemHeight: number;
|
|
306
|
+
containerWidth: number;
|
|
307
|
+
containerHeight: number;
|
|
308
|
+
renderItem: (item: T, index: number) => VNode;
|
|
309
|
+
gap?: number;
|
|
310
|
+
overscan?: number;
|
|
311
|
+
className?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function VirtualGrid<T>(props: VirtualGridProps<T>): Component {
|
|
315
|
+
const {
|
|
316
|
+
items,
|
|
317
|
+
itemWidth,
|
|
318
|
+
itemHeight,
|
|
319
|
+
containerWidth,
|
|
320
|
+
containerHeight,
|
|
321
|
+
renderItem,
|
|
322
|
+
gap = 0,
|
|
323
|
+
overscan = 2,
|
|
324
|
+
className = 'virtual-grid'
|
|
325
|
+
} = props;
|
|
326
|
+
|
|
327
|
+
const columns = Math.floor(containerWidth / (itemWidth + gap));
|
|
328
|
+
const rows = Math.ceil(items.length / columns);
|
|
329
|
+
|
|
330
|
+
const scrollTop = signal(0);
|
|
331
|
+
|
|
332
|
+
const visibleRows = Math.ceil(containerHeight / itemHeight);
|
|
333
|
+
|
|
334
|
+
const visibleRange = computed(() => {
|
|
335
|
+
const scrollPos = scrollTop();
|
|
336
|
+
const startRow = Math.max(0, Math.floor(scrollPos / itemHeight) - overscan);
|
|
337
|
+
const endRow = Math.min(rows - 1, Math.ceil((scrollPos + containerHeight) / itemHeight) + overscan);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
startRow,
|
|
341
|
+
endRow,
|
|
342
|
+
startIndex: startRow * columns,
|
|
343
|
+
endIndex: Math.min(items.length - 1, (endRow + 1) * columns - 1)
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const handleScroll = (e: Event) => {
|
|
348
|
+
const target = e.target as HTMLElement;
|
|
349
|
+
scrollTop(target.scrollTop);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
return () => {
|
|
353
|
+
const { startRow, startIndex, endIndex } = visibleRange();
|
|
354
|
+
const visibleItems = items.slice(startIndex, endIndex + 1);
|
|
355
|
+
|
|
356
|
+
const topSpacerHeight = startRow * itemHeight;
|
|
357
|
+
const bottomSpacerHeight = (rows - startRow - Math.ceil(visibleItems.length / columns)) * itemHeight;
|
|
358
|
+
|
|
359
|
+
return h('div', {
|
|
360
|
+
class: className,
|
|
361
|
+
style: {
|
|
362
|
+
width: containerWidth + 'px',
|
|
363
|
+
height: containerHeight + 'px',
|
|
364
|
+
overflowY: 'auto',
|
|
365
|
+
position: 'relative'
|
|
366
|
+
},
|
|
367
|
+
onscroll: handleScroll
|
|
368
|
+
}, [
|
|
369
|
+
h('div', {
|
|
370
|
+
style: {
|
|
371
|
+
height: topSpacerHeight + 'px'
|
|
372
|
+
}
|
|
373
|
+
}),
|
|
374
|
+
|
|
375
|
+
h('div', {
|
|
376
|
+
style: {
|
|
377
|
+
display: 'flex',
|
|
378
|
+
flexWrap: 'wrap',
|
|
379
|
+
gap: gap + 'px'
|
|
380
|
+
}
|
|
381
|
+
}, [
|
|
382
|
+
...visibleItems.map((item, i) => {
|
|
383
|
+
const index = startIndex + i;
|
|
384
|
+
return h('div', {
|
|
385
|
+
key: index,
|
|
386
|
+
'data-index': index,
|
|
387
|
+
style: {
|
|
388
|
+
width: itemWidth + 'px',
|
|
389
|
+
height: itemHeight + 'px',
|
|
390
|
+
flexShrink: 0
|
|
391
|
+
}
|
|
392
|
+
}, [
|
|
393
|
+
renderItem(item, index)
|
|
394
|
+
]);
|
|
395
|
+
})
|
|
396
|
+
]),
|
|
397
|
+
|
|
398
|
+
h('div', {
|
|
399
|
+
style: {
|
|
400
|
+
height: Math.max(0, bottomSpacerHeight) + 'px'
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
]);
|
|
404
|
+
};
|
|
405
|
+
}
|