mtrl-addons 0.2.2 → 0.2.3

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.
Files changed (128) hide show
  1. package/{src/components/index.ts → dist/components/index.d.ts} +0 -2
  2. package/dist/components/vlist/config.d.ts +86 -0
  3. package/{src/components/vlist/constants.ts → dist/components/vlist/constants.d.ts} +10 -11
  4. package/dist/components/vlist/features/api.d.ts +7 -0
  5. package/{src/components/vlist/features/index.ts → dist/components/vlist/features/index.d.ts} +0 -2
  6. package/dist/components/vlist/features/selection.d.ts +6 -0
  7. package/dist/components/vlist/features/viewport.d.ts +9 -0
  8. package/dist/components/vlist/features.d.ts +31 -0
  9. package/{src/components/vlist/index.ts → dist/components/vlist/index.d.ts} +1 -10
  10. package/dist/components/vlist/types.d.ts +596 -0
  11. package/dist/components/vlist/vlist.d.ts +29 -0
  12. package/dist/core/compose/features/gestures/index.d.ts +86 -0
  13. package/dist/core/compose/features/gestures/longpress.d.ts +85 -0
  14. package/dist/core/compose/features/gestures/pan.d.ts +108 -0
  15. package/dist/core/compose/features/gestures/pinch.d.ts +111 -0
  16. package/dist/core/compose/features/gestures/rotate.d.ts +111 -0
  17. package/dist/core/compose/features/gestures/swipe.d.ts +149 -0
  18. package/dist/core/compose/features/gestures/tap.d.ts +79 -0
  19. package/{src/core/compose/features/index.ts → dist/core/compose/features/index.d.ts} +1 -2
  20. package/{src/core/compose/index.ts → dist/core/compose/index.d.ts} +2 -11
  21. package/{src/core/gestures/index.ts → dist/core/gestures/index.d.ts} +1 -20
  22. package/dist/core/gestures/longpress.d.ts +23 -0
  23. package/dist/core/gestures/manager.d.ts +14 -0
  24. package/dist/core/gestures/pan.d.ts +12 -0
  25. package/dist/core/gestures/pinch.d.ts +14 -0
  26. package/dist/core/gestures/rotate.d.ts +14 -0
  27. package/dist/core/gestures/swipe.d.ts +20 -0
  28. package/dist/core/gestures/tap.d.ts +12 -0
  29. package/dist/core/gestures/types.d.ts +320 -0
  30. package/dist/core/gestures/utils.d.ts +57 -0
  31. package/dist/core/index.d.ts +13 -0
  32. package/dist/core/layout/config.d.ts +33 -0
  33. package/dist/core/layout/index.d.ts +51 -0
  34. package/dist/core/layout/jsx.d.ts +65 -0
  35. package/dist/core/layout/schema.d.ts +112 -0
  36. package/dist/core/layout/types.d.ts +69 -0
  37. package/dist/core/viewport/constants.d.ts +105 -0
  38. package/dist/core/viewport/features/base.d.ts +14 -0
  39. package/dist/core/viewport/features/collection.d.ts +41 -0
  40. package/dist/core/viewport/features/events.d.ts +13 -0
  41. package/{src/core/viewport/features/index.ts → dist/core/viewport/features/index.d.ts} +0 -7
  42. package/dist/core/viewport/features/item-size.d.ts +30 -0
  43. package/dist/core/viewport/features/loading.d.ts +34 -0
  44. package/dist/core/viewport/features/momentum.d.ts +17 -0
  45. package/dist/core/viewport/features/performance.d.ts +53 -0
  46. package/dist/core/viewport/features/placeholders.d.ts +38 -0
  47. package/dist/core/viewport/features/rendering.d.ts +16 -0
  48. package/dist/core/viewport/features/scrollbar.d.ts +26 -0
  49. package/dist/core/viewport/features/scrolling.d.ts +16 -0
  50. package/dist/core/viewport/features/utils.d.ts +43 -0
  51. package/dist/core/viewport/features/virtual.d.ts +18 -0
  52. package/{src/core/viewport/index.ts → dist/core/viewport/index.d.ts} +1 -17
  53. package/dist/core/viewport/types.d.ts +96 -0
  54. package/dist/core/viewport/utils/speed-tracker.d.ts +22 -0
  55. package/dist/core/viewport/viewport.d.ts +11 -0
  56. package/{src/index.ts → dist/index.d.ts} +0 -4
  57. package/dist/index.js +5143 -0
  58. package/dist/index.mjs +5111 -0
  59. package/dist/styles.css +254 -0
  60. package/dist/styles.css.map +1 -0
  61. package/package.json +5 -1
  62. package/.cursorrules +0 -117
  63. package/AI.md +0 -39
  64. package/CLAUDE.md +0 -882
  65. package/build.js +0 -377
  66. package/scripts/analyze-orphaned-functions.ts +0 -387
  67. package/scripts/debug/vlist-selection.ts +0 -121
  68. package/src/components/vlist/config.ts +0 -323
  69. package/src/components/vlist/features/api.ts +0 -626
  70. package/src/components/vlist/features/selection.ts +0 -436
  71. package/src/components/vlist/features/viewport.ts +0 -59
  72. package/src/components/vlist/features.ts +0 -112
  73. package/src/components/vlist/types.ts +0 -723
  74. package/src/components/vlist/vlist.ts +0 -92
  75. package/src/core/compose/features/gestures/index.ts +0 -227
  76. package/src/core/compose/features/gestures/longpress.ts +0 -383
  77. package/src/core/compose/features/gestures/pan.ts +0 -424
  78. package/src/core/compose/features/gestures/pinch.ts +0 -475
  79. package/src/core/compose/features/gestures/rotate.ts +0 -485
  80. package/src/core/compose/features/gestures/swipe.ts +0 -492
  81. package/src/core/compose/features/gestures/tap.ts +0 -334
  82. package/src/core/gestures/longpress.ts +0 -68
  83. package/src/core/gestures/manager.ts +0 -418
  84. package/src/core/gestures/pan.ts +0 -48
  85. package/src/core/gestures/pinch.ts +0 -58
  86. package/src/core/gestures/rotate.ts +0 -58
  87. package/src/core/gestures/swipe.ts +0 -66
  88. package/src/core/gestures/tap.ts +0 -45
  89. package/src/core/gestures/types.ts +0 -387
  90. package/src/core/gestures/utils.ts +0 -128
  91. package/src/core/index.ts +0 -43
  92. package/src/core/layout/config.ts +0 -102
  93. package/src/core/layout/index.ts +0 -168
  94. package/src/core/layout/jsx.ts +0 -174
  95. package/src/core/layout/schema.ts +0 -1044
  96. package/src/core/layout/types.ts +0 -95
  97. package/src/core/viewport/constants.ts +0 -145
  98. package/src/core/viewport/features/base.ts +0 -73
  99. package/src/core/viewport/features/collection.ts +0 -1182
  100. package/src/core/viewport/features/events.ts +0 -130
  101. package/src/core/viewport/features/item-size.ts +0 -271
  102. package/src/core/viewport/features/loading.ts +0 -263
  103. package/src/core/viewport/features/momentum.ts +0 -269
  104. package/src/core/viewport/features/performance.ts +0 -161
  105. package/src/core/viewport/features/placeholders.ts +0 -335
  106. package/src/core/viewport/features/rendering.ts +0 -962
  107. package/src/core/viewport/features/scrollbar.ts +0 -434
  108. package/src/core/viewport/features/scrolling.ts +0 -634
  109. package/src/core/viewport/features/utils.ts +0 -94
  110. package/src/core/viewport/features/virtual.ts +0 -525
  111. package/src/core/viewport/types.ts +0 -133
  112. package/src/core/viewport/utils/speed-tracker.ts +0 -79
  113. package/src/core/viewport/viewport.ts +0 -265
  114. package/test/benchmarks/layout/advanced.test.ts +0 -656
  115. package/test/benchmarks/layout/comparison.test.ts +0 -519
  116. package/test/benchmarks/layout/performance-comparison.test.ts +0 -274
  117. package/test/benchmarks/layout/real-components.test.ts +0 -733
  118. package/test/benchmarks/layout/simple.test.ts +0 -321
  119. package/test/benchmarks/layout/stress.test.ts +0 -990
  120. package/test/collection/basic.test.ts +0 -304
  121. package/test/components/vlist-selection.test.ts +0 -240
  122. package/test/components/vlist.test.ts +0 -63
  123. package/test/core/collection/adapter.test.ts +0 -161
  124. package/test/core/collection/collection.test.ts +0 -394
  125. package/test/core/layout/layout.test.ts +0 -201
  126. package/test/utils/dom-helpers.ts +0 -275
  127. package/test/utils/performance-helpers.ts +0 -392
  128. package/tsconfig.json +0 -20
