@quicktvui/web-renderer 1.0.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.
Files changed (56) hide show
  1. package/package.json +24 -0
  2. package/src/adapters/es3-video-player.js +828 -0
  3. package/src/components/Modal.js +119 -0
  4. package/src/components/QtAnimationView.js +678 -0
  5. package/src/components/QtBaseComponent.js +165 -0
  6. package/src/components/QtFastListView.js +1920 -0
  7. package/src/components/QtFlexView.js +799 -0
  8. package/src/components/QtImage.js +203 -0
  9. package/src/components/QtItemFrame.js +239 -0
  10. package/src/components/QtItemStoreView.js +93 -0
  11. package/src/components/QtItemView.js +125 -0
  12. package/src/components/QtListView.js +331 -0
  13. package/src/components/QtLoadingView.js +55 -0
  14. package/src/components/QtPageRootView.js +19 -0
  15. package/src/components/QtPlayMark.js +168 -0
  16. package/src/components/QtProgressBar.js +199 -0
  17. package/src/components/QtQRCode.js +78 -0
  18. package/src/components/QtReplaceChild.js +149 -0
  19. package/src/components/QtRippleView.js +166 -0
  20. package/src/components/QtSeekBar.js +409 -0
  21. package/src/components/QtText.js +679 -0
  22. package/src/components/QtTransitionImage.js +170 -0
  23. package/src/components/QtView.js +706 -0
  24. package/src/components/QtWebView.js +613 -0
  25. package/src/components/TabsView.js +420 -0
  26. package/src/components/ViewPager.js +206 -0
  27. package/src/components/index.js +24 -0
  28. package/src/components/plugins/TextV2Component.js +70 -0
  29. package/src/components/plugins/index.js +7 -0
  30. package/src/core/SceneBuilder.js +58 -0
  31. package/src/core/TVFocusManager.js +2014 -0
  32. package/src/core/asyncLocalStorage.js +175 -0
  33. package/src/core/autoProxy.js +165 -0
  34. package/src/core/componentRegistry.js +84 -0
  35. package/src/core/constants.js +6 -0
  36. package/src/core/index.js +8 -0
  37. package/src/core/moduleUtils.js +36 -0
  38. package/src/core/patches.js +958 -0
  39. package/src/core/templateBinding.js +666 -0
  40. package/src/index.js +246 -0
  41. package/src/modules/AndroidDevelopModule.js +101 -0
  42. package/src/modules/AndroidDeviceModule.js +341 -0
  43. package/src/modules/AndroidNetworkModule.js +178 -0
  44. package/src/modules/AndroidSharedPreferencesModule.js +100 -0
  45. package/src/modules/ESDeviceInfoModule.js +450 -0
  46. package/src/modules/ESGroupDataModule.js +195 -0
  47. package/src/modules/ESIJKAudioPlayerModule.js +477 -0
  48. package/src/modules/ESLocalStorageModule.js +100 -0
  49. package/src/modules/ESLogModule.js +65 -0
  50. package/src/modules/ESModule.js +106 -0
  51. package/src/modules/ESNetworkSpeedModule.js +117 -0
  52. package/src/modules/ESToastModule.js +172 -0
  53. package/src/modules/EsNativeModule.js +117 -0
  54. package/src/modules/FastListModule.js +101 -0
  55. package/src/modules/FocusModule.js +145 -0
  56. package/src/modules/RuntimeDeviceModule.js +176 -0
@@ -0,0 +1,1920 @@
1
+ // QtFastListView - A data-driven list component
2
+ // Children act as templates, data is bound via ${prop} syntax
3
+ import { QtBaseComponent } from './QtBaseComponent'
4
+ import { registerComponent } from '../core/componentRegistry'
5
+ import { applyDomOnlyTemplateRootDefaults, renderQtFlexViewInPlace } from './QtFlexView'
6
+ import {
7
+ bindTemplateDataToNode,
8
+ extractTemplateValue,
9
+ replaceTemplatePlaceholders,
10
+ syncDomAutoWidthIn,
11
+ } from '../core/templateBinding'
12
+ import {
13
+ registerFastListInstance,
14
+ unregisterFastListInstance,
15
+ getFadeConfig,
16
+ } from '../modules/FastListModule'
17
+
18
+ export class QtFastListView extends QtBaseComponent {
19
+ constructor(context, id, pId) {
20
+ super(context, id, pId)
21
+ this.tagName = 'FastListView'
22
+ this.dom = document.createElement('div')
23
+ this.dom.setAttribute('data-component-name', 'FastListView')
24
+ this._listData = []
25
+ this._templateChildren = [] // Regular templates (cloned for each data item)
26
+ this._singletonTemplates = [] // Singleton templates (used once, not cloned)
27
+ this._horizontal = false // Default: vertical layout
28
+ this._spanCount = 0 // Default: list layout (0 = list, > 0 = grid)
29
+ this._fadeConfig = getFadeConfig() // Initialize fade config
30
+ this._focusedIndex = -1
31
+ this._itemContainer = document.createElement('div')
32
+ this._itemContainer.className = 'fast-list-item-container'
33
+ this._itemContainer.style.cssText =
34
+ 'display: flex; flex-direction: column; align-content: flex-start;'
35
+ this.dom.appendChild(this._itemContainer)
36
+ this.dom.style.cssText = 'overflow: auto; display: block;'
37
+ // 添加实例引用,用于焦点导航
38
+ this.dom.__fastListViewInstance = this
39
+ this._itemContainer.__fastListViewInstance = this
40
+ this._templateCaptured = false
41
+
42
+ registerComponent(id, this)
43
+
44
+ // Register this instance for fade config updates
45
+ registerFastListInstance(this)
46
+
47
+ // Set up MutationObserver to watch for children added to DOM
48
+ this._observer = new MutationObserver((mutations) => {
49
+ mutations.forEach((mutation) => {
50
+ if (mutation.type === 'childList') {
51
+ mutation.addedNodes.forEach((node) => {
52
+ if (node !== this._itemContainer && node.nodeType === Node.ELEMENT_NODE) {
53
+ this._processTemplateNode(node)
54
+ }
55
+ })
56
+ }
57
+ })
58
+ })
59
+ this._observer.observe(this.dom, { childList: true, subtree: false })
60
+ }
61
+
62
+ /**
63
+ * Update fade configuration (called by FastListModule)
64
+ * @param {{enabled: boolean, duration: number}} config
65
+ */
66
+ updateFadeConfig(config) {
67
+ this._fadeConfig = { ...config }
68
+ }
69
+
70
+ defaultStyle() {
71
+ return {
72
+ display: 'block',
73
+ overflow: 'auto',
74
+ boxSizing: 'border-box',
75
+ }
76
+ }
77
+
78
+ _processTemplateNode(node) {
79
+ if (!node || node === this._itemContainer) return
80
+ if (node.nodeType !== Node.ELEMENT_NODE) return
81
+
82
+ const parseType = (element) => {
83
+ const raw = element.getAttribute('data-template-type') ?? element.getAttribute('type')
84
+ if (raw === null || raw === undefined || raw === '') return null
85
+ return isNaN(Number(raw)) ? raw : Number(raw)
86
+ }
87
+ const registerTemplate = (element, singleton = false) => {
88
+ const type = parseType(element)
89
+ if (singleton) {
90
+ if (!this._singletonTemplates.some((t) => t.dom === element)) {
91
+ this._singletonTemplates.push({
92
+ dom: element,
93
+ tagName: element.tagName,
94
+ component: null,
95
+ type,
96
+ })
97
+ element.setAttribute('data-singleton', 'true')
98
+ }
99
+ element.style.display = 'none'
100
+ return
101
+ }
102
+ if (!this._templateChildren.some((t) => t.dom === element)) {
103
+ this._templateChildren.push({
104
+ dom: element,
105
+ tagName: element.tagName,
106
+ component: null,
107
+ type,
108
+ })
109
+ element.setAttribute('data-template', 'true')
110
+ if (type !== null) {
111
+ element.setAttribute('data-template-type', String(type))
112
+ }
113
+ }
114
+ element.style.display = 'none'
115
+ }
116
+
117
+ const hasSingleton = node.hasAttribute && node.hasAttribute('singleton')
118
+ const type = parseType(node)
119
+
120
+ if (!hasSingleton && type === null && node.querySelectorAll) {
121
+ const nestedTemplates = Array.from(
122
+ node.querySelectorAll('[type], [data-template-type], [singleton]')
123
+ )
124
+ if (nestedTemplates.length > 0) {
125
+ node.style.display = 'none'
126
+ nestedTemplates.forEach((element) => {
127
+ registerTemplate(element, element.hasAttribute('singleton'))
128
+ })
129
+ return
130
+ }
131
+ }
132
+
133
+ if (hasSingleton) {
134
+ registerTemplate(node, true)
135
+ return
136
+ }
137
+
138
+ registerTemplate(node)
139
+ }
140
+
141
+ // Handle property updates
142
+ updateProperty(key, value) {
143
+ switch (key) {
144
+ case 'horizontal':
145
+ this._horizontal = value === true || value === 'true' || value === ''
146
+ if (this._horizontal) {
147
+ this.dom.setAttribute('horizontal', '')
148
+ } else {
149
+ this.dom.removeAttribute('horizontal')
150
+ }
151
+ this._updateLayoutDirection()
152
+ break
153
+ case 'spanCount':
154
+ case 'span-count':
155
+ this._spanCount = value > 0 ? value : 0
156
+ if (this._spanCount > 0) {
157
+ this.dom.setAttribute('span-count', String(this._spanCount))
158
+ } else {
159
+ this.dom.removeAttribute('span-count')
160
+ }
161
+ this._updateLayoutDirection()
162
+ break
163
+ case 'listData':
164
+ case 'list-data':
165
+ this.setListData(value)
166
+ break
167
+ case 'list':
168
+ if (Array.isArray(value)) {
169
+ this.setListData(value)
170
+ } else if (typeof value === 'string' && value.startsWith('${')) {
171
+ // Template variable - store for later resolution
172
+ this.dom.setAttribute('list', value)
173
+ this._pendingListValue = value
174
+ } else {
175
+ this.setListData(value)
176
+ }
177
+ break
178
+ default:
179
+ if (typeof value === 'string' && value.startsWith('${')) {
180
+ this.dom.setAttribute(key, value)
181
+ }
182
+ super.updateProperty(key, value)
183
+ }
184
+ }
185
+
186
+ // Update layout direction based on horizontal and spanCount properties
187
+ _updateLayoutDirection() {
188
+ if (this._spanCount > 0) {
189
+ // Grid layout: use CSS Grid
190
+ this._itemContainer.style.display = 'grid'
191
+ this._itemContainer.style.gridTemplateColumns = `repeat(${this._spanCount}, 1fr)`
192
+ this._itemContainer.style.flexDirection = ''
193
+ this._itemContainer.style.flexWrap = ''
194
+ this._itemContainer.style.overflowX = 'hidden'
195
+ this._itemContainer.style.overflowY = 'auto'
196
+ } else if (this._horizontal) {
197
+ // Horizontal list: row layout, no wrap
198
+ this._itemContainer.style.display = 'flex'
199
+ this._itemContainer.style.flexDirection = 'row'
200
+ this._itemContainer.style.flexWrap = 'nowrap'
201
+ this._itemContainer.style.gridTemplateColumns = ''
202
+ this._itemContainer.style.overflowX = 'auto'
203
+ this._itemContainer.style.overflowY = 'hidden'
204
+ } else {
205
+ // Vertical list: column layout
206
+ this._itemContainer.style.display = 'flex'
207
+ this._itemContainer.style.flexDirection = 'column'
208
+ this._itemContainer.style.flexWrap = 'nowrap'
209
+ this._itemContainer.style.gridTemplateColumns = ''
210
+ this._itemContainer.style.overflowX = 'hidden'
211
+ this._itemContainer.style.overflowY = 'auto'
212
+ }
213
+ }
214
+
215
+ // Helper: check if a view is singleton
216
+ _isSingleton(view) {
217
+ if (view.props && view.props.singleton) return true
218
+ if (view.dom && view.dom.hasAttribute && view.dom.hasAttribute('singleton')) return true
219
+ return false
220
+ }
221
+
222
+ // Override insertChild to capture template children
223
+ insertChild(view, index) {
224
+ if (view && view.dom) {
225
+ // Check if this view's parent is NOT this FastListView (meaning it should be nested)
226
+ // This happens when a child view has a pId that matches one of our templates
227
+ if (view.pId && view.pId !== this.id) {
228
+ const parentTemplate =
229
+ this._singletonTemplates.find((t) => t.component && t.component.id === view.pId) ||
230
+ this._templateChildren.find((t) => t.component && t.component.id === view.pId)
231
+ if (parentTemplate) {
232
+ if (parentTemplate.component && parentTemplate.component.insertChild) {
233
+ parentTemplate.component.insertChild(view, index)
234
+ }
235
+ return
236
+ }
237
+ }
238
+
239
+ // Check if this is a singleton template
240
+ if (this._isSingleton(view)) {
241
+ if (!this._singletonTemplates.some((t) => t.dom === view.dom)) {
242
+ // Extract type from the view's props or dom (same logic as regular templates)
243
+ let type = null
244
+ if (view.props && view.props.type !== undefined) {
245
+ type = view.props.type
246
+ } else if (view.dom.hasAttribute('type')) {
247
+ const typeAttr = view.dom.getAttribute('type')
248
+ type =
249
+ typeAttr !== null ? (isNaN(Number(typeAttr)) ? typeAttr : Number(typeAttr)) : null
250
+ }
251
+
252
+ this._singletonTemplates.push({
253
+ dom: view.dom,
254
+ tagName: view.tagName,
255
+ id: view.id,
256
+ component: view,
257
+ type: type,
258
+ })
259
+ view.dom.setAttribute('data-singleton', 'true')
260
+ }
261
+ view.dom.style.display = 'none'
262
+ // Don't add to templateChildren, and don't return
263
+ // Let it continue so child elements can be mounted properly
264
+ } else {
265
+ // Regular template (not singleton)
266
+ if (!this._templateChildren.some((t) => t.dom === view.dom)) {
267
+ // Extract type from the view's props or dom
268
+ let type = null
269
+ if (view.props && view.props.type !== undefined) {
270
+ type = view.props.type
271
+ } else if (view.dom.hasAttribute('type')) {
272
+ const typeAttr = view.dom.getAttribute('type')
273
+ type =
274
+ typeAttr !== null ? (isNaN(Number(typeAttr)) ? typeAttr : Number(typeAttr)) : null
275
+ }
276
+
277
+ const templateWrapper = {
278
+ dom: view.dom,
279
+ tagName: view.tagName,
280
+ id: view.id,
281
+ component: view,
282
+ type: type,
283
+ }
284
+ this._templateChildren.push(templateWrapper)
285
+ view.dom.setAttribute('data-template', 'true')
286
+ }
287
+ view.dom.style.display = 'none'
288
+ }
289
+ if (!this.dom.contains(view.dom)) {
290
+ if (this._itemContainer && this.dom.contains(this._itemContainer)) {
291
+ this.dom.insertBefore(view.dom, this._itemContainer)
292
+ } else {
293
+ this.dom.appendChild(view.dom)
294
+ }
295
+ }
296
+
297
+ if (this._listData.length > 0 && !this._rerenderScheduled) {
298
+ this._rerenderScheduled = true
299
+ requestAnimationFrame(() => {
300
+ this._rerenderScheduled = false
301
+ this._renderItems()
302
+ })
303
+ }
304
+ }
305
+ }
306
+
307
+ // Override beforeChildMount - called before a child is mounted
308
+ async beforeChildMount(child, childPosition) {
309
+ if (child && child.dom) {
310
+ // Check if this child's parent is NOT this FastListView
311
+ if (child.pId && child.pId !== this.id) {
312
+ const parentTemplate =
313
+ this._singletonTemplates.find((t) => t.component && t.component.id === child.pId) ||
314
+ this._templateChildren.find((t) => t.component && t.component.id === child.pId)
315
+ if (parentTemplate) {
316
+ if (parentTemplate.component && parentTemplate.component.beforeChildMount) {
317
+ await parentTemplate.component.beforeChildMount(child, childPosition)
318
+ }
319
+ await super.beforeChildMount(child, childPosition)
320
+ return
321
+ }
322
+ }
323
+
324
+ // Check if this is a singleton template
325
+ if (this._isSingleton(child)) {
326
+ if (!this._singletonTemplates.some((t) => t.dom === child.dom)) {
327
+ // Extract type from the child's props or dom (same logic as regular templates)
328
+ let type = null
329
+ if (child.props && child.props.type !== undefined) {
330
+ type = child.props.type
331
+ } else if (child.dom.hasAttribute('type')) {
332
+ const typeAttr = child.dom.getAttribute('type')
333
+ type =
334
+ typeAttr !== null ? (isNaN(Number(typeAttr)) ? typeAttr : Number(typeAttr)) : null
335
+ }
336
+
337
+ this._singletonTemplates.push({
338
+ dom: child.dom,
339
+ tagName: child.tagName,
340
+ id: child.id,
341
+ component: child,
342
+ type: type,
343
+ })
344
+ child.dom.setAttribute('data-singleton', 'true')
345
+ }
346
+ child.dom.style.display = 'none'
347
+ // Don't return - let child elements continue to be mounted
348
+ } else {
349
+ // Regular template (not singleton)
350
+ if (!this._templateChildren.some((t) => t.dom === child.dom)) {
351
+ // Extract type from the child's props or dom
352
+ let type = null
353
+ if (child.props && child.props.type !== undefined) {
354
+ type = child.props.type
355
+ } else if (child.dom.hasAttribute('type')) {
356
+ const typeAttr = child.dom.getAttribute('type')
357
+ type =
358
+ typeAttr !== null ? (isNaN(Number(typeAttr)) ? typeAttr : Number(typeAttr)) : null
359
+ }
360
+
361
+ const templateWrapper = {
362
+ dom: child.dom,
363
+ tagName: child.tagName,
364
+ id: child.id,
365
+ component: child,
366
+ type: type,
367
+ }
368
+ this._templateChildren.push(templateWrapper)
369
+ child.dom.setAttribute('data-template', 'true')
370
+ }
371
+ child.dom.style.display = 'none'
372
+ }
373
+ if (!this.dom.contains(child.dom)) {
374
+ if (this._itemContainer && this.dom.contains(this._itemContainer)) {
375
+ this.dom.insertBefore(child.dom, this._itemContainer)
376
+ } else {
377
+ this.dom.appendChild(child.dom)
378
+ }
379
+ }
380
+ }
381
+ await super.beforeChildMount(child, childPosition)
382
+ }
383
+
384
+ // Capture templates from DOM children
385
+ _captureTemplatesFromDOM() {
386
+ this._templateCaptured = true
387
+ }
388
+
389
+ // Set list data and render items
390
+ setListData(data) {
391
+ // Handle wrapped params - sometimes data comes as [dataArray]
392
+ if (Array.isArray(data) && data.length === 1 && Array.isArray(data[0])) {
393
+ data = data[0]
394
+ }
395
+
396
+ if (!Array.isArray(data)) {
397
+ return
398
+ }
399
+ this._listData = data
400
+
401
+ // Wait for templates to be ready, then render
402
+ const tryRender = (attempts = 0) => {
403
+ if (
404
+ this._templateChildren.length > 0 ||
405
+ this._singletonTemplates.length > 0 ||
406
+ attempts >= 10
407
+ ) {
408
+ this._renderItems()
409
+ } else {
410
+ setTimeout(() => tryRender(attempts + 1), 50)
411
+ }
412
+ }
413
+ setTimeout(() => tryRender(), 0)
414
+ }
415
+
416
+ // Render items based on current data
417
+ _renderItems() {
418
+ this._itemContainer.innerHTML = ''
419
+
420
+ // Hide regular templates (they will be cloned)
421
+ this._templateChildren.forEach((t) => {
422
+ if (t.dom) {
423
+ t.dom.style.display = 'none'
424
+ }
425
+ })
426
+
427
+ const renderedSingletonTypes = new Set()
428
+
429
+ // Process singleton templates - show directly, no cloning
430
+ // Match by type instead of index
431
+ this._singletonTemplates.forEach((template) => {
432
+ if (template.dom) {
433
+ // Find matching data by type
434
+ let matchingData = null
435
+ if (template.type !== null && template.type !== undefined) {
436
+ // Find data with matching type
437
+ matchingData = this._listData.find((item) => item.type === template.type)
438
+ }
439
+
440
+ // If no matching data found, hide this template (don't render it)
441
+ if (!matchingData) {
442
+ template.dom.style.display = 'none'
443
+ return
444
+ }
445
+
446
+ // Show the singleton
447
+ template.dom.style.display = ''
448
+
449
+ if (matchingData.type !== null && matchingData.type !== undefined) {
450
+ renderedSingletonTypes.add(matchingData.type)
451
+ } else if (template.type !== null && template.type !== undefined) {
452
+ renderedSingletonTypes.add(template.type)
453
+ }
454
+
455
+ // Apply style from matching data if available
456
+ if (matchingData.style) {
457
+ this._applyStyleToElement(template.dom, matchingData.style)
458
+ }
459
+
460
+ // Move to item container
461
+ this._itemContainer.appendChild(template.dom)
462
+ }
463
+ })
464
+
465
+ if (this._listData.length === 0) {
466
+ return
467
+ }
468
+
469
+ // Create items for each data entry (for regular templates)
470
+ this._listData.forEach((itemData, index) => {
471
+ if (renderedSingletonTypes.has(itemData.type)) return
472
+ const itemElement = this._createItemFromData(itemData, index)
473
+ if (itemElement) {
474
+ this._itemContainer.appendChild(itemElement)
475
+ }
476
+ })
477
+
478
+ // Initialize showOnState visibility for dynamically created elements
479
+ requestAnimationFrame(() => {
480
+ const focusManager = global.__TV_FOCUS_MANAGER__
481
+ if (focusManager && focusManager.initShowOnStateForElement) {
482
+ focusManager.initShowOnStateForElement(this._itemContainer)
483
+ }
484
+ })
485
+ }
486
+
487
+ // Helper: apply style object to element
488
+ _applyStyleToElement(element, styleObj) {
489
+ if (!styleObj || typeof styleObj !== 'object') return
490
+ Object.keys(styleObj).forEach((key) => {
491
+ let value = styleObj[key]
492
+ if (typeof value === 'number') {
493
+ const needsPx = [
494
+ 'width',
495
+ 'height',
496
+ 'minWidth',
497
+ 'minHeight',
498
+ 'maxWidth',
499
+ 'maxHeight',
500
+ 'padding',
501
+ 'paddingTop',
502
+ 'paddingRight',
503
+ 'paddingBottom',
504
+ 'paddingLeft',
505
+ 'margin',
506
+ 'marginTop',
507
+ 'marginRight',
508
+ 'marginBottom',
509
+ 'marginLeft',
510
+ 'top',
511
+ 'left',
512
+ 'right',
513
+ 'bottom',
514
+ 'borderRadius',
515
+ 'borderWidth',
516
+ ].includes(key)
517
+ if (needsPx) value = value + 'px'
518
+ }
519
+ element.style[key] = value
520
+ })
521
+ }
522
+
523
+ // Create an item element by cloning template and binding data
524
+ _createItemFromData(itemData, index) {
525
+ const item = document.createElement('div')
526
+ item.setAttribute('data-index', index)
527
+ item.setAttribute('data-position', index)
528
+
529
+ // Get the item's type
530
+ const itemType = itemData.type !== undefined ? itemData.type : null
531
+
532
+ // Find matching template by type
533
+ let matchingTemplate = this._templateChildren.find((t) => t.type === itemType)
534
+
535
+ const sharedTemplates = window.__QT_SHARED_TEMPLATES__
536
+ const sharedByType =
537
+ sharedTemplates && itemType !== null && itemType !== undefined
538
+ ? sharedTemplates.get(itemType)
539
+ : null
540
+
541
+ if (!matchingTemplate && sharedByType && sharedByType.dom) {
542
+ matchingTemplate = sharedByType
543
+ }
544
+
545
+ // If no matching template found, try to use template without type (default template)
546
+ if (!matchingTemplate) {
547
+ matchingTemplate = this._templateChildren.find((t) => t.type === null)
548
+ }
549
+
550
+ if (!matchingTemplate && sharedTemplates && sharedTemplates.size > 0) {
551
+ const firstShared = sharedTemplates.values().next().value
552
+ if (firstShared && firstShared.dom) {
553
+ matchingTemplate = firstShared
554
+ }
555
+ }
556
+
557
+ // If still no template, use the first template
558
+ if (!matchingTemplate && this._templateChildren.length > 0) {
559
+ matchingTemplate = this._templateChildren[0]
560
+ }
561
+
562
+ // Clone the matching template and bind data
563
+ if (matchingTemplate && matchingTemplate.dom) {
564
+ const clonedNode = matchingTemplate.dom.cloneNode(true)
565
+ clonedNode.removeAttribute('data-template')
566
+
567
+ // Reset position for display
568
+ clonedNode.style.position = 'relative'
569
+ clonedNode.style.left = 'auto'
570
+ clonedNode.style.top = 'auto'
571
+ clonedNode.style.display = ''
572
+
573
+ // Process component-level props if available
574
+ // console.log(
575
+ // '[QtFastListView] matchingTemplate:',
576
+ // matchingTemplate.tagName,
577
+ // 'has component:',
578
+ // !!matchingTemplate.component,
579
+ // 'has props:',
580
+ // !!matchingTemplate.component?.props
581
+ // )
582
+ if (matchingTemplate.component && matchingTemplate.component.props) {
583
+ // console.log('[QtFastListView] props keys:', Object.keys(matchingTemplate.component.props))
584
+ this._bindComponentPropsToNode(clonedNode, matchingTemplate.component.props, itemData, {
585
+ itemList: this._listData,
586
+ })
587
+ }
588
+
589
+ // Bind data to the cloned node, pass list data as additional scope
590
+ this._bindDataToNode(clonedNode, itemData, { itemList: this._listData })
591
+
592
+ item.appendChild(clonedNode)
593
+
594
+ // Process child list data after DOM is attached
595
+ requestAnimationFrame(() => {
596
+ this._processChildListData(item)
597
+ syncDomAutoWidthIn(item)
598
+ })
599
+ }
600
+
601
+ // If no templates, create a simple item
602
+ if (this._templateChildren.length === 0) {
603
+ const textContent = itemData.text || itemData.title || itemData.name || `Item ${index}`
604
+ const textSpan = document.createElement('span')
605
+ textSpan.textContent = textContent
606
+ textSpan.style.cssText =
607
+ 'color: white; font-size: 14px; padding: 5px; background: #333; margin: 5px; border-radius: 4px;'
608
+ item.appendChild(textSpan)
609
+ }
610
+
611
+ // Handle decoration (positioning)
612
+ if (itemData.decoration) {
613
+ const dec = itemData.decoration
614
+ if (dec.top !== undefined) item.style.marginTop = dec.top + 'px'
615
+ if (dec.left !== undefined) item.style.marginLeft = dec.left + 'px'
616
+ if (dec.right !== undefined) item.style.marginRight = dec.right + 'px'
617
+ if (dec.bottom !== undefined) item.style.marginBottom = dec.bottom + 'px'
618
+ }
619
+
620
+ item._itemData = itemData
621
+ item._itemPosition = index
622
+
623
+ item.addEventListener('click', (evt) => {
624
+ const nested = this._findNestedItemMeta(evt?.target, item)
625
+ if (
626
+ nested &&
627
+ nested.itemData !== itemData &&
628
+ itemData &&
629
+ typeof itemData === 'object' &&
630
+ Array.isArray(itemData.itemList)
631
+ ) {
632
+ this._dispatchItemClick(nested.position, nested.itemData, index)
633
+ return
634
+ }
635
+ this._dispatchItemClick(index, itemData)
636
+ })
637
+
638
+ // Apply fade-in animation if enabled
639
+ if (this._fadeConfig.enabled) {
640
+ item.style.opacity = '0'
641
+ item.style.transition = `opacity ${this._fadeConfig.duration}ms ease-in-out`
642
+ // Trigger animation after element is in DOM
643
+ requestAnimationFrame(() => {
644
+ requestAnimationFrame(() => {
645
+ item.style.opacity = '1'
646
+ })
647
+ })
648
+ }
649
+
650
+ return item
651
+ }
652
+
653
+ // Bind component-level props to a cloned node (handles flexStyle etc.)
654
+ _bindComponentPropsToNode(node, props, data, additionalScope = {}) {
655
+ if (!props || !data) return
656
+
657
+ const combinedData = { ...additionalScope, ...data }
658
+
659
+ Object.keys(props).forEach((key) => {
660
+ const value = props[key]
661
+
662
+ if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
663
+ let resolvedValue = extractTemplateValue(value, combinedData)
664
+ const keyLower = key.toLowerCase()
665
+
666
+ // Handle list/listData specially - try item data first, then additional scope
667
+ if (keyLower === 'list' || keyLower === 'listdata') {
668
+ console.log(
669
+ '[QtFastListView] Processing list prop, value:',
670
+ value,
671
+ 'resolvedValue from itemData:',
672
+ resolvedValue
673
+ )
674
+ if (resolvedValue === undefined || !Array.isArray(resolvedValue)) {
675
+ resolvedValue = extractTemplateValue(value, combinedData)
676
+ console.log('[QtFastListView] resolvedValue from combinedData:', resolvedValue)
677
+ }
678
+ if (Array.isArray(resolvedValue)) {
679
+ // Find the component registered for this node and call setListData directly
680
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
681
+ console.log(
682
+ '[QtFastListView] Looking for component, registry size:',
683
+ componentRegistry?.size
684
+ )
685
+ if (componentRegistry) {
686
+ const component = componentRegistry.get(node)
687
+ console.log('[QtFastListView] Found component by node:', component?.constructor?.name)
688
+ if (component && typeof component.setListData === 'function') {
689
+ console.log(
690
+ '[QtFastListView] Calling setListData on component with',
691
+ resolvedValue.length,
692
+ 'items'
693
+ )
694
+ component.setListData(resolvedValue)
695
+ } else {
696
+ // Fallback: store on node for later lookup
697
+ node._qtListData = resolvedValue
698
+ console.log('[QtFastListView] Set node._qtListData as fallback')
699
+ }
700
+ } else {
701
+ node._qtListData = resolvedValue
702
+ }
703
+ }
704
+ return // Don't process further
705
+ }
706
+
707
+ // Handle flexStyle specially
708
+ if (
709
+ (keyLower === 'flexstyle' || keyLower === 'flexstyletemplate') &&
710
+ resolvedValue &&
711
+ typeof resolvedValue === 'object'
712
+ ) {
713
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
714
+ const component = componentRegistry ? componentRegistry.get(node) : null
715
+ if (component && typeof component.updateProperty === 'function') {
716
+ component.updateProperty(key, resolvedValue)
717
+ } else {
718
+ this._applyFlexStyleToElement(node, resolvedValue)
719
+ }
720
+ }
721
+
722
+ // Handle textSize and fontSize specially
723
+ if ((keyLower === 'textsize' || keyLower === 'fontsize') && resolvedValue !== undefined) {
724
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
725
+ const component = componentRegistry ? componentRegistry.get(node) : null
726
+ if (component && typeof component.updateProperty === 'function') {
727
+ component.updateProperty(key, resolvedValue)
728
+ } else {
729
+ const fontSize =
730
+ typeof resolvedValue === 'number' ? resolvedValue + 'px' : resolvedValue
731
+ node.style.fontSize = fontSize
732
+ }
733
+ }
734
+
735
+ // Handle src attribute
736
+ if (keyLower === 'src') {
737
+ if (resolvedValue && typeof resolvedValue === 'object') {
738
+ if (resolvedValue.url !== undefined) {
739
+ resolvedValue = resolvedValue.url
740
+ } else if (resolvedValue.uri !== undefined) {
741
+ resolvedValue = resolvedValue.uri
742
+ } else if (resolvedValue.src !== undefined) {
743
+ resolvedValue = resolvedValue.src
744
+ }
745
+ }
746
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
747
+ const component = componentRegistry ? componentRegistry.get(node) : null
748
+ if (component && typeof component.updateProperty === 'function') {
749
+ component.updateProperty(key, resolvedValue)
750
+ } else if (node.tagName === 'IMG' && typeof resolvedValue === 'string') {
751
+ node.src = resolvedValue
752
+ }
753
+ }
754
+ }
755
+ })
756
+ }
757
+
758
+ // Bind data to a node: replace ${prop} in text and attributes
759
+ _bindDataToNode(node, data, additionalScope = {}) {
760
+ bindTemplateDataToNode(node, data, {
761
+ scope: additionalScope,
762
+ onListData: (element, listValue) => {
763
+ element._qtListData = listValue
764
+ element.setAttribute('data-has-list', 'true')
765
+ },
766
+ })
767
+ }
768
+
769
+ // Replace ${prop} placeholders with actual values
770
+ _replacePlaceholders(str, data) {
771
+ return replaceTemplatePlaceholders(str, data)
772
+ }
773
+
774
+ // Process child components that have list data
775
+ _processChildListData(container) {
776
+ // Find child nodes with _qtListData property
777
+ const processNode = (element) => {
778
+ // console.log(
779
+ // '[QtFastListView] _processChildListData checking element:',
780
+ // element.tagName,
781
+ // 'has _qtListData:',
782
+ // !!element._qtListData
783
+ // )
784
+ if (element._qtListData) {
785
+ // Find the component for this element
786
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
787
+ // console.log(
788
+ // '[QtFastListView] Found _qtListData:',
789
+ // element._qtListData.length,
790
+ // 'items, componentRegistry size:',
791
+ // componentRegistry?.size
792
+ // )
793
+ if (componentRegistry) {
794
+ const component = componentRegistry.get(element)
795
+ // console.log('[QtFastListView] Found component:', component?.constructor?.name)
796
+ if (component && typeof component.setListData === 'function') {
797
+ component.setListData(element._qtListData)
798
+ } else {
799
+ // DOM-only fallback: when templates are cloned as DOM, nested components are not instantiated.
800
+ // Handle tv-flex/FastFlexView by rendering in-place from its hidden templates.
801
+ const componentName = element.getAttribute?.('data-component-name')
802
+ const hasListTemplates = !!element.querySelector?.(
803
+ '[type], [data-template-type], [data-template], [data-singleton]'
804
+ )
805
+ if (componentName === 'QtFlexView') {
806
+ renderQtFlexViewInPlace(element, element._qtListData)
807
+ } else if (
808
+ componentName === 'FastListView' ||
809
+ componentName === 'ListView' ||
810
+ (element.hasAttribute?.('data-has-list') && hasListTemplates)
811
+ ) {
812
+ renderQtFastListViewInPlace(element, element._qtListData)
813
+ }
814
+ }
815
+ }
816
+ delete element._qtListData
817
+ }
818
+ // Recursively process children
819
+ if (element.children) {
820
+ Array.from(element.children).forEach((child) => processNode(child))
821
+ }
822
+ }
823
+ processNode(container)
824
+ }
825
+
826
+ // Dispatch item click event
827
+ _findNestedItemMeta(target, stopElement) {
828
+ let el = target
829
+ if (el && el.nodeType === Node.TEXT_NODE) {
830
+ el = el.parentElement
831
+ }
832
+ while (el && el !== stopElement) {
833
+ if (el._itemData !== undefined && el._itemPosition !== undefined) {
834
+ return {
835
+ position: el._itemPosition,
836
+ itemData: el._itemData,
837
+ }
838
+ }
839
+ el = el.parentElement
840
+ }
841
+ return null
842
+ }
843
+
844
+ _dispatchItemClick(position, itemData, parentPosition) {
845
+ const payload = {
846
+ position: position,
847
+ index: position,
848
+ item: itemData,
849
+ }
850
+ if (parentPosition !== undefined && parentPosition !== null) {
851
+ payload.parentPosition = parentPosition
852
+ }
853
+ this.dispatchEvent('onItemclick', payload)
854
+ }
855
+
856
+ _dispatchItemFocused(position, itemData, hasFocus) {
857
+ const payload = {
858
+ position: position,
859
+ index: position,
860
+ item: itemData,
861
+ hasFocus: !!hasFocus,
862
+ isFocused: !!hasFocus,
863
+ }
864
+ this.dispatchEvent('onItemfocused', payload)
865
+ }
866
+
867
+ notifyItemFocusChanged(currentElement, hasFocus) {
868
+ if (!currentElement || !this._itemContainer) return
869
+ const items = this._itemContainer.children
870
+ const itemCount = items.length
871
+ if (itemCount === 0) return
872
+
873
+ let currentIndex = -1
874
+ let parentItem = null
875
+ for (let i = 0; i < itemCount; i++) {
876
+ if (items[i] === currentElement || items[i].contains(currentElement)) {
877
+ currentIndex = i
878
+ parentItem = items[i]
879
+ break
880
+ }
881
+ }
882
+ if (currentIndex === -1) return
883
+
884
+ const itemData = this._listData[currentIndex]
885
+
886
+ // Check if focus is on a nested item (waterfall -> section -> item)
887
+ if (
888
+ parentItem &&
889
+ itemData &&
890
+ typeof itemData === 'object' &&
891
+ Array.isArray(itemData.itemList)
892
+ ) {
893
+ const nested = this._findNestedItemMeta(currentElement, parentItem)
894
+ if (nested && nested.itemData) {
895
+ // Dispatch focus event for nested item with parent position
896
+ const payload = {
897
+ position: nested.position,
898
+ index: nested.position,
899
+ item: nested.itemData,
900
+ hasFocus: !!hasFocus,
901
+ isFocused: !!hasFocus,
902
+ parentPosition: currentIndex,
903
+ }
904
+ this.dispatchEvent('onItemfocused', payload)
905
+ return
906
+ }
907
+ }
908
+
909
+ if (hasFocus) {
910
+ if (this._focusedIndex === currentIndex) return
911
+ this._focusedIndex = currentIndex
912
+ } else {
913
+ if (this._focusedIndex !== currentIndex) return
914
+ this._focusedIndex = -1
915
+ }
916
+
917
+ this._dispatchItemFocused(currentIndex, itemData, hasFocus)
918
+ }
919
+
920
+ // Add more items to the list
921
+ addListData(data) {
922
+ if (!Array.isArray(data)) return
923
+ const startIndex = this._listData.length
924
+ this._listData = this._listData.concat(data)
925
+ data.forEach((itemData, i) => {
926
+ const index = startIndex + i
927
+ const itemElement = this._createItemFromData(itemData, index)
928
+ if (itemElement) {
929
+ this._itemContainer.appendChild(itemElement)
930
+ }
931
+ })
932
+ }
933
+
934
+ // Update a single item
935
+ updateItem(position, data) {
936
+ if (position < 0 || position >= this._listData.length) return
937
+ this._listData[position] = { ...this._listData[position], ...data }
938
+
939
+ const existingItem = this._itemContainer.children[position]
940
+ if (existingItem) {
941
+ const newItem = this._createItemFromData(this._listData[position], position)
942
+ this._itemContainer.replaceChild(newItem, existingItem)
943
+ }
944
+ }
945
+
946
+ // Delete items
947
+ deleteItem(position, count = 1) {
948
+ this._listData.splice(position, count)
949
+ for (let i = 0; i < count; i++) {
950
+ const item = this._itemContainer.children[position]
951
+ if (item) {
952
+ this._itemContainer.removeChild(item)
953
+ }
954
+ }
955
+ Array.from(this._itemContainer.children).forEach((item, idx) => {
956
+ item.setAttribute('data-index', idx)
957
+ item.setAttribute('data-position', idx)
958
+ item._itemPosition = idx
959
+ })
960
+ }
961
+
962
+ // Clear all data
963
+ clearData() {
964
+ this._listData = []
965
+ this._itemContainer.innerHTML = ''
966
+ }
967
+
968
+ // Scroll to position
969
+ scrollToPosition(position, offset) {
970
+ const item = this._itemContainer.children[position]
971
+ if (item) {
972
+ item.scrollIntoView({ behavior: 'smooth', block: 'start' })
973
+ }
974
+ }
975
+
976
+ // Request focus on item
977
+ requestChildFocus(position) {
978
+ const item = this._itemContainer.children[position]
979
+ if (item) {
980
+ item.focus()
981
+ }
982
+ }
983
+
984
+ // Set span count for grid layout
985
+ setSpanCount(count) {
986
+ if (count > 0) {
987
+ this._itemContainer.style.setProperty('--span-count', count)
988
+ }
989
+ }
990
+
991
+ // Cleanup when component is destroyed
992
+ destroy() {
993
+ // Unregister from FastListModule updates
994
+ unregisterFastListInstance(this)
995
+ // Call parent destroy if exists
996
+ if (super.destroy) {
997
+ super.destroy()
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * 处理焦点导航 - 根据布局类型计算下一个焦点元素
1003
+ * @param {HTMLElement} currentElement 当前焦点元素
1004
+ * @param {string} direction 方向: 'up' | 'down' | 'left' | 'right'
1005
+ * @returns {HTMLElement|null} 下一个焦点元素,如果没有则返回 null
1006
+ */
1007
+ handleFocusNavigation(currentElement, direction) {
1008
+ console.log('[QtFastListView] handleFocusNavigation called:', { direction, currentElement })
1009
+
1010
+ const items = this._itemContainer.children
1011
+ const itemCount = items.length
1012
+ console.log('[QtFastListView] items:', { itemCount })
1013
+
1014
+ if (itemCount === 0) return null
1015
+
1016
+ // 找到当前焦点元素所在的 item
1017
+ let currentItem = null
1018
+ let currentIndex = -1
1019
+ for (let i = 0; i < itemCount; i++) {
1020
+ if (items[i] === currentElement || items[i].contains(currentElement)) {
1021
+ currentItem = items[i]
1022
+ currentIndex = i
1023
+ break
1024
+ }
1025
+ }
1026
+
1027
+ console.log('[QtFastListView] currentIndex:', currentIndex)
1028
+
1029
+ if (currentIndex === -1) return null
1030
+
1031
+ // 尝试在当前 item 内部进行焦点导航
1032
+ const internalNext = this._handleInternalNavigation(currentItem, currentElement, direction)
1033
+ console.log('[QtFastListView] internalNext:', !!internalNext)
1034
+
1035
+ if (internalNext) {
1036
+ return internalNext
1037
+ }
1038
+
1039
+ // 内部导航失败,检查是否在数据边界
1040
+ // 在第一个 item 且向上/左移动,滚动到头部
1041
+ if (currentIndex === 0 && (direction === 'up' || direction === 'left')) {
1042
+ console.log('[QtFastListView] 在第一个 item,滚动到头部')
1043
+ this._scrollToBoundary(direction, currentElement)
1044
+ }
1045
+ // 在最后一个 item 且向下/右移动,滚动到尾部
1046
+ else if (currentIndex === itemCount - 1 && (direction === 'down' || direction === 'right')) {
1047
+ console.log('[QtFastListView] 在最后一个 item,滚动到尾部')
1048
+ this._scrollToBoundary(direction, currentElement)
1049
+ }
1050
+
1051
+ // 尝试导航到其他 item
1052
+ let nextElement = null
1053
+ let nextIndex = -1
1054
+
1055
+ // 默认使用空间距离导航,支持瀑布流和复杂布局
1056
+ const result = this._handleSpatialNavigation(currentElement, direction, items, itemCount)
1057
+ if (result.element) {
1058
+ nextElement = result.element
1059
+ nextIndex = result.index
1060
+ } else {
1061
+ // 空间导航没找到时,作为后备回退到网格/列表基础逻辑
1062
+ if (this._spanCount > 0) {
1063
+ const fallback = this._handleGridNavigation(currentIndex, direction, items, itemCount)
1064
+ nextElement = fallback.element
1065
+ nextIndex = fallback.index
1066
+ } else {
1067
+ const fallback = this._handleListNavigation(currentIndex, direction, items, itemCount)
1068
+ nextElement = fallback.element
1069
+ nextIndex = fallback.index
1070
+ }
1071
+ }
1072
+
1073
+ console.log('[QtFastListView] navigation result:', { nextIndex, hasElement: !!nextElement })
1074
+
1075
+ return nextElement
1076
+ }
1077
+
1078
+ updateBlockFocusDirections(currentElement) {
1079
+ if (!currentElement || !this._itemContainer) return
1080
+
1081
+ if (
1082
+ currentElement.hasAttribute &&
1083
+ (currentElement.hasAttribute('blockFocusDirections') ||
1084
+ currentElement.hasAttribute('blockfocusdirections'))
1085
+ ) {
1086
+ return
1087
+ }
1088
+
1089
+ const items = this._itemContainer.children
1090
+ const itemCount = items.length
1091
+ if (itemCount === 0) return
1092
+
1093
+ let currentIndex = -1
1094
+ for (let i = 0; i < itemCount; i++) {
1095
+ if (items[i] === currentElement || items[i].contains(currentElement)) {
1096
+ currentIndex = i
1097
+ break
1098
+ }
1099
+ }
1100
+ if (currentIndex === -1) return
1101
+
1102
+ // 默认不应该阻止焦点移出列表,这会导致无法导航到 Tabs 等外部组件
1103
+ // 只有在组件自身设置了 blockFocusDirections 时才生效
1104
+ // const blocked = []
1105
+ // if (this._horizontal) {
1106
+ // if (currentIndex === 0) blocked.push('left')
1107
+ // if (currentIndex === itemCount - 1) blocked.push('right')
1108
+ // } else {
1109
+ // if (currentIndex === 0) blocked.push('up')
1110
+ // if (currentIndex === itemCount - 1) blocked.push('down')
1111
+ // }
1112
+
1113
+ // if (blocked.length > 0) {
1114
+ // currentElement.setAttribute('blockfocusdirections', JSON.stringify(blocked))
1115
+ // } else {
1116
+ // currentElement.removeAttribute('blockfocusdirections')
1117
+ // currentElement.removeAttribute('blockFocusDirections')
1118
+ // }
1119
+ }
1120
+
1121
+ /**
1122
+ * 滚动到列表边界(头部或尾部)
1123
+ * @param {string} direction - 'left', 'right', 'up', 'down'
1124
+ * @param {HTMLElement} currentElement - 当前焦点的元素
1125
+ */
1126
+ _scrollToBoundary(direction, currentElement) {
1127
+ if (!this.dom) return
1128
+
1129
+ // 为了实现嵌套列表(如 Waterfall -> Section -> Item)的精确边界滚动
1130
+ // 我们需要判断当前元素是否“真的”在这个方向的逻辑边缘上
1131
+ // 如果它不是在边缘,说明只是局部移动被拦截,不应该触发整个外层列表的回滚
1132
+
1133
+ // 获取当前列表所有直接的项
1134
+ const items = this._itemContainer ? Array.from(this._itemContainer.children) : []
1135
+ if (items.length === 0 || !currentElement) return
1136
+
1137
+ // 找到当前焦点元素所属的顶层项 (item)
1138
+ let currentItem = null
1139
+ let currentIndex = -1
1140
+ for (let i = 0; i < items.length; i++) {
1141
+ if (items[i] === currentElement || items[i].contains(currentElement)) {
1142
+ currentItem = items[i]
1143
+ currentIndex = i
1144
+ break
1145
+ }
1146
+ }
1147
+
1148
+ if (currentIndex === -1) return
1149
+
1150
+ // 获取所有可见项的坐标,用于判断是否是第一行/最后一行
1151
+ const itemRects = items
1152
+ .map((item) => ({
1153
+ el: item,
1154
+ rect: item.getBoundingClientRect(),
1155
+ }))
1156
+ .filter((info) => info.rect.width > 0 && info.rect.height > 0)
1157
+
1158
+ if (itemRects.length === 0) return
1159
+
1160
+ const currentRect = currentItem.getBoundingClientRect()
1161
+
1162
+ // 判断是否处于该方向的“最前排”
1163
+ let isAtBoundary = false
1164
+ const tolerance = 20 // 20px的行高容差
1165
+
1166
+ if (this._horizontal) {
1167
+ if (direction === 'left') {
1168
+ // 判断是否是最左边的一列(没有比它明显更靠左的元素)
1169
+ const minLeft = Math.min(...itemRects.map((i) => i.rect.left))
1170
+ isAtBoundary = currentRect.left <= minLeft + tolerance
1171
+ if (isAtBoundary) {
1172
+ // 这里必须直接设置 scrollTop/scrollLeft 为 0,而不是调用 scrollTo,
1173
+ // 因为 scrollTo 可能受到 CSS scroll-behavior 或其他库的干扰,导致滚动不到位。
1174
+ // 对于“回滚到边缘”的操作,直接重置是最稳妥的。
1175
+ this.dom.scrollLeft = 0
1176
+ }
1177
+ } else if (direction === 'right') {
1178
+ // 判断是否是最右边的一列
1179
+ const maxRight = Math.max(...itemRects.map((i) => i.rect.right))
1180
+ isAtBoundary = currentRect.right >= maxRight - tolerance
1181
+ if (isAtBoundary) {
1182
+ this.dom.scrollLeft = this.dom.scrollWidth
1183
+ }
1184
+ }
1185
+ } else {
1186
+ if (direction === 'up') {
1187
+ // 判断是否是最高的一行(第一行)
1188
+ const minTop = Math.min(...itemRects.map((i) => i.rect.top))
1189
+ isAtBoundary = currentRect.top <= minTop + tolerance
1190
+ if (isAtBoundary) {
1191
+ // console.log(`[QtFastListView] Scroll to top triggered for ${this.dom.id}`);
1192
+ this.dom.scrollTop = 0
1193
+ // 如果外层还有滚动容器(比如页面本身的 body),也尝试重置一下,防止因为局部滚动导致的视差
1194
+ if (this.dom.parentElement && this.dom.parentElement.scrollTop > 0) {
1195
+ this.dom.parentElement.scrollTop = 0
1196
+ }
1197
+ }
1198
+ } else if (direction === 'down') {
1199
+ // 判断是否是最底的一行(最后一行)
1200
+ const maxBottom = Math.max(...itemRects.map((i) => i.rect.bottom))
1201
+ isAtBoundary = currentRect.bottom >= maxBottom - tolerance
1202
+ if (isAtBoundary) {
1203
+ this.dom.scrollTop = this.dom.scrollHeight
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * 处理 item 内部的焦点导航
1211
+ * 当 item 内部有多个可聚焦元素时,先在内部导航
1212
+ */
1213
+ _handleInternalNavigation(item, currentElement, direction) {
1214
+ // 查找 item 内所有可聚焦元素
1215
+ const focusables = item.querySelectorAll('[focusable="true"]')
1216
+ if (focusables.length <= 1) {
1217
+ return null // 只有一个或没有可聚焦元素,无法内部导航
1218
+ }
1219
+
1220
+ // 找到当前焦点在可聚焦元素列表中的位置
1221
+ let currentFocusIndex = -1
1222
+ for (let i = 0; i < focusables.length; i++) {
1223
+ if (focusables[i] === currentElement || focusables[i].contains(currentElement)) {
1224
+ currentFocusIndex = i
1225
+ break
1226
+ }
1227
+ }
1228
+
1229
+ if (currentFocusIndex === -1) {
1230
+ return null
1231
+ }
1232
+
1233
+ // 根据方向计算下一个焦点
1234
+ // 获取可聚焦元素的布局信息
1235
+ const focusableRects = Array.from(focusables).map((el) => ({
1236
+ el,
1237
+ rect: el.getBoundingClientRect(),
1238
+ }))
1239
+
1240
+ const currentRect = focusableRects[currentFocusIndex].rect
1241
+ let nextElement = null
1242
+ let minDistance = Infinity
1243
+
1244
+ focusableRects.forEach((item, index) => {
1245
+ if (index === currentFocusIndex) return
1246
+
1247
+ const rect = item.rect
1248
+ let distance = Infinity
1249
+ let isValidDirection = false
1250
+
1251
+ switch (direction) {
1252
+ case 'up':
1253
+ // 目标元素在当前元素上方
1254
+ if (rect.bottom <= currentRect.top) {
1255
+ distance = Math.abs(rect.left - currentRect.left) + (currentRect.top - rect.bottom)
1256
+ isValidDirection = true
1257
+ }
1258
+ break
1259
+ case 'down':
1260
+ // 目标元素在当前元素下方
1261
+ if (rect.top >= currentRect.bottom) {
1262
+ distance = Math.abs(rect.left - currentRect.left) + (rect.top - currentRect.bottom)
1263
+ isValidDirection = true
1264
+ }
1265
+ break
1266
+ case 'left':
1267
+ // 目标元素在当前元素左边
1268
+ if (rect.right <= currentRect.left) {
1269
+ distance = Math.abs(rect.top - currentRect.top) + (currentRect.left - rect.right)
1270
+ isValidDirection = true
1271
+ }
1272
+ break
1273
+ case 'right':
1274
+ // 目标元素在当前元素右边
1275
+ if (rect.left >= currentRect.right) {
1276
+ distance = Math.abs(rect.top - currentRect.top) + (rect.left - currentRect.right)
1277
+ isValidDirection = true
1278
+ }
1279
+ break
1280
+ }
1281
+
1282
+ if (isValidDirection && distance < minDistance) {
1283
+ minDistance = distance
1284
+ nextElement = item.el
1285
+ }
1286
+ })
1287
+
1288
+ return nextElement
1289
+ }
1290
+
1291
+ /**
1292
+ * 获取 item 内实际可聚焦的元素
1293
+ * @param {HTMLElement} item - 目标 item
1294
+ * @param {string} direction - 导航方向 ('up'|'down'|'left'|'right'),用于选择最近的焦点
1295
+ */
1296
+ _getFocusableInItem(item, direction = null) {
1297
+ // 如果 item 本身可聚焦
1298
+ if (item.getAttribute('focusable') === 'true') {
1299
+ return item
1300
+ }
1301
+
1302
+ // 查找子元素中的所有可聚焦元素
1303
+ const focusables = item.querySelectorAll('[focusable="true"]')
1304
+ if (focusables.length === 0) {
1305
+ // 没有找到可聚焦元素,返回 item 本身
1306
+ return item
1307
+ }
1308
+
1309
+ if (focusables.length === 1 || !direction) {
1310
+ // 只有一个可聚焦元素,或没有方向信息,直接返回第一个
1311
+ return focusables[0]
1312
+ }
1313
+
1314
+ // 有多个可聚焦元素,根据方向选择最近的
1315
+ // 获取所有可聚焦元素的位置信息
1316
+ const focusableRects = Array.from(focusables).map((el) => ({
1317
+ el,
1318
+ rect: el.getBoundingClientRect(),
1319
+ }))
1320
+
1321
+ // 根据方向选择最佳元素
1322
+ // up: 选择最下方的元素(从下面进入)
1323
+ // down: 选择最上方的元素(从上面进入)
1324
+ // left: 选择最右边的元素(从右边进入)
1325
+ // right: 选择最左边的元素(从左边进入)
1326
+ let bestElement = focusableRects[0].el
1327
+ let bestValue = null
1328
+
1329
+ focusableRects.forEach((item) => {
1330
+ const rect = item.rect
1331
+ let value
1332
+
1333
+ switch (direction) {
1334
+ case 'up':
1335
+ // 从下往上导航,选择最下方的元素
1336
+ value = rect.bottom
1337
+ if (bestValue === null || value > bestValue) {
1338
+ bestValue = value
1339
+ bestElement = item.el
1340
+ }
1341
+ break
1342
+ case 'down':
1343
+ // 从上往下导航,选择最上方的元素
1344
+ value = rect.top
1345
+ if (bestValue === null || value < bestValue) {
1346
+ bestValue = value
1347
+ bestElement = item.el
1348
+ }
1349
+ break
1350
+ case 'left':
1351
+ // 从右往左导航,选择最右边的元素
1352
+ value = rect.right
1353
+ if (bestValue === null || value > bestValue) {
1354
+ bestValue = value
1355
+ bestElement = item.el
1356
+ }
1357
+ break
1358
+ case 'right':
1359
+ // 从左往右导航,选择最左边的元素
1360
+ value = rect.left
1361
+ if (bestValue === null || value < bestValue) {
1362
+ bestValue = value
1363
+ bestElement = item.el
1364
+ }
1365
+ break
1366
+ }
1367
+ })
1368
+
1369
+ return bestElement
1370
+ }
1371
+
1372
+ /**
1373
+ * 基于空间距离的焦点导航(适用于瀑布流或不规则布局)
1374
+ */
1375
+ _handleSpatialNavigation(currentElement, direction, items, itemCount) {
1376
+ if (!currentElement) return { element: null, index: -1 }
1377
+
1378
+ const currentRect = currentElement.getBoundingClientRect()
1379
+ // 计算中心点坐标(相对于视口)
1380
+ const currentCenter = {
1381
+ x: currentRect.left + currentRect.width / 2,
1382
+ y: currentRect.top + currentRect.height / 2,
1383
+ }
1384
+
1385
+ console.log(
1386
+ `[QtFastListView] SpatialNav start - Current Focus Center: (${currentCenter.x}, ${currentCenter.y}), direction: ${direction}`
1387
+ )
1388
+
1389
+ let bestElement = null
1390
+ let bestIndex = -1
1391
+ let bestDistance = Infinity
1392
+ const crossWeight = 0.5 // 侧向距离权重
1393
+
1394
+ // 获取所有项的实际位置(处理绝对定位和相对定位)
1395
+ for (let i = 0; i < itemCount; i++) {
1396
+ const item = items[i]
1397
+ // 跳过不可见的项
1398
+ if (item.style.display === 'none') continue
1399
+
1400
+ const focusable = this._getFocusableInItem(item, direction)
1401
+ if (!focusable || focusable === currentElement) continue
1402
+
1403
+ // 如果整个 item 都不可聚焦(如瀑布流的空标题),跳过
1404
+ if (focusable === item && item.getAttribute('focusable') !== 'true') continue
1405
+
1406
+ // 必须使用 getBoundingClientRect 获取真实视口坐标,因为 waterfall 的子项可能是绝对定位的
1407
+ const rect = focusable.getBoundingClientRect()
1408
+ // 跳过尺寸为 0 的不可见元素
1409
+ if (rect.width === 0 && rect.height === 0) continue
1410
+
1411
+ const center = {
1412
+ x: rect.left + rect.width / 2,
1413
+ y: rect.top + rect.height / 2,
1414
+ }
1415
+
1416
+ const dx = center.x - currentCenter.x
1417
+ const dy = center.y - currentCenter.y
1418
+
1419
+ let isInDirection = false
1420
+ let primaryDistance = 0
1421
+ let crossDistance = 0
1422
+
1423
+ switch (direction) {
1424
+ case 'up':
1425
+ isInDirection = dy < -5 // 减小容差,更精确匹配
1426
+ primaryDistance = Math.abs(dy)
1427
+ crossDistance = Math.abs(dx)
1428
+ break
1429
+ case 'down':
1430
+ isInDirection = dy > 5
1431
+ primaryDistance = Math.abs(dy)
1432
+ crossDistance = Math.abs(dx)
1433
+ break
1434
+ case 'left':
1435
+ isInDirection = dx < -5
1436
+ primaryDistance = Math.abs(dx)
1437
+ crossDistance = Math.abs(dy)
1438
+ break
1439
+ case 'right':
1440
+ isInDirection = dx > 5
1441
+ primaryDistance = Math.abs(dx)
1442
+ crossDistance = Math.abs(dy)
1443
+ break
1444
+ }
1445
+
1446
+ if (isInDirection) {
1447
+ // 主方向距离 + 侧向距离加权
1448
+ // 对于左右移动,如果 y 轴差异很大(不在同一行),应大大增加距离惩罚
1449
+ if ((direction === 'left' || direction === 'right') && Math.abs(dy) > rect.height / 2) {
1450
+ crossDistance *= 5 // 如果不在同一行,极大地降低其被选中的概率
1451
+ }
1452
+
1453
+ // 对于上下移动,如果 x 轴差异很大(不在同一列),应大大增加距离惩罚
1454
+ if ((direction === 'up' || direction === 'down') && Math.abs(dx) > rect.width / 2) {
1455
+ crossDistance *= 5
1456
+ }
1457
+
1458
+ const distance = primaryDistance + crossDistance * crossWeight
1459
+
1460
+ console.log(
1461
+ `[QtFastListView] SpatialNav candidate - Index: ${i}, Center: (${center.x}, ${center.y}), dx: ${dx}, dy: ${dy}, distance: ${distance}`
1462
+ )
1463
+
1464
+ if (distance < bestDistance) {
1465
+ bestDistance = distance
1466
+ bestElement = focusable
1467
+ bestIndex = i
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ if (bestElement) {
1473
+ console.log(
1474
+ `[QtFastListView] SpatialNav success - Best Index: ${bestIndex}, distance: ${bestDistance}`
1475
+ )
1476
+ } else {
1477
+ console.log(
1478
+ `[QtFastListView] SpatialNav failed - No candidate found for direction ${direction}`
1479
+ )
1480
+ }
1481
+
1482
+ return { element: bestElement, index: bestIndex }
1483
+ }
1484
+
1485
+ /**
1486
+ * 网格布局的焦点导航
1487
+ * @returns { element: HTMLElement|null, index: number }
1488
+ */
1489
+ _handleGridNavigation(currentIndex, direction, items, itemCount) {
1490
+ const spanCount = this._spanCount
1491
+ let currentRow = Math.floor(currentIndex / spanCount)
1492
+ let currentCol = currentIndex % spanCount
1493
+
1494
+ let nextIndex = currentIndex
1495
+
1496
+ while (true) {
1497
+ switch (direction) {
1498
+ case 'up':
1499
+ if (currentRow > 0) {
1500
+ nextIndex -= spanCount
1501
+ currentRow--
1502
+ } else {
1503
+ nextIndex = -1
1504
+ }
1505
+ break
1506
+ case 'down':
1507
+ nextIndex += spanCount
1508
+ currentRow++
1509
+ if (nextIndex >= itemCount) {
1510
+ nextIndex = -1
1511
+ }
1512
+ break
1513
+ case 'left':
1514
+ if (currentCol > 0) {
1515
+ nextIndex -= 1
1516
+ currentCol--
1517
+ } else {
1518
+ nextIndex = -1
1519
+ }
1520
+ break
1521
+ case 'right':
1522
+ if (currentCol < spanCount - 1 && nextIndex + 1 < itemCount) {
1523
+ nextIndex += 1
1524
+ currentCol++
1525
+ } else {
1526
+ nextIndex = -1
1527
+ }
1528
+ break
1529
+ }
1530
+
1531
+ if (nextIndex >= 0 && nextIndex < itemCount) {
1532
+ const focusable = this._getFocusableInItem(items[nextIndex], direction)
1533
+ if (
1534
+ focusable !== items[nextIndex] ||
1535
+ items[nextIndex].getAttribute('focusable') === 'true'
1536
+ ) {
1537
+ return { element: focusable, index: nextIndex }
1538
+ }
1539
+ // 如果当前项不可聚焦,继续在同方向上查找
1540
+ continue
1541
+ }
1542
+
1543
+ break
1544
+ }
1545
+
1546
+ return { element: null, index: -1 }
1547
+ }
1548
+
1549
+ /**
1550
+ * 列表布局的焦点导航
1551
+ * @returns { element: HTMLElement|null, index: number }
1552
+ */
1553
+ _handleListNavigation(currentIndex, direction, items, itemCount) {
1554
+ let nextIndex = currentIndex
1555
+
1556
+ while (true) {
1557
+ if (this._horizontal) {
1558
+ // 水平列表
1559
+ switch (direction) {
1560
+ case 'left':
1561
+ if (nextIndex > 0) {
1562
+ nextIndex -= 1
1563
+ } else {
1564
+ nextIndex = -1
1565
+ }
1566
+ break
1567
+ case 'right':
1568
+ if (nextIndex < itemCount - 1) {
1569
+ nextIndex += 1
1570
+ } else {
1571
+ nextIndex = -1
1572
+ }
1573
+ break
1574
+ default:
1575
+ nextIndex = -1
1576
+ break
1577
+ }
1578
+ } else {
1579
+ // 垂直列表
1580
+ switch (direction) {
1581
+ case 'up':
1582
+ if (nextIndex > 0) {
1583
+ nextIndex -= 1
1584
+ } else {
1585
+ nextIndex = -1
1586
+ }
1587
+ break
1588
+ case 'down':
1589
+ if (nextIndex < itemCount - 1) {
1590
+ nextIndex += 1
1591
+ } else {
1592
+ nextIndex = -1
1593
+ }
1594
+ break
1595
+ default:
1596
+ nextIndex = -1
1597
+ break
1598
+ }
1599
+ }
1600
+
1601
+ if (nextIndex >= 0 && nextIndex < itemCount) {
1602
+ const focusable = this._getFocusableInItem(items[nextIndex], direction)
1603
+ if (
1604
+ focusable !== items[nextIndex] ||
1605
+ items[nextIndex].getAttribute('focusable') === 'true'
1606
+ ) {
1607
+ return { element: focusable, index: nextIndex }
1608
+ }
1609
+ // 如果当前项不可聚焦,继续在同方向上查找
1610
+ continue
1611
+ }
1612
+
1613
+ break
1614
+ }
1615
+
1616
+ return { element: null, index: -1 }
1617
+ }
1618
+
1619
+ // Extract the actual value from a ${prop} placeholder
1620
+ _extractValue(placeholder, data) {
1621
+ return extractTemplateValue(placeholder, data)
1622
+ }
1623
+
1624
+ // Apply flexStyle object to a DOM element
1625
+ _applyFlexStyleToElement(element, styleObj) {
1626
+ if (!styleObj || typeof styleObj !== 'object') return
1627
+
1628
+ Object.keys(styleObj).forEach((key) => {
1629
+ let value = styleObj[key]
1630
+
1631
+ if (typeof value === 'number') {
1632
+ const needsPx = [
1633
+ 'width',
1634
+ 'height',
1635
+ 'minWidth',
1636
+ 'minHeight',
1637
+ 'maxWidth',
1638
+ 'maxHeight',
1639
+ 'padding',
1640
+ 'paddingTop',
1641
+ 'paddingRight',
1642
+ 'paddingBottom',
1643
+ 'paddingLeft',
1644
+ 'margin',
1645
+ 'marginTop',
1646
+ 'marginRight',
1647
+ 'marginBottom',
1648
+ 'marginLeft',
1649
+ 'top',
1650
+ 'left',
1651
+ 'right',
1652
+ 'bottom',
1653
+ 'borderRadius',
1654
+ 'borderWidth',
1655
+ 'fontSize',
1656
+ 'lineHeight',
1657
+ 'letterSpacing',
1658
+ 'gap',
1659
+ 'rowGap',
1660
+ 'columnGap',
1661
+ ].includes(key)
1662
+
1663
+ if (needsPx) {
1664
+ value = value + 'px'
1665
+ }
1666
+ }
1667
+
1668
+ try {
1669
+ element.style[key] = value
1670
+ } catch (e) {
1671
+ try {
1672
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
1673
+ element.style.setProperty(cssKey, value)
1674
+ } catch (err) {
1675
+ // Ignore style errors
1676
+ }
1677
+ }
1678
+ })
1679
+ }
1680
+
1681
+ // Override updateChildzIndex to safely handle null children
1682
+ updateChildzIndex() {
1683
+ if (!this.dom) return
1684
+ const uiManager = this.context.getModuleByName('UIManagerModule')
1685
+ this.dom.childNodes.forEach((item) => {
1686
+ const childDom = uiManager.findViewById(item.id)
1687
+ if (!childDom) return
1688
+
1689
+ if (this.exitChildrenStackContext && childDom.props?.style?.zIndex !== undefined) {
1690
+ childDom.updateSelfStackContext()
1691
+ }
1692
+ if (!this.exitChildrenStackContext && childDom.updatedZIndex) {
1693
+ childDom.updateSelfStackContext(false)
1694
+ }
1695
+ })
1696
+ }
1697
+ }
1698
+
1699
+ function parseDomTemplateType(element) {
1700
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return null
1701
+ const raw = element.getAttribute('data-template-type') ?? element.getAttribute('type')
1702
+ if (raw === null || raw === undefined || raw === '') return null
1703
+ return isNaN(Number(raw)) ? raw : Number(raw)
1704
+ }
1705
+
1706
+ function collectDomTemplates(rootElement, itemContainer) {
1707
+ const templates = []
1708
+ const directChildren = Array.from(rootElement.children || []).filter(
1709
+ (child) => child !== itemContainer
1710
+ )
1711
+
1712
+ directChildren.forEach((element) => {
1713
+ const ownType = parseDomTemplateType(element)
1714
+ const isSingleton =
1715
+ element.hasAttribute?.('singleton') || element.hasAttribute?.('data-singleton')
1716
+
1717
+ if (ownType !== null || isSingleton) {
1718
+ element.setAttribute(isSingleton ? 'data-singleton' : 'data-template', 'true')
1719
+ if (ownType !== null) {
1720
+ element.setAttribute('data-template-type', String(ownType))
1721
+ }
1722
+ element.style.display = 'none'
1723
+ templates.push({ dom: element, type: ownType, singleton: isSingleton })
1724
+ return
1725
+ }
1726
+
1727
+ const nestedTemplates = element.querySelectorAll
1728
+ ? Array.from(
1729
+ element.querySelectorAll('[type], [data-template-type], [singleton], [data-singleton]')
1730
+ )
1731
+ : []
1732
+
1733
+ if (nestedTemplates.length > 0) {
1734
+ element.style.display = 'none'
1735
+ nestedTemplates.forEach((nested) => {
1736
+ const nestedType = parseDomTemplateType(nested)
1737
+ const nestedSingleton =
1738
+ nested.hasAttribute?.('singleton') || nested.hasAttribute?.('data-singleton')
1739
+ nested.setAttribute(nestedSingleton ? 'data-singleton' : 'data-template', 'true')
1740
+ if (nestedType !== null) {
1741
+ nested.setAttribute('data-template-type', String(nestedType))
1742
+ }
1743
+ nested.style.display = 'none'
1744
+ templates.push({ dom: nested, type: nestedType, singleton: nestedSingleton })
1745
+ })
1746
+ return
1747
+ }
1748
+
1749
+ element.setAttribute('data-template', 'true')
1750
+ element.style.display = 'none'
1751
+ templates.push({ dom: element, type: null, singleton: false })
1752
+ })
1753
+
1754
+ return templates
1755
+ }
1756
+
1757
+ function configureDomListItemContainer(rootElement, itemContainer) {
1758
+ const isHorizontal =
1759
+ rootElement.hasAttribute('horizontal') || rootElement.getAttribute('horizontal') === ''
1760
+
1761
+ const spanCountValue =
1762
+ rootElement.getAttribute('spanCount') || rootElement.getAttribute('span-count') || '0'
1763
+ const spanCount = Number(spanCountValue) || 0
1764
+
1765
+ if (spanCount > 0) {
1766
+ itemContainer.style.display = 'grid'
1767
+ itemContainer.style.gridTemplateColumns = `repeat(${spanCount}, 1fr)`
1768
+ itemContainer.style.flexDirection = ''
1769
+ itemContainer.style.flexWrap = ''
1770
+ itemContainer.style.overflowX = 'hidden'
1771
+ itemContainer.style.overflowY = 'auto'
1772
+ return
1773
+ }
1774
+
1775
+ itemContainer.style.display = 'flex'
1776
+ itemContainer.style.gridTemplateColumns = ''
1777
+
1778
+ if (isHorizontal) {
1779
+ itemContainer.style.flexDirection = 'row'
1780
+ itemContainer.style.flexWrap = 'nowrap'
1781
+ itemContainer.style.overflowX = 'auto'
1782
+ itemContainer.style.overflowY = 'hidden'
1783
+ return
1784
+ }
1785
+
1786
+ itemContainer.style.flexDirection = 'column'
1787
+ itemContainer.style.flexWrap = 'nowrap'
1788
+ itemContainer.style.overflowX = 'hidden'
1789
+ itemContainer.style.overflowY = 'auto'
1790
+ }
1791
+
1792
+ function bindDomOnlyListNode(node, itemData, listData) {
1793
+ bindTemplateDataToNode(node, itemData, {
1794
+ scope: { itemList: listData },
1795
+ onListData: (element, value) => {
1796
+ element._qtListData = value
1797
+ element.setAttribute('data-has-list', 'true')
1798
+ },
1799
+ })
1800
+ }
1801
+
1802
+ export function renderQtFastListViewInPlace(rootElement, listData) {
1803
+ if (!rootElement) return
1804
+ if (Array.isArray(listData) && listData.length === 1 && Array.isArray(listData[0])) {
1805
+ listData = listData[0]
1806
+ }
1807
+ if (!Array.isArray(listData)) return
1808
+ if (rootElement.style.display === 'none') {
1809
+ rootElement.style.display = ''
1810
+ }
1811
+
1812
+ let itemContainer = Array.from(rootElement.children || []).find(
1813
+ (child) => child.classList && child.classList.contains('fast-list-item-container')
1814
+ )
1815
+
1816
+ if (!itemContainer) {
1817
+ itemContainer = document.createElement('div')
1818
+ itemContainer.className = 'fast-list-item-container'
1819
+ rootElement.appendChild(itemContainer)
1820
+ }
1821
+
1822
+ configureDomListItemContainer(rootElement, itemContainer)
1823
+
1824
+ const templates = collectDomTemplates(rootElement, itemContainer)
1825
+ const sharedTemplates = window.__QT_SHARED_TEMPLATES__
1826
+ itemContainer.innerHTML = ''
1827
+
1828
+ if (listData.length === 0) {
1829
+ return
1830
+ }
1831
+
1832
+ const renderedSingletonTypes = new Set()
1833
+ const singletonTemplates = templates.filter((template) => template.singleton)
1834
+ const regularTemplates = templates.filter((template) => !template.singleton)
1835
+
1836
+ singletonTemplates.forEach((template) => {
1837
+ let matchingData = null
1838
+ if (template.type !== null && template.type !== undefined) {
1839
+ matchingData = listData.find((item) => item?.type === template.type) || null
1840
+ } else {
1841
+ matchingData = listData[0] || null
1842
+ }
1843
+ if (!matchingData) {
1844
+ template.dom.style.display = 'none'
1845
+ return
1846
+ }
1847
+ template.dom.style.display = ''
1848
+ bindDomOnlyListNode(template.dom, matchingData, listData)
1849
+ applyDomOnlyTemplateRootDefaults(template.dom)
1850
+ itemContainer.appendChild(template.dom)
1851
+ if (matchingData.type !== null && matchingData.type !== undefined) {
1852
+ renderedSingletonTypes.add(matchingData.type)
1853
+ }
1854
+ })
1855
+
1856
+ const renderTemplates = regularTemplates.length > 0 ? regularTemplates : templates
1857
+ listData.forEach((itemData, index) => {
1858
+ if (renderedSingletonTypes.has(itemData?.type)) {
1859
+ return
1860
+ }
1861
+
1862
+ let matchingTemplate = renderTemplates.find((template) => template.type === itemData?.type)
1863
+ if (
1864
+ !matchingTemplate &&
1865
+ sharedTemplates &&
1866
+ itemData?.type !== null &&
1867
+ itemData?.type !== undefined
1868
+ ) {
1869
+ const sharedByType = sharedTemplates.get(itemData.type)
1870
+ if (sharedByType?.dom) {
1871
+ matchingTemplate = sharedByType
1872
+ }
1873
+ }
1874
+ if (!matchingTemplate) {
1875
+ matchingTemplate = renderTemplates.find((template) => template.type === null)
1876
+ }
1877
+ if (!matchingTemplate) {
1878
+ if (sharedTemplates && sharedTemplates.size > 0) {
1879
+ const firstShared = sharedTemplates.values().next().value
1880
+ if (firstShared?.dom) {
1881
+ matchingTemplate = firstShared
1882
+ }
1883
+ }
1884
+ }
1885
+ if (!matchingTemplate) {
1886
+ matchingTemplate = renderTemplates[0]
1887
+ }
1888
+ if (!matchingTemplate?.dom) {
1889
+ return
1890
+ }
1891
+
1892
+ const itemWrapper = document.createElement('div')
1893
+ itemWrapper.setAttribute('data-index', index)
1894
+ itemWrapper.setAttribute('data-position', index)
1895
+ itemWrapper._itemData = itemData
1896
+ itemWrapper._itemPosition = index
1897
+
1898
+ if (itemData && itemData.decoration && typeof itemData.decoration === 'object') {
1899
+ const decoration = itemData.decoration
1900
+ if (decoration.top !== undefined) itemWrapper.style.marginTop = decoration.top + 'px'
1901
+ if (decoration.left !== undefined) itemWrapper.style.marginLeft = decoration.left + 'px'
1902
+ if (decoration.right !== undefined) itemWrapper.style.marginRight = decoration.right + 'px'
1903
+ if (decoration.bottom !== undefined) itemWrapper.style.marginBottom = decoration.bottom + 'px'
1904
+ }
1905
+
1906
+ const clonedNode = matchingTemplate.dom.cloneNode(true)
1907
+ clonedNode.removeAttribute('data-template')
1908
+ clonedNode.removeAttribute('data-singleton')
1909
+ clonedNode.style.display = ''
1910
+ clonedNode.style.position = 'relative'
1911
+
1912
+ bindDomOnlyListNode(clonedNode, itemData, listData)
1913
+ applyDomOnlyTemplateRootDefaults(clonedNode)
1914
+
1915
+ itemWrapper.appendChild(clonedNode)
1916
+ itemContainer.appendChild(itemWrapper)
1917
+ })
1918
+
1919
+ requestAnimationFrame(() => syncDomAutoWidthIn(itemContainer))
1920
+ }