@@ -1,962 +0,0 @@
1
- /**
2
- * Rendering Feature - Item rendering and positioning for viewport
3
- * Handles DOM element creation, positioning, recycling, and updates
4
- */
5
-
6
- import { addClass, removeClass, hasClass } from "mtrl";
7
- import type { ViewportContext, ViewportComponent } from "../types";
8
- import { VIEWPORT_CONSTANTS } from "../constants";
9
- import {
10
- isPlaceholder,
11
- getViewportState,
12
- wrapInitialize,
13
- wrapDestroy,
14
- } from "./utils";
15
- import { createLayout } from "../../layout";
16
-
17
- export interface RenderingConfig {
18
- template?: (
19
- item: any,
20
- index: number,
21
- ) => string | HTMLElement | any[] | Record<string, any>;
22
- overscan?: number;
23
- measureItems?: boolean;
24
- enableRecycling?: boolean;
25
- maxPoolSize?: number;
26
- }
27
-
28
- interface ViewportState {
29
- scrollPosition: number;
30
- totalItems: number;
31
- itemSize: number;
32
- containerSize: number;
33
- virtualTotalSize: number;
34
- visibleRange: { start: number; end: number };
35
- itemsContainer: HTMLElement | null;
36
- }
37
-
38
- /**
39
- * Rendering feature for viewport
40
- */
41
- export const withRendering = (config: RenderingConfig = {}) => {
42
- return <T extends ViewportContext & ViewportComponent>(component: T): T => {
43
- const {
44
- template,
45
- overscan = 5,
46
- measureItems = false,
47
- enableRecycling = true,
48
- maxPoolSize = VIEWPORT_CONSTANTS.RENDERING.DEFAULT_MAX_POOL_SIZE,
49
- } = config;
50
-
51
- // State
52
- const renderedElements = new Map<number, HTMLElement>();
53
- const collectionItems: Record<number, any> = {};
54
- const elementPool: HTMLElement[] = [];
55
- const poolStats = { created: 0, recycled: 0, poolSize: 0, released: 0 };
56
-
57
- // Store layout results for proper cleanup (prevents memory leak)
58
- const layoutResults = new WeakMap<HTMLElement, { destroy: () => void }>();
59
-
60
- // Reusable template element for HTML string parsing (more efficient than div)
61
- const templateParser = document.createElement("template");
62
-
63
- let viewportState: ViewportState | null = null;
64
- let currentVisibleRange = { start: 0, end: 0 };
65
- let lastRenderTime = 0;
66
- let isRemovingItem = false;
67
-
68
- // Element pool management
69
- const getPooledElement = (): HTMLElement => {
70
- if (enableRecycling && elementPool.length > 0) {
71
- poolStats.recycled++;
72
- return elementPool.pop()!;
73
- }
74
- const element = document.createElement("div");
75
- element.className = "mtrl-viewport-item";
76
- poolStats.created++;
77
- return element;
78
- };
79
-
80
- const releaseElement = (element: HTMLElement): void => {
81
- poolStats.released++;
82
-
83
- // CRITICAL: Call destroy on layoutResult to clean up components and event listeners
84
- // This fixes the memory leak when using layout templates
85
- const layoutResult = layoutResults.get(element);
86
- if (layoutResult && typeof layoutResult.destroy === "function") {
87
- try {
88
- layoutResult.destroy();
89
- } catch (e) {
90
- // Ignore destroy errors - element may already be cleaned up
91
- }
92
- layoutResults.delete(element);
93
- }
94
-
95
- if (!enableRecycling) {
96
- element.remove();
97
- return;
98
- }
99
- // Clean element for reuse (no cloning - reuse the actual element)
100
- element.className = "mtrl-viewport-item";
101
- element.removeAttribute("data-index");
102
- element.style.cssText = "";
103
- element.innerHTML = "";
104
-
105
- // Remove from DOM first
106
- if (element.parentNode) {
107
- element.parentNode.removeChild(element);
108
- }
109
-
110
- // Add to pool if not full
111
- if (elementPool.length < maxPoolSize) {
112
- elementPool.push(element);
113
- poolStats.poolSize = elementPool.length;
114
- }
115
- // If pool is full, element is just dereferenced and GC'd
116
- };
117
-
118
- // Initialize
119
- wrapInitialize(component, () => {
120
- viewportState = getViewportState(component) as ViewportState;
121
-
122
- // Set initial items container height from current virtual size
123
- if (viewportState?.itemsContainer && viewportState.virtualTotalSize > 0) {
124
- viewportState.itemsContainer.style.height = `${viewportState.virtualTotalSize}px`;
125
- }
126
-
127
- // Listen for item update requests from API
128
- component.on?.("item:update-request", (data: any) => {
129
- const { index, item, previousItem } = data;
130
-
131
- // Update the item in collectionItems
132
- collectionItems[index] = item;
133
-
134
- // Check if this item is currently rendered
135
- const existingElement = renderedElements.get(index);
136
- const wasVisible = !!existingElement;
137
-
138
- // Check if item was selected before update
139
- const wasSelected = existingElement
140
- ? hasClass(
141
- existingElement,
142
- VIEWPORT_CONSTANTS.SELECTION.SELECTED_CLASS,
143
- ) || hasClass(existingElement, "mtrl-viewport-item--selected")
144
- : false;
145
-
146
- if (existingElement && existingElement.parentNode) {
147
- // Re-render the item
148
- const newElement = renderItem(item, index);
149
-
150
- if (newElement) {
151
- // Copy position styles from existing element
152
- Object.assign(newElement.style, {
153
- position: existingElement.style.position,
154
- transform: existingElement.style.transform,
155
- width: existingElement.style.width,
156
- });
157
-
158
- // Preserve selected state
159
- if (wasSelected) {
160
- addClass(newElement, VIEWPORT_CONSTANTS.SELECTION.SELECTED_CLASS);
161
- addClass(newElement, "mtrl-viewport-item--selected");
162
- }
163
-
164
- // Add update animation class to inner content for visibility
165
- addClass(newElement, "viewport-item--updated");
166
- // Also try to add to first child if it exists (the actual item content)
167
- const innerItem = newElement.firstElementChild as HTMLElement;
168
- if (innerItem) {
169
- addClass(innerItem, "item--updated");
170
- }
171
-
172
- // Replace in DOM
173
- existingElement.parentNode.replaceChild(
174
- newElement,
175
- existingElement,
176
- );
177
- renderedElements.set(index, newElement);
178
- releaseElement(existingElement);
179
-
180
- // Remove the animation class after transition
181
- setTimeout(() => {
182
- removeClass(newElement, "viewport-item--updated");
183
- if (innerItem) {
184
- removeClass(innerItem, "item--updated");
185
- }
186
- }, 500);
187
- }
188
- }
189
-
190
- // Emit completion event
191
- component.emit?.("item:updated", {
192
- item,
193
- index,
194
- previousItem,
195
- wasVisible,
196
- wasSelected,
197
- });
198
- });
199
-
200
- // Listen for item remove requests from API
201
- component.on?.("item:remove-request", (data: any) => {
202
- const { index, item } = data;
203
- isRemovingItem = true;
204
-
205
- // console.log(
206
- // `[RENDER-FIX] ========== ITEM REMOVE START (v4) ==========`,
207
- // );
208
- // console.log(`[RENDER-FIX] Removing index: ${index}`);
209
-
210
- // Get the source of truth - collection.items has already been spliced by api.ts
211
- const collectionItemsSource =
212
- (component as any).collection?.items ||
213
- (component as any).items ||
214
- [];
215
-
216
- // console.log(
217
- // `[RENDER-FIX] collectionItemsSource.length: ${collectionItemsSource.length}`,
218
- // );
219
- // console.log(
220
- // `[RENDER-FIX] collectionItems cache size BEFORE: ${Object.keys(collectionItems).length}`,
221
- // );
222
-
223
- // Strategy: Shift collectionItems cache indices down, but use actual data
224
- // from collection.items (which has been spliced) for the loaded range.
225
- // For indices beyond the loaded range, shift the cached data.
226
-
227
- const keys = Object.keys(collectionItems)
228
- .map(Number)
229
- .filter((k) => !isNaN(k))
230
- .sort((a, b) => a - b);
231
-
232
- // Delete the removed index
233
- delete collectionItems[index];
234
-
235
- // Shift all indices after the removed one down by 1
236
- // For indices within the loaded range, use data from collection.items
237
- // For indices beyond, shift the existing cached data
238
- const loadedLength = collectionItemsSource.length;
239
-
240
- for (const key of keys) {
241
- if (key > index) {
242
- const newIndex = key - 1;
243
- // If newIndex is within loaded data, use source; otherwise shift cache
244
- if (newIndex < loadedLength && collectionItemsSource[newIndex]) {
245
- collectionItems[newIndex] = collectionItemsSource[newIndex];
246
- } else {
247
- // Shift cached data for indices beyond loaded range
248
- collectionItems[newIndex] = collectionItems[key];
249
- }
250
- delete collectionItems[key];
251
- }
252
- }
253
-
254
- const finalKeys = Object.keys(collectionItems)
255
- .map(Number)
256
- .filter((k) => !isNaN(k))
257
- .sort((a, b) => a - b);
258
- // console.log(
259
- // `[RENDER-FIX] collectionItems cache size AFTER: ${finalKeys.length}`,
260
- // );
261
- // console.log(
262
- // `[RENDER-FIX] collectionItems keys: [${finalKeys.slice(0, 10).join(", ")}${finalKeys.length > 10 ? "..." : ""}]`,
263
- // );
264
- // console.log(
265
- // `[RENDER-FIX] viewportState.totalItems: ${viewportState?.totalItems}`,
266
- // );
267
- // console.log(
268
- // `[RENDER-FIX] viewportState.visibleRange: ${JSON.stringify(viewportState?.visibleRange)}`,
269
- // );
270
-
271
- // Check for any undefined/null values in first 30 items
272
- const badIndices: number[] = [];
273
- for (let i = 0; i < Math.min(30, finalKeys.length); i++) {
274
- const k = finalKeys[i];
275
- if (!collectionItems[k] || collectionItems[k]._placeholder) {
276
- badIndices.push(k);
277
- }
278
- }
279
-
280
- // Remove the rendered element at this index
281
- const existingElement = renderedElements.get(index);
282
- if (existingElement && existingElement.parentNode) {
283
- releaseElement(existingElement);
284
- }
285
- renderedElements.delete(index);
286
-
287
- // Shift rendered elements indices down
288
- const renderedKeys = Array.from(renderedElements.keys()).sort(
289
- (a, b) => a - b,
290
- );
291
- const newRenderedElements = new Map<number, HTMLElement>();
292
- for (const key of renderedKeys) {
293
- if (key > index) {
294
- const element = renderedElements.get(key);
295
- if (element) {
296
- // Update data-index attribute
297
- element.dataset.index = String(key - 1);
298
- newRenderedElements.set(key - 1, element);
299
- }
300
- } else if (key < index) {
301
- const element = renderedElements.get(key);
302
- if (element) {
303
- newRenderedElements.set(key, element);
304
- }
305
- }
306
- }
307
- renderedElements.clear();
308
- for (const [key, element] of newRenderedElements) {
309
- renderedElements.set(key, element);
310
- }
311
-
312
- // Decrement totalItems NOW so renderItems() uses the correct count
313
- // setTotalItems() will be called later by the API, which will emit
314
- // viewport:total-items-changed for virtual size recalculation
315
- if (viewportState && viewportState.totalItems > 0) {
316
- viewportState.totalItems--;
317
- }
318
-
319
- // Reset visible range to force re-render
320
- currentVisibleRange = { start: -1, end: -1 };
321
-
322
- // Emit completion event
323
- component.emit?.("item:removed", {
324
- item,
325
- index,
326
- });
327
-
328
- // After item removal, clear ALL loadedRanges and collectionItems cache
329
- // This forces a complete reload which is more reliable than trying to
330
- // track shifted indices. The items array has been spliced and all indices
331
- // are now different from what loadedRanges thinks they are.
332
- const collection = (component.viewport as any)?.collection;
333
- if (collection?.getLoadedRanges) {
334
- const loadedRanges = collection.getLoadedRanges();
335
- loadedRanges.clear();
336
- }
337
-
338
- // Clear the entire collectionItems cache - it's all stale after removal
339
- // Keep only the items we actually have in the source array
340
- const loadedItemsCount = collectionItemsSource.length;
341
- const cacheKeys = Object.keys(collectionItems)
342
- .map(Number)
343
- .filter((k) => !isNaN(k));
344
- cacheKeys.forEach((key) => {
345
- if (key >= loadedItemsCount) {
346
- delete collectionItems[key];
347
- }
348
- });
349
-
350
- // Trigger reload of visible range
351
- const visibleRange = component.viewport?.getVisibleRange?.();
352
- if (visibleRange && collection?.loadMissingRanges) {
353
- collection.loadMissingRanges(
354
- { start: 0, end: Math.max(visibleRange.end, loadedItemsCount) },
355
- "item-removal",
356
- );
357
- }
358
-
359
- // Trigger re-render
360
- renderItems();
361
- isRemovingItem = false;
362
- });
363
-
364
- // Listen for collection items evicted - clean up our cache too
365
- component.on?.("collection:items-evicted", (data: any) => {
366
- const { keepStart, keepEnd, evictedCount } = data;
367
- let cleanedCount = 0;
368
-
369
- // Remove evicted items from our cache
370
- for (const key in collectionItems) {
371
- const index = parseInt(key, 10);
372
- if (index < keepStart || index > keepEnd) {
373
- delete collectionItems[index];
374
- cleanedCount++;
375
- }
376
- }
377
- });
378
-
379
- // Listen for collection data loaded
380
- component.on?.("collection:range-loaded", (data: any) => {
381
- // console.log(
382
- // `[RENDER-FIX] collection:range-loaded - offset: ${data.offset}, items: ${data.items?.length}`,
383
- // );
384
- if (!data.items?.length) return;
385
-
386
- // Analyze data structure on first load
387
- const placeholders = (component as any).placeholders;
388
- if (placeholders && !placeholders.hasAnalyzedStructure()) {
389
- placeholders.analyzeDataStructure(data.items);
390
- }
391
-
392
- // Update collection items and replace placeholders
393
- // console.log(
394
- // `[RENDER-FIX] Updating collectionItems for indices ${data.offset} to ${data.offset + data.items.length - 1}`,
395
- // );
396
- let replacedCount = 0;
397
- let skippedCount = 0;
398
- data.items.forEach((item: any, i: number) => {
399
- const index = data.offset + i;
400
- const oldItem = collectionItems[index];
401
- collectionItems[index] = item;
402
-
403
- // Replace placeholder in DOM if needed
404
- const wasPlaceholder = oldItem && isPlaceholder(oldItem);
405
- const hasElement = renderedElements.has(index);
406
- if (wasPlaceholder && hasElement) {
407
- replacedCount++;
408
- const element = renderedElements.get(index);
409
- if (element) {
410
- const newElement = renderItem(item, index);
411
- if (newElement) {
412
- // Remove placeholder classes from wrapper
413
- removeClass(newElement, VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS);
414
- // Also remove from inner element (for string templates)
415
- if (newElement.firstElementChild) {
416
- removeClass(
417
- newElement.firstElementChild as HTMLElement,
418
- VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS,
419
- );
420
- }
421
-
422
- // Add replaced class for fade-in animation
423
- addClass(newElement, "viewport-item--replaced");
424
-
425
- // Copy position and replace
426
- Object.assign(newElement.style, {
427
- position: element.style.position,
428
- transform: element.style.transform,
429
- width: element.style.width,
430
- });
431
- element.parentNode?.replaceChild(newElement, element);
432
- renderedElements.set(index, newElement);
433
- releaseElement(element);
434
-
435
- // Remove the replaced class after animation completes
436
- setTimeout(() => {
437
- removeClass(newElement, "viewport-item--replaced");
438
- }, 300);
439
- } else {
440
- // renderItem returned null - still release the old element
441
- releaseElement(element);
442
- renderedElements.delete(index);
443
- }
444
- }
445
- } else if (wasPlaceholder && !hasElement) {
446
- skippedCount++;
447
- }
448
- });
449
- // if (replacedCount > 0 || skippedCount > 0) {
450
- // console.log(
451
- // `[RENDER-FIX] Placeholder replacement: ${replacedCount} replaced, ${skippedCount} skipped (not rendered)`,
452
- // );
453
- // }
454
-
455
- // Check if we need to render
456
- const { visibleRange } = viewportState || {};
457
- if (visibleRange) {
458
- const renderStart = Math.max(0, visibleRange.start - overscan);
459
- const renderEnd = Math.min(
460
- viewportState?.totalItems ?? 0 - 1,
461
- visibleRange.end + overscan,
462
- );
463
- const loadedStart = data.offset;
464
- const loadedEnd = data.offset + data.items.length - 1;
465
-
466
- // Check for placeholders in range
467
- const hasPlaceholdersInRange = Array.from(
468
- { length: Math.min(loadedEnd, renderEnd) - loadedStart + 1 },
469
- (_, i) => collectionItems[loadedStart + i],
470
- ).some((item) => item && isPlaceholder(item));
471
-
472
- const needsRender =
473
- (loadedStart <= renderEnd &&
474
- loadedEnd >= renderStart &&
475
- renderedElements.size < renderEnd - renderStart + 1) ||
476
- hasPlaceholdersInRange;
477
-
478
- if (needsRender) renderItems();
479
- }
480
- });
481
-
482
- // Listen for events
483
- component.on?.("viewport:range-changed", renderItems);
484
- component.on?.("viewport:scroll", updateItemPositions);
485
-
486
- // Update items container height when virtual size changes
487
- component.on?.("viewport:virtual-size-changed", (data: any) => {
488
- if (
489
- viewportState?.itemsContainer &&
490
- data.totalVirtualSize !== undefined
491
- ) {
492
- viewportState.itemsContainer.style.height = `${data.totalVirtualSize}px`;
493
- }
494
- });
495
- });
496
-
497
- // Template helpers
498
- const getDefaultTemplate = () => (item: any, index: number) => {
499
- // Use layout system for default template
500
- return [
501
- {
502
- tag: "div",
503
- class: "viewport-item",
504
- text:
505
- typeof item === "object"
506
- ? item.name || item.label || item.text || `Item ${index}`
507
- : String(item),
508
- },
509
- ];
510
- };
511
-
512
- // Process layout schema with item data substitution
513
- const processLayoutSchema = (
514
- schema: any,
515
- item: any,
516
- index: number,
517
- ): any => {
518
- if (typeof schema === "string") {
519
- // Handle variable substitution like {{name}}, {{index}}
520
- return schema.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
521
- if (key === "index") return String(index);
522
- if (key === "item") return String(item);
523
-
524
- // Handle nested properties like {{user.name}}
525
- const value = key.split(".").reduce((obj: any, prop: string) => {
526
- return obj?.[prop.trim()];
527
- }, item);
528
-
529
- return value !== undefined ? String(value) : match;
530
- });
531
- }
532
-
533
- if (Array.isArray(schema)) {
534
- return schema.map((child) => processLayoutSchema(child, item, index));
535
- }
536
-
537
- if (typeof schema === "object" && schema !== null) {
538
- const processed: any = {};
539
- for (const [key, value] of Object.entries(schema)) {
540
- processed[key] = processLayoutSchema(value, item, index);
541
- }
542
- return processed;
543
- }
544
-
545
- return schema;
546
- };
547
-
548
- // Position calculation
549
- const calculateItemPosition = (
550
- index: number,
551
- scrollPosition: number,
552
- totalItems: number,
553
- itemSize: number,
554
- virtualTotalSize: number,
555
- containerSize: number,
556
- targetScrollIndex?: number,
557
- ): number => {
558
- const actualTotalSize = totalItems * itemSize;
559
- const isCompressed =
560
- actualTotalSize > virtualTotalSize && virtualTotalSize > 0;
561
-
562
- // If we have a targetScrollIndex (from initialScrollIndex), use it directly
563
- // This ensures items are positioned correctly even with compression
564
- if (targetScrollIndex !== undefined && targetScrollIndex > 0) {
565
- // Position items relative to the target index
566
- // The target index should appear at the top of the viewport
567
- return (index - targetScrollIndex) * itemSize;
568
- }
569
-
570
- if (!isCompressed || totalItems === 0) {
571
- return index * itemSize - scrollPosition;
572
- }
573
-
574
- // Compressed space handling
575
- const maxScrollPosition = virtualTotalSize - containerSize;
576
- const distanceFromBottom = maxScrollPosition - scrollPosition;
577
- const nearBottomThreshold = containerSize;
578
-
579
- if (
580
- distanceFromBottom <= nearBottomThreshold &&
581
- distanceFromBottom >= -1
582
- ) {
583
- // Near bottom interpolation
584
- const itemsAtBottom = Math.floor(containerSize / itemSize);
585
- const firstVisibleAtBottom = Math.max(0, totalItems - itemsAtBottom);
586
- const scrollRatio = scrollPosition / virtualTotalSize;
587
- const exactScrollIndex = scrollRatio * totalItems;
588
- const interpolation = Math.max(
589
- 0,
590
- Math.min(1, 1 - distanceFromBottom / nearBottomThreshold),
591
- );
592
-
593
- const bottomPosition = (index - firstVisibleAtBottom) * itemSize;
594
- const normalPosition = (index - exactScrollIndex) * itemSize;
595
- return (
596
- normalPosition + (bottomPosition - normalPosition) * interpolation
597
- );
598
- }
599
-
600
- // Normal compressed scrolling
601
- const scrollRatio = scrollPosition / virtualTotalSize;
602
- return (index - scrollRatio * totalItems) * itemSize;
603
- };
604
-
605
- // Render single item
606
- const renderItem = (item: any, index: number): HTMLElement | null => {
607
- const itemTemplate = template || getDefaultTemplate();
608
-
609
- try {
610
- const result = itemTemplate(item, index);
611
- let element: HTMLElement;
612
-
613
- // Check if result is a layout schema (array or object)
614
- if (
615
- Array.isArray(result) ||
616
- (typeof result === "object" &&
617
- result !== null &&
618
- !(result instanceof HTMLElement))
619
- ) {
620
- // Process schema to substitute variables
621
- const processedSchema = processLayoutSchema(result, item, index);
622
-
623
- // Use layout system to create element
624
- const layoutResult = createLayout(processedSchema);
625
- element = layoutResult.element;
626
-
627
- // If the layout created a wrapper, use it directly
628
- if (element && element.nodeType === 1) {
629
- // Element is already created by layout system
630
- // Store layoutResult for cleanup when element is released (prevents memory leak)
631
- layoutResults.set(element, layoutResult);
632
- } else {
633
- // Fallback if layout didn't create a proper element
634
- element = getPooledElement();
635
- if (layoutResult.element) {
636
- element.appendChild(layoutResult.element);
637
- // Store layoutResult on wrapper element for cleanup
638
- layoutResults.set(element, layoutResult);
639
- }
640
- }
641
- } else if (typeof result === "string") {
642
- // Parse HTML using template element - optimized path
643
- // Move nodes directly instead of cloning - much faster, no memory overhead
644
- templateParser.innerHTML = result;
645
- const content = templateParser.content;
646
-
647
- if (content.children.length === 1) {
648
- // Single root element - move directly (faster than cloneNode)
649
- element = content.firstElementChild as HTMLElement;
650
- content.removeChild(element);
651
- addClass(element, "mtrl-viewport-item");
652
- if (isPlaceholder(item)) {
653
- addClass(element, VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS);
654
- }
655
- } else {
656
- // Multiple children - move all into pooled element
657
- element = getPooledElement();
658
- while (content.firstChild) {
659
- element.appendChild(content.firstChild);
660
- }
661
- }
662
- } else if (result instanceof HTMLElement) {
663
- element = getPooledElement();
664
- element.appendChild(result);
665
- } else {
666
- console.warn(`[Rendering] Invalid template result for item ${index}`);
667
- return null;
668
- }
669
-
670
- // Add classes
671
- if (!hasClass(element, "viewport-item"))
672
- addClass(element, "viewport-item");
673
- if (isPlaceholder(item)) {
674
- addClass(element, VIEWPORT_CONSTANTS.PLACEHOLDER.CLASS);
675
- }
676
-
677
- element.dataset.index = String(index);
678
- return element;
679
- } catch (error) {
680
- console.error(`[Rendering] Error rendering item ${index}:`, error);
681
- return null;
682
- }
683
- };
684
-
685
- // Update positions
686
- const updateItemPositions = (): void => {
687
- if (!viewportState || renderedElements.size === 0) return;
688
-
689
- const {
690
- scrollPosition,
691
- itemSize: itemSize,
692
- totalItems,
693
- virtualTotalSize,
694
- containerSize,
695
- } = viewportState;
696
- const actualTotalSize = totalItems * itemSize;
697
- const isCompressed =
698
- actualTotalSize > virtualTotalSize && virtualTotalSize > 0;
699
-
700
- const sortedIndices = Array.from(renderedElements.keys()).sort(
701
- (a, b) => a - b,
702
- );
703
- if (!sortedIndices.length) return;
704
-
705
- const firstIndex = sortedIndices[0];
706
- let currentPosition = 0;
707
-
708
- if (isCompressed) {
709
- const maxScroll = virtualTotalSize - containerSize;
710
- const distanceFromBottom = maxScroll - scrollPosition;
711
-
712
- if (distanceFromBottom <= containerSize && distanceFromBottom >= -1) {
713
- // Near bottom interpolation
714
- const itemsAtBottom = Math.floor(containerSize / itemSize);
715
- const firstVisibleAtBottom = Math.max(0, totalItems - itemsAtBottom);
716
- const scrollRatio = scrollPosition / virtualTotalSize;
717
- const exactScrollIndex = scrollRatio * totalItems;
718
- const interpolation = Math.max(
719
- 0,
720
- Math.min(1, 1 - distanceFromBottom / containerSize),
721
- );
722
-
723
- const bottomPos = (firstIndex - firstVisibleAtBottom) * itemSize;
724
- const normalPos = (firstIndex - exactScrollIndex) * itemSize;
725
- currentPosition = normalPos + (bottomPos - normalPos) * interpolation;
726
- } else {
727
- const scrollRatio = scrollPosition / virtualTotalSize;
728
- currentPosition = (firstIndex - scrollRatio * totalItems) * itemSize;
729
- }
730
- } else {
731
- currentPosition = firstIndex * itemSize - scrollPosition;
732
- }
733
-
734
- // Position each item
735
- sortedIndices.forEach((index) => {
736
- const element = renderedElements.get(index);
737
- if (element) {
738
- element.style.transform = `translateY(${Math.round(
739
- currentPosition,
740
- )}px)`;
741
-
742
- // Strategic log for last items
743
- // if (index >= totalItems - 5) {
744
- // console.log(
745
- // `[Rendering] Last item positioned: index=${index}, position=${Math.round(
746
- // currentPosition
747
- // )}px, itemSize=${itemSize}px, totalItems=${totalItems}`
748
- // );
749
- // }
750
-
751
- currentPosition += itemSize;
752
- }
753
- });
754
- };
755
-
756
- // Main render function
757
- const renderItems = () => {
758
- if (!viewportState?.itemsContainer) {
759
- return;
760
- }
761
-
762
- const {
763
- visibleRange,
764
- itemsContainer,
765
- totalItems,
766
- itemSize,
767
- scrollPosition,
768
- containerSize,
769
- virtualTotalSize,
770
- } = viewportState;
771
-
772
- // Validate range - skip rendering if no items or range is invalid
773
- if (
774
- !visibleRange ||
775
- totalItems <= 0 ||
776
- visibleRange.start < 0 ||
777
- visibleRange.start >= totalItems ||
778
- visibleRange.end < visibleRange.start ||
779
- isNaN(visibleRange.start) ||
780
- isNaN(visibleRange.end)
781
- ) {
782
- // If totalItems is 0, clear all rendered elements to remove stale placeholders
783
- if (totalItems <= 0 && renderedElements.size > 0) {
784
- Array.from(renderedElements.entries()).forEach(([index, element]) => {
785
- if (element.parentNode) releaseElement(element);
786
- renderedElements.delete(index);
787
- });
788
- currentVisibleRange = { start: -1, end: -1 };
789
- }
790
- return;
791
- }
792
-
793
- // Check if range changed
794
- if (
795
- visibleRange.start === currentVisibleRange.start &&
796
- visibleRange.end === currentVisibleRange.end &&
797
- renderedElements.size > 0
798
- ) {
799
- updateItemPositions();
800
- return;
801
- }
802
-
803
- lastRenderTime = Date.now();
804
- const renderStart = Math.max(0, visibleRange.start - overscan);
805
- const renderEnd = Math.min(totalItems - 1, visibleRange.end + overscan);
806
-
807
- // if (isRemovingItem) {
808
- // console.log(
809
- // `[RENDER-FIX] renderItems calc: visibleRange=${JSON.stringify(visibleRange)}, overscan=${overscan}, totalItems=${totalItems}`,
810
- // );
811
- // console.log(
812
- // `[RENDER-FIX] renderItems calc: renderStart=${renderStart}, renderEnd=${renderEnd}`,
813
- // );
814
- // }
815
-
816
- // Remove items outside range
817
- Array.from(renderedElements.entries())
818
- .filter(([index]) => index < renderStart || index > renderEnd)
819
- .forEach(([index, element]) => {
820
- if (element.parentNode) releaseElement(element);
821
- renderedElements.delete(index);
822
- });
823
-
824
- // Get items source
825
- const hasCollectionItems = Object.keys(collectionItems).length > 0;
826
- const items = hasCollectionItems
827
- ? collectionItems
828
- : component.items || [];
829
- const missingItems: number[] = [];
830
-
831
- // Render items in range
832
- for (let i = renderStart; i <= renderEnd; i++) {
833
- if (i < 0 || i >= totalItems || renderedElements.has(i)) continue;
834
-
835
- let item = items[i];
836
- if (!item) {
837
- // Only log during removal to avoid spam during scroll
838
-
839
- missingItems.push(i);
840
- // Generate placeholder
841
- const placeholders = (component as any).placeholders;
842
- item = placeholders?.generatePlaceholderItem(i) || {
843
- _placeholder: true,
844
- index: i,
845
- id: `placeholder-${i}`,
846
- name: VIEWPORT_CONSTANTS.PLACEHOLDER.MASK_CHARACTER.repeat(15),
847
- text: VIEWPORT_CONSTANTS.PLACEHOLDER.MASK_CHARACTER.repeat(25),
848
- description:
849
- VIEWPORT_CONSTANTS.PLACEHOLDER.MASK_CHARACTER.repeat(40),
850
- };
851
- collectionItems[i] = item;
852
- }
853
-
854
- const element = renderItem(item, i);
855
- if (element) {
856
- // Get targetScrollIndex from viewportState if available
857
- const targetScrollIndex = (viewportState as any)?.targetScrollIndex;
858
- const position = calculateItemPosition(
859
- i,
860
- scrollPosition,
861
- totalItems,
862
- itemSize,
863
- virtualTotalSize,
864
- containerSize,
865
- targetScrollIndex,
866
- );
867
-
868
- Object.assign(element.style, {
869
- position: "absolute",
870
- transform: `translateY(${position}px)`,
871
- width: "100%",
872
- });
873
-
874
- itemsContainer.appendChild(element);
875
- renderedElements.set(i, element);
876
- }
877
- }
878
-
879
- // Request missing items
880
- if (
881
- missingItems.length > 0 &&
882
- component.viewport?.collection?.loadMissingRanges
883
- ) {
884
- component.viewport.collection.loadMissingRanges(
885
- {
886
- start: Math.min(...missingItems),
887
- end: Math.max(...missingItems),
888
- },
889
- "rendering:missing-items",
890
- );
891
- }
892
-
893
- currentVisibleRange = visibleRange;
894
-
895
- // Log DOM stats periodically (every 10 renders)
896
- if (poolStats.created % 50 === 0 && poolStats.created > 0) {
897
- logDOMStats(`render:${poolStats.created}`);
898
- }
899
-
900
- // Emit items rendered event with elements for size calculation
901
- const renderedElementsArray = Array.from(renderedElements.values());
902
- component.emit?.("viewport:items-rendered", {
903
- elements: renderedElementsArray,
904
- range: visibleRange,
905
- });
906
-
907
- component.emit?.("viewport:rendered", {
908
- range: visibleRange,
909
- renderedCount: renderedElements.size,
910
- });
911
- updateItemPositions();
912
-
913
- // Load data if no items rendered
914
- if (
915
- renderedElements.size === 0 &&
916
- totalItems > 0 &&
917
- component.viewport?.collection
918
- ) {
919
- component.viewport.collection.loadMissingRanges(
920
- visibleRange,
921
- "rendering:no-items",
922
- );
923
- }
924
- };
925
-
926
- // Extend viewport API
927
- const originalRenderItems = component.viewport.renderItems;
928
- component.viewport.renderItems = () => {
929
- renderItems();
930
- originalRenderItems?.();
931
- };
932
-
933
- // Cleanup
934
- wrapDestroy(component, () => {
935
- // Release all rendered elements - use releaseElement to properly destroy layoutResults
936
- renderedElements.forEach((element) => {
937
- releaseElement(element);
938
- });
939
- renderedElements.clear();
940
-
941
- // Clear element pool
942
- elementPool.length = 0;
943
-
944
- // Clear collection items cache - this is critical for memory!
945
- for (const key in collectionItems) {
946
- delete collectionItems[key];
947
- }
948
-
949
- // Reset state
950
- currentVisibleRange = { start: -1, end: -1 };
951
- viewportState = null;
952
- lastRenderTime = 0;
953
-
954
- // Reset pool stats
955
- poolStats.created = 0;
956
- poolStats.recycled = 0;
957
- poolStats.poolSize = 0;
958
- });
959
-
960
- return component;
961
- };
962
- };