@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,2014 @@
1
+ // TV Focus Navigation System
2
+ // Handles keyboard arrow keys for TV-style focus navigation
3
+
4
+ import { syncDomAutoWidthIn } from './templateBinding'
5
+
6
+ const DEBUG = false
7
+
8
+ export class TVFocusManager {
9
+ constructor() {
10
+ this.focusableElements = []
11
+ this.focusedElement = null
12
+ this.focusedElementOriginalStyles = new Map()
13
+
14
+ // Focus history for back navigation
15
+ // Stack of { routeName, focusSelector, focusIndex } objects
16
+ this.focusHistory = []
17
+ this.currentRouteName = null
18
+
19
+ // Focus navigation options
20
+ this.options = {
21
+ // 是否使用距离计算来选择最近的焦点元素
22
+ // 强烈建议在瀑布流/复杂列表中开启 true
23
+ useDistanceBasedNavigation: true,
24
+ // 距离计算时的横向距离权重(0-1)
25
+ crossDistanceWeight: 0.5,
26
+ }
27
+
28
+ // Default focus styles
29
+ this.defaultFocusStyle = {
30
+ border: {
31
+ enabled: true,
32
+ color: '#FFFFFF',
33
+ width: 3,
34
+ innerColor: '#000000',
35
+ innerWidth: 2,
36
+ cornerRadius: 0,
37
+ },
38
+ scale: 1.0,
39
+ transition: 'transform 0.15s ease-out, outline 0.15s ease-out',
40
+ }
41
+
42
+ // Bind methods
43
+ this.handleKeyDown = this.handleKeyDown.bind(this)
44
+ this.updateFocusableElements = this.updateFocusableElements.bind(this)
45
+
46
+ // Start listening
47
+ this.init()
48
+ }
49
+
50
+ /**
51
+ * 设置焦点导航选项
52
+ */
53
+ setOptions(options) {
54
+ this.options = { ...this.options, ...options }
55
+ }
56
+
57
+ /**
58
+ * Update focus configuration from FocusModule
59
+ * @param {object} config - Focus configuration from FocusModule
60
+ */
61
+ updateFocusConfig(config) {
62
+ if (!config) return
63
+
64
+ // Update border color
65
+ if (config.borderColorEnabled) {
66
+ this.defaultFocusStyle.border.color = config.borderColor
67
+ }
68
+
69
+ // Update border width
70
+ if (config.borderWidthEnabled) {
71
+ this.defaultFocusStyle.border.width = config.borderWidth
72
+ }
73
+
74
+ // Update corner radius
75
+ if (config.cornerEnabled) {
76
+ this.defaultFocusStyle.border.cornerRadius = config.cornerRadius
77
+ }
78
+
79
+ // Update border inset (we'll apply this as margin in focus styles)
80
+ // Note: inset in native means the border moves inward, in web we handle this differently
81
+ if (config.borderInsetEnabled) {
82
+ this.defaultFocusStyle.border.inset = config.borderInset
83
+ }
84
+
85
+ // Update inner border
86
+ this.defaultFocusStyle.border.innerEnabled = config.innerBorderEnabled
87
+
88
+ // Update focus scale
89
+ if (config.focusScale) {
90
+ this.defaultFocusStyle.scale = config.focusScale
91
+ }
92
+
93
+ // Re-apply focus styles if there's a focused element
94
+ if (this.focusedElement) {
95
+ this._applyFocusStyle(this.focusedElement)
96
+ }
97
+
98
+ this._log('[TVFocusManager] Focus config updated:', this.defaultFocusStyle)
99
+ }
100
+
101
+ _log(...args) {
102
+ if (DEBUG) console.log(...args)
103
+ }
104
+
105
+ init() {
106
+ document.addEventListener('keydown', this.handleKeyDown)
107
+
108
+ // Add mouse/pointer click support
109
+ document.addEventListener('click', this.handleClick.bind(this), true)
110
+
111
+ const observer = new MutationObserver((mutations) => {
112
+ this.updateFocusableElements()
113
+
114
+ // 检查新添加的元素是否有 autofocus 属性,实现 Android 端兼容效果
115
+ for (const mutation of mutations) {
116
+ if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
117
+ // 延迟处理,等待元素完全渲染
118
+ setTimeout(() => {
119
+ for (const node of mutation.addedNodes) {
120
+ this._checkAndFocusAutofocusElement(node)
121
+ }
122
+ }, 50)
123
+ }
124
+ }
125
+ })
126
+ observer.observe(document.body, { childList: true, subtree: true })
127
+
128
+ // Initial scan (no auto-focus, keep consistent with Android)
129
+ setTimeout(() => {
130
+ this.updateFocusableElements()
131
+ }, 1000)
132
+ this._log('Focus manager initialized')
133
+ }
134
+
135
+ // Check if navigation is going back
136
+ _isGoingBack(from, to) {
137
+ // Check if the 'to' route exists in our history
138
+ const toIndex = this.focusHistory.findIndex((h) => h.routeName === to.name)
139
+ return toIndex >= 0
140
+ }
141
+
142
+ // Save current focus state before navigation
143
+ _saveFocusState(routeName) {
144
+ if (!this.focusedElement) return
145
+
146
+ // Generate a unique selector for the focused element
147
+ const selector = this._generateElementSelector(this.focusedElement)
148
+
149
+ const focusState = {
150
+ routeName,
151
+ focusSelector: selector,
152
+ }
153
+
154
+ // Check if we already have a state for this route
155
+ const existingIndex = this.focusHistory.findIndex((h) => h.routeName === routeName)
156
+ if (existingIndex >= 0) {
157
+ // Update existing state
158
+ this.focusHistory[existingIndex] = focusState
159
+ } else {
160
+ // Push new state
161
+ this.focusHistory.push(focusState)
162
+ }
163
+
164
+ // console.log('[TVFocus] Saved focus state for route:', routeName, 'selector:', selector)
165
+ }
166
+
167
+ // Restore focus state when returning to a page
168
+ _restoreFocusState(routeName) {
169
+ const focusState = this.focusHistory.find((h) => h.routeName === routeName)
170
+
171
+ // console.log('[TVFocus] Attempting to restore focus for route:', routeName)
172
+
173
+ // Clear current focus
174
+ this.clearFocus()
175
+
176
+ // Update focusable elements first
177
+ this.updateFocusableElements()
178
+
179
+ if (focusState && focusState.focusSelector) {
180
+ // Try to find element by selector
181
+ setTimeout(() => {
182
+ const element = document.querySelector(focusState.focusSelector)
183
+ if (element && this.focusableElements.includes(element)) {
184
+ this._doFocusElement(element)
185
+ }
186
+ }, 200)
187
+ } else {
188
+ // console.log('[TVFocus] No saved focus state found for route:', routeName)
189
+ }
190
+ }
191
+
192
+ // Generate a unique CSS selector for an element
193
+ _generateElementSelector(element) {
194
+ if (!element) return null
195
+
196
+ // Helper to escape CSS identifier (handles numeric IDs)
197
+ const escapeCSSId = (id) => {
198
+ if (!id) return null
199
+ // CSS.escape is available in modern browsers
200
+ if (typeof CSS !== 'undefined' && CSS.escape) {
201
+ return CSS.escape(id)
202
+ }
203
+ // Fallback: manually escape
204
+ return id.replace(/([^\w-])/g, '\\$1')
205
+ }
206
+
207
+ // Try data-component-name first
208
+ const componentName = element.getAttribute('data-component-name')
209
+ const id = element.id
210
+
211
+ if (componentName && id) {
212
+ // Use attribute selector for ID to avoid issues with numeric IDs
213
+ return `[data-component-name="${componentName}"][id="${id}"]`
214
+ }
215
+
216
+ if (componentName) {
217
+ return `[data-component-name="${componentName}"]`
218
+ }
219
+
220
+ // Try id with proper escaping
221
+ if (id) {
222
+ return `[id="${id}"]`
223
+ }
224
+
225
+ // Generate path-based selector
226
+ const path = []
227
+ let current = element
228
+
229
+ while (current && current !== document.body) {
230
+ let selector = current.tagName.toLowerCase()
231
+
232
+ if (current.id) {
233
+ // Use attribute selector instead of # for numeric IDs
234
+ selector = `[id="${current.id}"]`
235
+ path.unshift(selector)
236
+ break
237
+ }
238
+
239
+ if (current.className && typeof current.className === 'string') {
240
+ const classes = current.className.split(' ').filter((c) => c && !c.includes(':'))
241
+ if (classes.length > 0) {
242
+ selector += `.${classes[0]}`
243
+ }
244
+ }
245
+
246
+ // Add nth-child if needed
247
+ const parent = current.parentElement
248
+ if (parent) {
249
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName)
250
+ if (siblings.length > 1) {
251
+ const index = siblings.indexOf(current) + 1
252
+ selector += `:nth-child(${index})`
253
+ }
254
+ }
255
+
256
+ path.unshift(selector)
257
+ current = current.parentElement
258
+ }
259
+
260
+ return path.join(' > ')
261
+ }
262
+
263
+ resetForNewPage() {
264
+ // console.log('[TVFocus] Resetting focus for new page')
265
+ this.clearFocus()
266
+
267
+ // Update focusable elements list
268
+ // Unlike native Android, we don't auto-focus the first element
269
+ // Only focus elements with 'autofocus' attribute
270
+ const updateElements = (attempt = 0, maxAttempts = 5) => {
271
+ this.updateFocusableElements()
272
+
273
+ // [TVFocus] Attempt ${attempt + 1}: Found ${this.focusableElements.length} focusable elements
274
+
275
+ if (this.focusableElements.length > 0) {
276
+ // Look for element with autofocus attribute
277
+ const autofocusElement = this.focusableElements.find((el) => {
278
+ const autofocus = el.getAttribute('autofocus')
279
+ return autofocus === 'true' || autofocus === '' || autofocus === '1'
280
+ })
281
+
282
+ if (autofocusElement) {
283
+ this._doFocusElement(autofocusElement)
284
+ // console.log('[TVFocus] Auto-focused element with autofocus attribute')
285
+ }
286
+ // If no autofocus element, don't auto-focus anything
287
+ // Focus will be set when user presses arrow keys or when requestFocus() is called
288
+ } else if (attempt < maxAttempts) {
289
+ const delay = 100 + attempt * 100
290
+ // console.log(`[TVFocus] No elements found, retrying in ${delay}ms...`)
291
+ setTimeout(() => updateElements(attempt + 1, maxAttempts), delay)
292
+ }
293
+ }
294
+
295
+ setTimeout(() => updateElements(0), 150)
296
+ }
297
+
298
+ /**
299
+ * 检查新添加的节点是否有 autofocus 属性,实现 Android 端兼容效果
300
+ * 当组件 mount 时自动获取焦点
301
+ */
302
+ _checkAndFocusAutofocusElement(node) {
303
+ if (!node || node.nodeType !== 1) return
304
+
305
+ // 检查节点本身是否有 autofocus 属性
306
+ const autofocus = node.getAttribute && node.getAttribute('autofocus')
307
+ const hasAutofocus = autofocus === 'true' || autofocus === '' || autofocus === '1'
308
+
309
+ // 检查节点是否可聚焦
310
+ const isFocusable = node.hasAttribute && node.hasAttribute('focusable')
311
+
312
+ if (hasAutofocus && isFocusable) {
313
+ // 更新 focusable 元素列表
314
+ this.updateFocusableElements()
315
+
316
+ // 检查该元素是否在 focusable 列表中
317
+ if (this.focusableElements.includes(node)) {
318
+ this._doFocusElement(node)
319
+ return
320
+ }
321
+ }
322
+
323
+ // 检查子元素是否有 autofocus 属性
324
+ if (node.querySelector) {
325
+ const autofocusChild = node.querySelector(
326
+ '[autofocus="true"], [autofocus=""], [autofocus="1"]'
327
+ )
328
+ if (
329
+ autofocusChild &&
330
+ autofocusChild.hasAttribute &&
331
+ autofocusChild.hasAttribute('focusable')
332
+ ) {
333
+ // 更新 focusable 元素列表
334
+ this.updateFocusableElements()
335
+
336
+ // 检查该元素是否在 focusable 列表中
337
+ if (this.focusableElements.includes(autofocusChild)) {
338
+ this._doFocusElement(autofocusChild)
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ _focusFirstDivFallback() {
345
+ const currentPage = this._getCurrentPage()
346
+ // console.log('[TVFocus] Fallback: searching for first div in', currentPage.tagName)
347
+
348
+ const divs = currentPage.querySelectorAll('div')
349
+ for (const div of divs) {
350
+ const style = window.getComputedStyle(div)
351
+ const isVisible =
352
+ style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0
353
+ const rect = div.getBoundingClientRect()
354
+ const hasDimensions = rect.width > 0 && rect.height > 0
355
+
356
+ if (isVisible && hasDimensions) {
357
+ div.setAttribute('focusable', 'true')
358
+ div.setAttribute('tabindex', '0')
359
+
360
+ this.focusableElements = [div]
361
+ this._doFocusElement(div)
362
+ // console.log('[TVFocus] Fallback: focused first div element')
363
+ return
364
+ }
365
+ }
366
+
367
+ console.warn('[TVFocus] Fallback: no visible div element found either')
368
+ }
369
+
370
+ _getCurrentPage() {
371
+ const routerViewSelectors = [
372
+ '[data-component-name="ESPageRouterView"]',
373
+ '[data-component-name="ESRouterView"]',
374
+ 'es-page-router-view',
375
+ 'es-router-view',
376
+ '[data-router-view]',
377
+ '.es-router-view',
378
+ ]
379
+
380
+ for (const selector of routerViewSelectors) {
381
+ const routerView = document.querySelector(selector)
382
+ if (routerView && routerView.children.length > 0) {
383
+ // [TVFocus] Router view found with selector: selector children: routerView.children.length
384
+
385
+ for (let i = routerView.children.length - 1; i >= 0; i--) {
386
+ const child = routerView.children[i]
387
+ const style = window.getComputedStyle(child)
388
+ const isVisible =
389
+ style.display !== 'none' &&
390
+ style.visibility !== 'hidden' &&
391
+ parseFloat(style.opacity) > 0
392
+ if (isVisible) {
393
+ const componentName = child.getAttribute('data-component-name') || child.tagName
394
+ // [TVFocus] Current page componentName className index
395
+ return child
396
+ }
397
+ }
398
+
399
+ return routerView.children[routerView.children.length - 1]
400
+ }
401
+ }
402
+
403
+ // console.log('[TVFocus] Router view not found, searching for visible page containers')
404
+
405
+ const pageContainers = document.querySelectorAll(
406
+ '[data-component-name="ESPageRootView"], es-page-root-view, [data-page]'
407
+ )
408
+ for (let i = pageContainers.length - 1; i >= 0; i--) {
409
+ const container = pageContainers[i]
410
+ const style = window.getComputedStyle(container)
411
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
412
+ return container
413
+ }
414
+ }
415
+
416
+ // console.log('[TVFocus] No page container found, using body as fallback')
417
+ return document.body
418
+ }
419
+
420
+ updateFocusableElements() {
421
+ const selectors = [
422
+ '[focusable="true"]',
423
+ '[focusable="1"]',
424
+ '[focusable]',
425
+ '[data-focusable="true"]',
426
+ '[data-focusable]',
427
+ '[canfocus="true"]',
428
+ '[canFocus="true"]',
429
+ '.focusable',
430
+ '[tabindex="0"]',
431
+ '[tabindex]',
432
+ ]
433
+
434
+ const allElements = new Set()
435
+ selectors.forEach((selector) => {
436
+ try {
437
+ document.querySelectorAll(selector).forEach((el) => allElements.add(el))
438
+ } catch (e) {
439
+ // Ignore invalid selectors
440
+ }
441
+ })
442
+
443
+ const elements = Array.from(allElements)
444
+ // console.log('[TVFocus] Total elements with focusable attributes:', elements.length)
445
+
446
+ if (elements.length > 0) {
447
+ const attrDebug = elements.slice(0, 5).map((el) => {
448
+ const attrs = []
449
+ if (el.hasAttribute('focusable')) attrs.push(`focusable="${el.getAttribute('focusable')}"`)
450
+ if (el.hasAttribute('data-focusable'))
451
+ attrs.push(`data-focusable="${el.getAttribute('data-focusable')}"`)
452
+ if (el.hasAttribute('tabindex')) attrs.push(`tabindex="${el.getAttribute('tabindex')}"`)
453
+ return { tag: el.tagName, attrs: attrs.join(', ') }
454
+ })
455
+ // console.log('[TVFocus] Sample focusable elements:', attrDebug)
456
+ }
457
+
458
+ const currentPage = this._getCurrentPage()
459
+ // [TVFocus] Current page for filtering
460
+
461
+ const candidates = Array.from(elements).filter((el) => {
462
+ // Skip elements with focusable="false" or focusable="0"
463
+ const focusableAttr = el.getAttribute('focusable')
464
+ if (focusableAttr === 'false' || focusableAttr === '0') {
465
+ return false
466
+ }
467
+
468
+ // Skip elements with duplicateParentState - they inherit parent's focus state
469
+ const duplicateParentState = el.getAttribute('duplicateParentState')
470
+ if (
471
+ duplicateParentState === 'true' ||
472
+ duplicateParentState === '' ||
473
+ el.hasAttribute('duplicateparentstate')
474
+ ) {
475
+ return false
476
+ }
477
+
478
+ if (currentPage !== document.body && !currentPage.contains(el)) {
479
+ return false
480
+ }
481
+
482
+ const style = window.getComputedStyle(el)
483
+ const isVisible =
484
+ style.display !== 'none' &&
485
+ style.visibility !== 'hidden' &&
486
+ parseFloat(style.opacity) > 0 &&
487
+ !el.hasAttribute('data-template')
488
+
489
+ if (!isVisible) return false
490
+
491
+ const rect = el.getBoundingClientRect()
492
+ const hasDimensions = rect.width > 0 && rect.height > 0
493
+
494
+ // 放宽视口检测:允许元素在更大的范围内被检测到
495
+ // 这样可以支持 waterfall 等可滚动布局中的焦点导航
496
+ // 使用一个更大的"虚拟视口"范围(3倍屏幕高度)
497
+ const extendedViewportHeight = window.innerHeight * 3
498
+ const extendedViewportWidth = window.innerWidth * 2
499
+ const inViewport =
500
+ rect.top < extendedViewportHeight &&
501
+ rect.bottom > -extendedViewportHeight &&
502
+ rect.left < extendedViewportWidth &&
503
+ rect.right > -extendedViewportWidth
504
+
505
+ return hasDimensions && inViewport
506
+ })
507
+
508
+ this.focusableElements = candidates
509
+
510
+ // Initialize showOnState visibility for all elements (default to 'normal' state)
511
+ this._initShowOnStateVisibility()
512
+
513
+ // [TVFocus] Found focusableElements.length visible focusable elements
514
+
515
+ if (this.focusableElements.length > 0) {
516
+ const sample = this.focusableElements.slice(0, 3).map((el, i) => {
517
+ const rect = el.getBoundingClientRect()
518
+ const componentName = el.getAttribute('data-component-name') || el.tagName
519
+ return {
520
+ index: i,
521
+ component: componentName,
522
+ tag: el.tagName,
523
+ class: el.className?.substring?.(0, 30),
524
+ rect: {
525
+ x: Math.round(rect.x),
526
+ y: Math.round(rect.y),
527
+ w: Math.round(rect.width),
528
+ h: Math.round(rect.height),
529
+ },
530
+ }
531
+ })
532
+ // console.log('[TVFocus] Sample elements:', sample)
533
+ }
534
+ }
535
+
536
+ handleKeyDown(e) {
537
+ this._dispatchPageKeyDown(e)
538
+
539
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'].includes(e.key)) {
540
+ return
541
+ }
542
+
543
+ e.preventDefault()
544
+
545
+ switch (e.key) {
546
+ case 'ArrowUp':
547
+ this.moveFocus('up')
548
+ break
549
+ case 'ArrowDown':
550
+ this.moveFocus('down')
551
+ break
552
+ case 'ArrowLeft':
553
+ this.moveFocus('left')
554
+ break
555
+ case 'ArrowRight':
556
+ this.moveFocus('right')
557
+ break
558
+ case 'Enter':
559
+ this.clickFocused()
560
+ break
561
+ case 'Escape':
562
+ this.dispatchBackPressed(e)
563
+ break
564
+ }
565
+ }
566
+
567
+ _dispatchPageKeyDown(e) {
568
+ const keyEvent = this._toESKeyEvent(e)
569
+ // action: 0 = keydown, 1 = keyup
570
+ keyEvent.action = 0
571
+
572
+ // 模拟 Android 平台发送 DispatchKeyEvent
573
+ try {
574
+ const { EventBus } = require('@extscreen/es3-vue')
575
+ if (EventBus && EventBus.$emit) {
576
+ EventBus.$emit('DispatchKeyEvent', keyEvent)
577
+ }
578
+ } catch (err) {
579
+ // fallback to direct method call
580
+ this._callCurrentPageMethod('onKeyDown', keyEvent)
581
+ }
582
+ }
583
+
584
+ _dispatchPageKeyUp(e) {
585
+ const keyEvent = this._toESKeyEvent(e)
586
+ // action: 0 = keydown, 1 = keyup
587
+ keyEvent.action = 1
588
+
589
+ // 模拟 Android 平台发送 DispatchKeyEvent
590
+ try {
591
+ const { EventBus } = require('@extscreen/es3-vue')
592
+ if (EventBus && EventBus.$emit) {
593
+ EventBus.$emit('DispatchKeyEvent', keyEvent)
594
+ }
595
+ } catch (err) {
596
+ // fallback to direct method call
597
+ this._callCurrentPageMethod('onKeyUp', keyEvent)
598
+ }
599
+ }
600
+
601
+ _toESKeyEvent(e) {
602
+ const domKey = e?.key
603
+ let keyCode = e?.keyCode ?? e?.which
604
+
605
+ switch (domKey) {
606
+ case 'ArrowUp':
607
+ keyCode = 19
608
+ break
609
+ case 'ArrowDown':
610
+ keyCode = 20
611
+ break
612
+ case 'ArrowLeft':
613
+ keyCode = 21
614
+ break
615
+ case 'ArrowRight':
616
+ keyCode = 22
617
+ break
618
+ case 'Enter':
619
+ keyCode = 23
620
+ break
621
+ case 'Escape':
622
+ keyCode = 4
623
+ break
624
+ }
625
+
626
+ return {
627
+ keyCode,
628
+ key: domKey,
629
+ code: e?.code,
630
+ repeat: e?.repeat,
631
+ altKey: e?.altKey,
632
+ ctrlKey: e?.ctrlKey,
633
+ shiftKey: e?.shiftKey,
634
+ metaKey: e?.metaKey,
635
+ nativeEvent: e,
636
+ }
637
+ }
638
+
639
+ _getCurrentPageVueInstance() {
640
+ // 从页面栈获取栈顶实例
641
+ const stack = global.__ES_PAGE_STACK__
642
+ if (stack && stack.length > 0) {
643
+ const top = stack[stack.length - 1]
644
+ if (top && (top.exposed || top.proxy || top.setupState)) {
645
+ return top
646
+ }
647
+ }
648
+
649
+ const currentPage = this._getCurrentPage()
650
+ if (!currentPage) return null
651
+ return this._findVueInstanceInSubtree(currentPage)
652
+ }
653
+
654
+ _findVueInstanceInSubtree(rootEl) {
655
+ const queue = [rootEl]
656
+ const visited = new Set()
657
+ let scanned = 0
658
+
659
+ while (queue.length > 0 && scanned < 200) {
660
+ const el = queue.shift()
661
+ if (!el || visited.has(el)) continue
662
+ visited.add(el)
663
+ scanned += 1
664
+
665
+ const inst = el.__vueParentComponent || el.__vue__
666
+ if (inst && (inst.exposed || inst.proxy)) {
667
+ return inst
668
+ }
669
+
670
+ const children = el.children
671
+ if (children && children.length > 0) {
672
+ for (let i = 0; i < children.length; i++) {
673
+ queue.push(children[i])
674
+ }
675
+ }
676
+ }
677
+
678
+ return null
679
+ }
680
+
681
+ _callCurrentPageMethod(methodName, arg) {
682
+ const inst = this._getCurrentPageVueInstance()
683
+ if (!inst) return false
684
+
685
+ // 优先从 setupState 获取,然后是 exposed,最后是 proxy
686
+ const fn =
687
+ inst?.setupState?.[methodName] || inst?.exposed?.[methodName] || inst?.proxy?.[methodName]
688
+ if (typeof fn !== 'function') return false
689
+
690
+ try {
691
+ const result = fn(arg)
692
+ if (result && typeof result.then === 'function') {
693
+ result.catch((err) => console.error(`[TVFocus] ${methodName} rejected:`, err))
694
+ }
695
+ } catch (err) {
696
+ console.error(`[TVFocus] ${methodName} threw:`, err)
697
+ }
698
+
699
+ return true
700
+ }
701
+
702
+ moveFocus(direction) {
703
+ if (this.focusableElements.length === 0) {
704
+ this.updateFocusableElements()
705
+ if (this.focusableElements.length === 0) return
706
+ }
707
+
708
+ // 如果没有焦点元素,聚焦第一个
709
+ if (!this.focusedElement) {
710
+ this._doFocusElement(this.focusableElements[0])
711
+ return
712
+ }
713
+
714
+ const current = this.focusedElement
715
+
716
+ // Check if the current element blocks focus movement in this direction
717
+ if (this._isDirectionBlocked(current, direction)) {
718
+ this._log('[TVFocus] Direction', direction, 'is blocked for current element')
719
+ return
720
+ }
721
+
722
+ // Check if element is still valid and in the DOM
723
+ if (!document.body.contains(current)) {
724
+ console.warn(
725
+ '[TVFocus] Current focused element no longer in DOM, refreshing focusable elements'
726
+ )
727
+ this.updateFocusableElements()
728
+ if (this.focusableElements.length > 0) {
729
+ this._doFocusElement(this.focusableElements[0])
730
+ }
731
+ return
732
+ }
733
+
734
+ // 检查是否在 FastListView 中,如果是则委托处理
735
+ // 但是,由于有 List 嵌套 List (如 Waterfall 嵌套 Section) 的情况,
736
+ // 局部的 FastListView 可能无法找到合适的焦点,此时应该继续让全局基于距离寻找。
737
+ let fastListViewCandidate = null
738
+ if (!this.options.useDistanceBasedNavigation) {
739
+ fastListViewCandidate = this._tryFastListViewNavigation(current, direction)
740
+ if (fastListViewCandidate) {
741
+ this._doFocusElement(fastListViewCandidate)
742
+ return
743
+ }
744
+ }
745
+
746
+ // 根据配置选择导航方式
747
+ let bestCandidate = null
748
+ if (this.options.useDistanceBasedNavigation) {
749
+ bestCandidate = this._findBestCandidateByDistance(current, direction)
750
+ } else {
751
+ bestCandidate = this._findBestCandidateSequential(current, direction)
752
+ }
753
+
754
+ if (bestCandidate) {
755
+ if (this.options.useDistanceBasedNavigation) {
756
+ this._triggerBoundaryScrollOnExitIfNeeded(current, bestCandidate, direction)
757
+ }
758
+ this._doFocusElement(bestCandidate)
759
+ } else {
760
+ // 全局寻焦没找到候选者(到达了真正的边界)
761
+ // 此时,如果当前元素在某个 List 中,且方向是向边界方向,
762
+ // 我们需要触发该 List 的边界滚动(如回滚到顶部/底部)。
763
+ if (this.options.useDistanceBasedNavigation) {
764
+ this._triggerBoundaryScrollIfNeeded(current, direction)
765
+ }
766
+ }
767
+ }
768
+
769
+ _triggerBoundaryScrollOnExitIfNeeded(current, next, direction) {
770
+ if (!current || !next) return
771
+ let parent = current.parentElement
772
+ while (parent && parent !== document.body) {
773
+ if (parent.__fastListViewInstance) {
774
+ if (!parent.contains(next)) {
775
+ const fastList = parent.__fastListViewInstance
776
+ if (fastList && typeof fastList._scrollToBoundary === 'function') {
777
+ fastList._scrollToBoundary(direction, current)
778
+ }
779
+ }
780
+ }
781
+ parent = parent.parentElement
782
+ }
783
+ }
784
+
785
+ /**
786
+ * 当焦点无法继续移动时,检查是否需要触发列表的边界滚动
787
+ */
788
+ _triggerBoundaryScrollIfNeeded(current, direction) {
789
+ // 向上查找所有可能包含滚动逻辑的容器
790
+ let parent = current.parentElement
791
+ while (parent && parent !== document.body) {
792
+ if (parent.__fastListViewInstance) {
793
+ const fastList = parent.__fastListViewInstance
794
+
795
+ // 我们需要判断当前焦点是否处于这个列表的“逻辑第一行”或“逻辑最后一行”
796
+ // 最简单的方法是直接调用它的滚动边界方法,
797
+ // 或者让 fastList 内部判断并执行
798
+ if (typeof fastList._scrollToBoundary === 'function') {
799
+ // FastListView 会根据方向判断是否需要滚动,传入 current 让它自己判断是否在边缘
800
+ fastList._scrollToBoundary(direction, current)
801
+ // 我们继续向上遍历,因为可能有多层嵌套列表都需要响应(比如内层是水平列表,外层是垂直列表)
802
+ // 但对于同一个方向,通常只处理最近的一层即可。
803
+ // 不过为了安全起见,这里不 break,让所有的父列表都有机会检查边界
804
+ }
805
+ }
806
+ parent = parent.parentElement
807
+ }
808
+ }
809
+
810
+ /**
811
+ * 尝试 FastListView 焦点导航
812
+ * 如果当前焦点在 FastListView 内,委托给 FastListView 处理
813
+ */
814
+ _tryFastListViewNavigation(current, direction) {
815
+ // 向上查找 FastListView 容器
816
+ let parent = current.parentElement
817
+ let fastListViewInstance = null
818
+
819
+ while (parent) {
820
+ if (parent.__fastListViewInstance) {
821
+ fastListViewInstance = parent.__fastListViewInstance
822
+ break
823
+ }
824
+ parent = parent.parentElement
825
+ }
826
+
827
+ if (!fastListViewInstance) return null
828
+
829
+ // 如果启用了基于距离的全局导航,我们不再依赖局部的 FastListView 导航
830
+ // 直接让外层基于全屏坐标计算焦点,这样可以无缝处理 List 嵌套 List (如 Waterfall) 的问题
831
+ // 在复杂的嵌套布局中,局部的 _handleFocusNavigation 往往会因为视野受限而找不到最优解。
832
+ // 但是,全局导航无法处理列表滚动边界的特殊需求(比如焦点在列表顶端,按上应该让列表滚动到最顶部)。
833
+ // 所以我们依然让局部处理一下,但告诉它不要返回“未找到”或者处理失败,而是让它专注于“边界滚动”或特殊的内部逻辑。
834
+
835
+ // 我们可以在全局寻找之后,如果找不到合适的焦点,再考虑是不是触发了边界滚动。
836
+ // 或者更优雅的做法是:全局寻焦只负责“找焦点”。
837
+ // 当找到下一个焦点时,我们判断这个焦点是否导致了某个容器(List)的滚动。
838
+ // 但是如果找不到焦点(比如已经在最上面了),我们依然需要通知相关的容器“你触顶了”。
839
+
840
+ // 为了兼容滚动到顶部的逻辑,我们依然调用一下局部的处理,
841
+ // 但是我们给它传一个特殊的标记,让它只处理边界逻辑,不处理焦点导航。
842
+ // 由于我们不能修改 API 签名太多,这里我们直接返回 null,把边界滚动逻辑放在焦点没有改变时处理。
843
+ if (this.options.useDistanceBasedNavigation) {
844
+ return null
845
+ }
846
+
847
+ // 委托给 FastListView 处理
848
+ const nextElement = fastListViewInstance.handleFocusNavigation(current, direction)
849
+ if (nextElement) {
850
+ this._log('[TVFocus] FastListView handled navigation:', direction)
851
+ return nextElement
852
+ }
853
+
854
+ return null
855
+ }
856
+
857
+ /**
858
+ * 根据距离计算找到最佳候选元素
859
+ */
860
+ _findBestCandidateByDistance(current, direction) {
861
+ const currentRect = current.getBoundingClientRect()
862
+ const currentCenter = {
863
+ x: currentRect.left + currentRect.width / 2,
864
+ y: currentRect.top + currentRect.height / 2,
865
+ }
866
+
867
+ let bestCandidate = null
868
+ let bestDistance = Infinity
869
+ const crossWeight = this.options.crossDistanceWeight
870
+
871
+ this.focusableElements.forEach((el) => {
872
+ // 跳过当前焦点元素
873
+ if (el === current) return
874
+
875
+ // Skip elements no longer in the DOM or hidden
876
+ if (!el || !document.body.contains(el)) return
877
+ const computedStyle = window.getComputedStyle(el)
878
+ if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') return
879
+
880
+ const rect = el.getBoundingClientRect()
881
+ // Skip 0-sized elements
882
+ if (rect.width === 0 && rect.height === 0) return
883
+
884
+ const center = {
885
+ x: rect.left + rect.width / 2,
886
+ y: rect.top + rect.height / 2,
887
+ }
888
+
889
+ // 计算向量:从当前元素指向目标元素
890
+ const dx = center.x - currentCenter.x
891
+ const dy = center.y - currentCenter.y
892
+
893
+ // 判断是否在指定方向上:方向分量 > 0
894
+ let isInDirection = false
895
+ let primaryDistance = 0
896
+ let crossDistance = 0
897
+
898
+ // 1. 基础方向判断:不仅看中心点,也看边界是否有足够覆盖,同时加一些像素容差避免被忽略。
899
+ switch (direction) {
900
+ case 'up':
901
+ isInDirection = dy < 0 || rect.bottom < currentRect.top + 10
902
+ primaryDistance = Math.max(0, currentRect.top - rect.bottom)
903
+ crossDistance = Math.abs(dx)
904
+ break
905
+ case 'down':
906
+ isInDirection = dy > 0 || rect.top > currentRect.bottom - 10
907
+ primaryDistance = Math.max(0, rect.top - currentRect.bottom)
908
+ crossDistance = Math.abs(dx)
909
+ break
910
+ case 'left':
911
+ isInDirection = dx < 0 || rect.right < currentRect.left + 10
912
+ primaryDistance = Math.max(0, currentRect.left - rect.right)
913
+ crossDistance = Math.abs(dy)
914
+ break
915
+ case 'right':
916
+ isInDirection = dx > 0 || rect.left > currentRect.right - 10
917
+ primaryDistance = Math.max(0, rect.left - currentRect.right)
918
+ crossDistance = Math.abs(dy)
919
+ break
920
+ }
921
+
922
+ // 2. 角度限制判断(Cone/Angle Limit)
923
+ // 如果按右,即使元素在右下角,如果角度太陡(> 45度),也不应该被认为是“右侧”的元素,而应该是“下侧”的元素。
924
+ // 我们通过计算中心点连线的夹角来判断。tan(theta) = crossDistance / primaryCenterDistance
925
+ // 当 crossDistance > primaryCenterDistance 时,角度 > 45度。
926
+ if (isInDirection) {
927
+ // 使用中心点距离来计算角度是最准确的
928
+ const absDx = Math.abs(dx)
929
+ const absDy = Math.abs(dy)
930
+ let angleExceeded = false
931
+
932
+ // 为了防止相邻元素(距离很近)因为细微的高低差导致角度变得极大而被误杀,
933
+ // 我们只对“非重叠”或者“主方向距离有一定跨度”的元素进行严格的角度限制。
934
+ if (direction === 'left' || direction === 'right') {
935
+ // 左右移动时,如果垂直偏移量(dy)大于水平偏移量(dx),则角度大于45度
936
+ // 添加 20px 的宽容度,避免相邻元素轻微错位被过滤
937
+ if (absDy > absDx + 20) {
938
+ angleExceeded = true
939
+ }
940
+ } else {
941
+ // 上下移动时,如果水平偏移量(dx)大于垂直偏移量(dy),则角度大于45度
942
+ if (absDx > absDy + 20) {
943
+ angleExceeded = true
944
+ }
945
+ }
946
+
947
+ if (angleExceeded) {
948
+ console.log(
949
+ `[TVFocus] Candidate rejected by >45 degree angle limit - tag: ${el.tagName}, dx: ${dx.toFixed(1)}, dy: ${dy.toFixed(1)}`
950
+ )
951
+ isInDirection = false
952
+ }
953
+ }
954
+
955
+ if (isInDirection) {
956
+ // --- 处理 blockFocusDirections 的边界拦截 ---
957
+ // 外层容器(比如 List)可能会设置 blockFocusDirections="left,right"。
958
+ // 它的目的是防止焦点“溢出”这个容器,而不是阻止容器内部子元素之间的移动。
959
+ // 所以我们检查候选元素 `el` 是否和当前元素 `current` 在同一个受限的容器内。
960
+ // 如果不在,说明这一步移动跨越了受限容器的边界,应当被阻断。
961
+ let isBlockedByContainer = false
962
+ let parent = current.parentElement
963
+ while (parent && parent !== document.body) {
964
+ const blockAttr =
965
+ parent.getAttribute('blockFocusDirections') ||
966
+ parent.getAttribute('blockfocusdirections')
967
+ if (blockAttr) {
968
+ // 解析方向
969
+ let blockedDirections = []
970
+ try {
971
+ blockedDirections = JSON.parse(blockAttr.replace(/'/g, '"'))
972
+ if (!Array.isArray(blockedDirections)) blockedDirections = []
973
+ } catch (e) {
974
+ blockedDirections = blockAttr
975
+ .split(',')
976
+ .map((d) => d.trim().toLowerCase())
977
+ .filter((d) => d)
978
+ }
979
+
980
+ // 如果这个容器限制了当前移动的方向
981
+ if (blockedDirections.some((d) => d.toLowerCase() === direction.toLowerCase())) {
982
+ // 检查候选元素是否依然在这个容器内
983
+ // 如果候选元素不在这个容器内,说明当前移动试图“逃出”该容器,必须被阻断
984
+ if (!parent.contains(el)) {
985
+ isBlockedByContainer = true
986
+ break
987
+ }
988
+ }
989
+ }
990
+ parent = parent.parentElement
991
+ }
992
+
993
+ // 如果被容器边界阻断,跳过该候选者
994
+ if (isBlockedByContainer) {
995
+ console.log(
996
+ `[TVFocus] Candidate rejected by container blockFocusDirections - tag: ${el.tagName}, id: ${el.id}`
997
+ )
998
+ return // return from forEach callback
999
+ }
1000
+
1001
+ // 改进距离算法:
1002
+ // 1. 如果在对应方向的同一直线上(例如横向移动时,y坐标重叠部分较大),侧向距离应该被忽略或极小化
1003
+ // 2. 如果不在同一直线上,侧向距离应该被放大(惩罚),从而优先选择同一行的元素
1004
+ let isOverlapping = false
1005
+ let overlapRatio = 0
1006
+
1007
+ if (direction === 'left' || direction === 'right') {
1008
+ // 判断垂直方向是否有重叠
1009
+ const overlapY = Math.max(
1010
+ 0,
1011
+ Math.min(rect.bottom, currentRect.bottom) - Math.max(rect.top, currentRect.top)
1012
+ )
1013
+ isOverlapping = overlapY > 0
1014
+ overlapRatio = overlapY / Math.min(rect.height, currentRect.height)
1015
+
1016
+ if (!isOverlapping) {
1017
+ // 如果完全没有重叠,说明在不同行,施加极大惩罚,避免焦点跳行
1018
+ crossDistance *= 50
1019
+ } else {
1020
+ // 如果有重叠,根据重叠比例来减少侧向距离的影响
1021
+ // 重叠越多,侧向距离影响越小
1022
+ crossDistance *= 1 - overlapRatio * 0.9
1023
+ }
1024
+ } else if (direction === 'up' || direction === 'down') {
1025
+ // 判断水平方向是否有重叠
1026
+ const overlapX = Math.max(
1027
+ 0,
1028
+ Math.min(rect.right, currentRect.right) - Math.max(rect.left, currentRect.left)
1029
+ )
1030
+ isOverlapping = overlapX > 0
1031
+ overlapRatio = overlapX / Math.min(rect.width, currentRect.width)
1032
+
1033
+ if (!isOverlapping) {
1034
+ // 如果完全没有重叠,说明在不同列,施加极大惩罚
1035
+ crossDistance *= 50
1036
+ } else {
1037
+ // 如果有重叠,说明在同一列,降低侧向距离的影响
1038
+ crossDistance *= 1 - overlapRatio * 0.9
1039
+ }
1040
+ }
1041
+
1042
+ // 为了防止“跨行/跨列斜向移动”的优先级高于“本行/本列稍远距离的移动”
1043
+ // 我们需要把主方向距离和加权后的侧向距离相加,
1044
+ // 并且如果是不重叠的(跨行/列),给予一个基础的巨大惩罚值
1045
+ const distance = primaryDistance + crossDistance + (isOverlapping ? 0 : 10000)
1046
+
1047
+ console.log(
1048
+ `[TVFocus] Candidate - tag: ${el.tagName}, id: ${el.id}, dx: ${dx.toFixed(1)}, dy: ${dy.toFixed(1)}, primary: ${primaryDistance.toFixed(1)}, cross: ${crossDistance.toFixed(1)}, overlap: ${isOverlapping}, distance: ${distance.toFixed(1)}`
1049
+ )
1050
+
1051
+ if (distance < bestDistance) {
1052
+ bestDistance = distance
1053
+ bestCandidate = el
1054
+ }
1055
+ }
1056
+ })
1057
+
1058
+ if (bestCandidate) {
1059
+ console.log(
1060
+ `[TVFocus] Best candidate found for direction ${direction}, distance: ${bestDistance.toFixed(1)}`,
1061
+ bestCandidate
1062
+ )
1063
+ } else {
1064
+ console.log(`[TVFocus] No candidate found for direction ${direction}`)
1065
+ }
1066
+
1067
+ return bestCandidate
1068
+ }
1069
+
1070
+ /**
1071
+ * 简单顺序导航:按 DOM 顺序找到下一个可聚焦元素
1072
+ */
1073
+ _findBestCandidateSequential(current, direction) {
1074
+ const currentIndex = this.focusableElements.indexOf(current)
1075
+ if (currentIndex === -1) return null
1076
+
1077
+ // 根据方向决定查找顺序
1078
+ if (direction === 'down' || direction === 'right') {
1079
+ // 向下/向右:查找后面的元素
1080
+ for (let i = currentIndex + 1; i < this.focusableElements.length; i++) {
1081
+ const el = this.focusableElements[i]
1082
+ if (el && document.body.contains(el)) {
1083
+ return el
1084
+ }
1085
+ }
1086
+ } else if (direction === 'up' || direction === 'left') {
1087
+ // 向上/向左:查找前面的元素
1088
+ for (let i = currentIndex - 1; i >= 0; i--) {
1089
+ const el = this.focusableElements[i]
1090
+ if (el && document.body.contains(el)) {
1091
+ return el
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ return null
1097
+ }
1098
+
1099
+ // Check if the element blocks focus movement in a specific direction
1100
+ // blockFocusDirections can be:
1101
+ // - Array: ['left', 'right'] or ['up', 'down', 'left', 'right']
1102
+ // - String (JSON array): "['left', 'right']" or '["left", "right"]'
1103
+ // direction can be:
1104
+ // - String: 'left', 'up', 'right', 'down'
1105
+ // - Number: QTFocusDirection enum value (17, 33, 66, 130)
1106
+ _isDirectionBlocked(element, direction) {
1107
+ // Check element and all its parents up to the body
1108
+ let currentEl = element
1109
+ while (currentEl && currentEl !== document.body) {
1110
+ const blockAttr =
1111
+ currentEl.getAttribute('blockFocusDirections') ||
1112
+ currentEl.getAttribute('blockfocusdirections')
1113
+
1114
+ if (blockAttr) {
1115
+ // Convert direction number to name if needed
1116
+ let directionName = direction
1117
+ if (typeof direction === 'number') {
1118
+ directionName = this._directionNumberToName(direction)
1119
+ // If it's forward/backward, they don't block
1120
+ if (!directionName || directionName === 'forward' || directionName === 'backward') {
1121
+ return false
1122
+ }
1123
+ }
1124
+
1125
+ let blockedDirections = []
1126
+
1127
+ try {
1128
+ // Try to parse as JSON array
1129
+ // Handle both single quotes and double quotes
1130
+ const normalized = blockAttr.replace(/'/g, '"')
1131
+ blockedDirections = JSON.parse(normalized)
1132
+
1133
+ if (!Array.isArray(blockedDirections)) {
1134
+ blockedDirections = []
1135
+ }
1136
+ } catch (e) {
1137
+ // If not valid JSON, try comma-separated string
1138
+ blockedDirections = blockAttr
1139
+ .split(',')
1140
+ .map((d) => d.trim().toLowerCase())
1141
+ .filter((d) => d)
1142
+ }
1143
+
1144
+ // Normalize direction for comparison
1145
+ const normalizedDirection = String(directionName).toLowerCase()
1146
+
1147
+ if (blockedDirections.some((blocked) => blocked.toLowerCase() === normalizedDirection)) {
1148
+ // 找到了 blockFocusDirections 配置
1149
+
1150
+ // 【核心修复】:判断是不是真正的“溢出边缘”
1151
+ // 如果当前元素配置了 blockFocusDirections,但我们要寻找的下一个焦点依然在这个容器内部,
1152
+ // 那么不应该被阻断!block 仅仅是防止焦点逃出这个容器。
1153
+ // 但是 _isDirectionBlocked 是在找下一个元素前调用的,我们怎么知道下一个元素在不在容器里?
1154
+ // 所以这里我们不再进行彻底阻断,而是返回 `false` 放行,
1155
+ // 把容器的边界判断交给 `_findBestCandidateByDistance` 中的后续逻辑去处理,
1156
+ // 或者在这里,仅仅当“当前获取焦点的元素,已经处于该容器的最边缘”时,才阻断。
1157
+
1158
+ // 既然如此,我们只允许元素自身的 block 彻底阻断。
1159
+ // 对于父容器的 block,我们在这里一律不阻断。
1160
+ if (currentEl === element) {
1161
+ return true
1162
+ }
1163
+ }
1164
+ }
1165
+ currentEl = currentEl.parentElement
1166
+ }
1167
+
1168
+ return false
1169
+ }
1170
+
1171
+ /**
1172
+ * 解析 showOnState 属性值
1173
+ * @param {string} attrValue - showOnState 属性值
1174
+ * @returns {string[]} - 状态数组
1175
+ */
1176
+ _parseShowOnState(attrValue) {
1177
+ if (!attrValue) return null
1178
+
1179
+ // 尝试解析为 JSON 数组
1180
+ try {
1181
+ const parsed = JSON.parse(attrValue)
1182
+ if (Array.isArray(parsed)) {
1183
+ return parsed.map((s) => String(s).toLowerCase())
1184
+ }
1185
+ } catch (e) {
1186
+ // 不是 JSON,作为单个状态字符串处理
1187
+ }
1188
+
1189
+ // 作为单个状态字符串处理
1190
+ return [String(attrValue).toLowerCase()]
1191
+ }
1192
+
1193
+ /**
1194
+ * 检查元素是否应该在指定状态显示
1195
+ * @param {HTMLElement} element - 要检查的元素
1196
+ * @param {string} state - 当前状态 ('normal', 'focused', 'selected')
1197
+ * @returns {boolean|null} - 是否应该显示,null 表示没有 showOnState 属性
1198
+ */
1199
+ _shouldShowOnState(element, state) {
1200
+ const showOnStateAttr = element.getAttribute('showOnState')
1201
+ if (!showOnStateAttr) return null
1202
+
1203
+ const states = this._parseShowOnState(showOnStateAttr)
1204
+ if (!states || states.length === 0) return null
1205
+
1206
+ return states.includes(state.toLowerCase())
1207
+ }
1208
+
1209
+ /**
1210
+ * 更新元素及其子元素的 showOnState 可见性
1211
+ * @param {HTMLElement} element - 父元素
1212
+ * @param {string} state - 当前状态 ('normal', 'focused', 'selected')
1213
+ */
1214
+ _updateShowOnStateVisibility(element, state) {
1215
+ // 更新元素自身的可见性
1216
+ const shouldShow = this._shouldShowOnState(element, state)
1217
+ if (shouldShow !== null) {
1218
+ if (shouldShow) {
1219
+ element.style.display = ''
1220
+ element.style.visibility = ''
1221
+ } else {
1222
+ element.style.display = 'none'
1223
+ }
1224
+ }
1225
+
1226
+ // 更新所有带 showOnState 属性的子元素
1227
+ const showOnStateChildren = element.querySelectorAll('[showOnState]')
1228
+ showOnStateChildren.forEach((child) => {
1229
+ const childShouldShow = this._shouldShowOnState(child, state)
1230
+ if (childShouldShow !== null) {
1231
+ if (childShouldShow) {
1232
+ child.style.display = ''
1233
+ child.style.visibility = ''
1234
+ } else {
1235
+ child.style.display = 'none'
1236
+ }
1237
+ }
1238
+ })
1239
+ }
1240
+
1241
+ /**
1242
+ * 初始化所有带 showOnState 属性元素的可见性
1243
+ * 默认设置为 'normal' 状态
1244
+ */
1245
+ _initShowOnStateVisibility() {
1246
+ const showOnStateElements = document.querySelectorAll('[showOnState]')
1247
+ showOnStateElements.forEach((element) => {
1248
+ // 检查元素是否有 duplicateParentState 属性
1249
+ const hasDuplicateParentState =
1250
+ element.hasAttribute('duplicateParentState') || element.hasAttribute('duplicateparentstate')
1251
+
1252
+ // 检查元素是否在已获得焦点的元素内
1253
+ const focusedParent = element.closest('.focused')
1254
+
1255
+ if (focusedParent) {
1256
+ // 如果在已获得焦点的元素内,使用 focused 状态
1257
+ this._updateShowOnStateVisibilityForElement(element, 'focused')
1258
+ } else {
1259
+ // 否则使用 normal 状态
1260
+ this._updateShowOnStateVisibilityForElement(element, 'normal')
1261
+ }
1262
+ })
1263
+ }
1264
+
1265
+ /**
1266
+ * 更新单个元素的 showOnState 可见性
1267
+ * @param {HTMLElement} element - 元素
1268
+ * @param {string} state - 当前状态
1269
+ */
1270
+ _updateShowOnStateVisibilityForElement(element, state) {
1271
+ const shouldShow = this._shouldShowOnState(element, state)
1272
+ if (shouldShow !== null) {
1273
+ if (shouldShow) {
1274
+ element.style.display = ''
1275
+ element.style.visibility = ''
1276
+ } else {
1277
+ element.style.display = 'none'
1278
+ }
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * 初始化元素及其子元素的 showOnState 可见性(公开方法)
1284
+ * 用于动态创建的元素(如列表项)的初始化
1285
+ * @param {HTMLElement} container - 容器元素
1286
+ */
1287
+ initShowOnStateForElement(container) {
1288
+ if (!container) return
1289
+
1290
+ // 查找容器内所有带 showOnState 属性的元素
1291
+ const showOnStateElements = container.querySelectorAll('[showOnState]')
1292
+ console.log(
1293
+ '[TVFocusManager] initShowOnStateForElement found',
1294
+ showOnStateElements.length,
1295
+ 'elements with showOnState'
1296
+ )
1297
+
1298
+ showOnStateElements.forEach((element) => {
1299
+ // 检查元素是否在已获得焦点的元素内
1300
+ const focusedParent = element.closest('.focused')
1301
+ const selectedParent = element.closest('[selected="true"], [selected=""]')
1302
+
1303
+ if (focusedParent) {
1304
+ this._updateShowOnStateVisibilityForElement(element, 'focused')
1305
+ } else if (selectedParent) {
1306
+ this._updateShowOnStateVisibilityForElement(element, 'selected')
1307
+ } else {
1308
+ this._updateShowOnStateVisibilityForElement(element, 'normal')
1309
+ }
1310
+ })
1311
+ }
1312
+
1313
+ _getFocusProps(element) {
1314
+ // Get computed style for CSS variable reading and original borderRadius
1315
+ const computedStyle = window.getComputedStyle(element)
1316
+
1317
+ // 获取元素原本的 borderRadius 作为默认值
1318
+ const originalBorderRadius = computedStyle.borderRadius
1319
+ let defaultBorderRadius = this.defaultFocusStyle.border.cornerRadius || 8
1320
+ if (originalBorderRadius && originalBorderRadius !== 'none' && originalBorderRadius !== '0px') {
1321
+ // 解析 borderRadius,可能包含多个值(如 "8px 8px 8px 8px")
1322
+ const radiusMatch = originalBorderRadius.match(/^(\d+(?:\.\d+)?)(px)?/)
1323
+ if (radiusMatch) {
1324
+ defaultBorderRadius = parseFloat(radiusMatch[1])
1325
+ }
1326
+ }
1327
+
1328
+ // Use global focus config as defaults
1329
+ const props = {
1330
+ enableBorder: false, // 默认不显示边框
1331
+ borderColor: this.defaultFocusStyle.border.color,
1332
+ borderWidth: this.defaultFocusStyle.border.width,
1333
+ borderRadius: defaultBorderRadius,
1334
+ innerBorderColor: this.defaultFocusStyle.border.innerColor,
1335
+ innerBorderWidth: this.defaultFocusStyle.border.innerWidth,
1336
+ innerBorderEnabled: this.defaultFocusStyle.border.innerEnabled !== false,
1337
+ scale: this.defaultFocusStyle.scale,
1338
+ focusBackgroundColor: null,
1339
+ focusColor: null,
1340
+ }
1341
+
1342
+ const enableFocusBorder =
1343
+ element.getAttribute('enableFocusBorder') || element.getAttribute('enable-focus-border')
1344
+ // 只有显式设置 enableFocusBorder="true" 或 "1" 才启用边框
1345
+ if (enableFocusBorder === 'true' || enableFocusBorder === '1' || enableFocusBorder === '') {
1346
+ props.enableBorder = true
1347
+ }
1348
+
1349
+ const focusScale =
1350
+ element.getAttribute('focusScale') ||
1351
+ element.getAttribute('focus-scale') ||
1352
+ element.getAttribute('data-focus-scale') ||
1353
+ computedStyle.getPropertyValue('--focus-scale')?.trim()
1354
+ if (focusScale) {
1355
+ const scale = parseFloat(focusScale)
1356
+ if (!isNaN(scale) && scale > 0) {
1357
+ props.scale = scale
1358
+ }
1359
+ }
1360
+
1361
+ const focusBorderRadius =
1362
+ element.getAttribute('focusBorderRadius') ||
1363
+ element.getAttribute('focus-border-radius') ||
1364
+ element.getAttribute('data-focus-border-radius') ||
1365
+ computedStyle.getPropertyValue('--focus-border-radius')?.trim()
1366
+ if (focusBorderRadius) {
1367
+ const radius = parseFloat(focusBorderRadius)
1368
+ if (!isNaN(radius) && radius >= 0) {
1369
+ props.borderRadius = radius
1370
+ }
1371
+ }
1372
+
1373
+ const focusBorderWidth =
1374
+ element.getAttribute('focusBorderWidth') ||
1375
+ element.getAttribute('focus-border-width') ||
1376
+ element.getAttribute('data-focus-border-width') ||
1377
+ computedStyle.getPropertyValue('--focus-border-width')?.trim()
1378
+ if (focusBorderWidth) {
1379
+ const width = parseFloat(focusBorderWidth)
1380
+ if (!isNaN(width) && width >= 0) {
1381
+ props.borderWidth = width
1382
+ }
1383
+ }
1384
+
1385
+ const focusBorderColor =
1386
+ element.getAttribute('focusBorderColor') ||
1387
+ element.getAttribute('focus-border-color') ||
1388
+ element.getAttribute('data-focus-border-color') ||
1389
+ computedStyle.getPropertyValue('--focus-border-color')?.trim()
1390
+ if (focusBorderColor) {
1391
+ props.borderColor = focusBorderColor
1392
+ }
1393
+
1394
+ const focusBorderAttr = element.getAttribute('data-focus-border')
1395
+ if (focusBorderAttr) {
1396
+ props.enableBorder = focusBorderAttr === 'true'
1397
+ }
1398
+
1399
+ // Focus background color - check attributes first, then CSS variables
1400
+ const focusBackgroundColor =
1401
+ element.getAttribute('focusBackgroundColor') ||
1402
+ element.getAttribute('focus-background-color') ||
1403
+ element.getAttribute('data-focus-background-color') ||
1404
+ computedStyle.getPropertyValue('--focus-background-color')?.trim() ||
1405
+ computedStyle.getPropertyValue('--focus-background')?.trim()
1406
+ if (focusBackgroundColor) {
1407
+ props.focusBackgroundColor = focusBackgroundColor
1408
+ }
1409
+
1410
+ // Focus text color - check attributes first, then CSS variables
1411
+ const focusColor =
1412
+ element.getAttribute('focusColor') ||
1413
+ element.getAttribute('focus-color') ||
1414
+ element.getAttribute('data-focus-color') ||
1415
+ computedStyle.getPropertyValue('--focus-color')?.trim()
1416
+ if (focusColor) {
1417
+ props.focusColor = focusColor
1418
+ }
1419
+
1420
+ return props
1421
+ }
1422
+
1423
+ // 内部方法:直接设置焦点元素
1424
+ _doFocusElement(element) {
1425
+ if (!element) return
1426
+
1427
+ const prevFocused = this.focusedElement
1428
+ if (prevFocused) {
1429
+ this._maybeNotifyFastListItemFocusChanged(prevFocused, false)
1430
+ this._removeFocusStyle(prevFocused)
1431
+ }
1432
+
1433
+ this.focusedElement = element
1434
+
1435
+ this._applyFocusStyle(this.focusedElement)
1436
+ this.focusedElement.classList.add('focused')
1437
+ this.focusedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
1438
+ this._maybeScrollFastListToStart(this.focusedElement)
1439
+ this._maybeUpdateFastListBlockFocusDirections(this.focusedElement)
1440
+ this._maybeNotifyFastListItemFocusChanged(this.focusedElement, true)
1441
+
1442
+ // console.log('[TVFocus] Focused element', element)
1443
+ }
1444
+
1445
+ _maybeNotifyFastListItemFocusChanged(element, hasFocus) {
1446
+ if (!element) return
1447
+ let parent = element.parentElement
1448
+ let fastListViewInstance = null
1449
+ while (parent) {
1450
+ if (parent.__fastListViewInstance) {
1451
+ fastListViewInstance = parent.__fastListViewInstance
1452
+ break
1453
+ }
1454
+ parent = parent.parentElement
1455
+ }
1456
+ if (fastListViewInstance && typeof fastListViewInstance.notifyItemFocusChanged === 'function') {
1457
+ fastListViewInstance.notifyItemFocusChanged(element, hasFocus)
1458
+ }
1459
+ }
1460
+
1461
+ _maybeUpdateFastListBlockFocusDirections(element) {
1462
+ if (!element) return
1463
+ let parent = element.parentElement
1464
+ let fastListViewInstance = null
1465
+ while (parent) {
1466
+ if (parent.__fastListViewInstance) {
1467
+ fastListViewInstance = parent.__fastListViewInstance
1468
+ break
1469
+ }
1470
+ parent = parent.parentElement
1471
+ }
1472
+ if (
1473
+ fastListViewInstance &&
1474
+ typeof fastListViewInstance.updateBlockFocusDirections === 'function'
1475
+ ) {
1476
+ fastListViewInstance.updateBlockFocusDirections(element)
1477
+ }
1478
+ }
1479
+
1480
+ _maybeScrollFastListToStart(element) {
1481
+ if (!element || !element.closest) return
1482
+
1483
+ const item = element.closest('[data-position]') || element.closest('[data-index]')
1484
+ if (!item) return
1485
+
1486
+ const rawPos = item.getAttribute('data-position') ?? item.getAttribute('data-index')
1487
+ if (rawPos === null || rawPos === undefined) return
1488
+ const pos = Number(rawPos)
1489
+ if (!Number.isFinite(pos) || pos !== 0) return
1490
+
1491
+ const container = element.closest('.fast-list-item-container')
1492
+ if (!container) return
1493
+
1494
+ const style = window.getComputedStyle(container)
1495
+ const isHorizontal =
1496
+ style.flexDirection === 'row' || style.overflowX === 'auto' || style.overflowX === 'scroll'
1497
+ if (!isHorizontal) return
1498
+
1499
+ if (container.scrollLeft > 0) {
1500
+ try {
1501
+ container.scrollTo({ left: 0, behavior: 'smooth' })
1502
+ } catch (e) {
1503
+ container.scrollLeft = 0
1504
+ }
1505
+ }
1506
+
1507
+ const parent = container.parentElement
1508
+ if (parent && parent.scrollLeft > 0) {
1509
+ try {
1510
+ parent.scrollTo({ left: 0, behavior: 'smooth' })
1511
+ } catch (e) {
1512
+ parent.scrollLeft = 0
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ // 兼容方法:通过索引设置焦点
1518
+ setFocus(index) {
1519
+ if (index < 0 || index >= this.focusableElements.length) return
1520
+ this._doFocusElement(this.focusableElements[index])
1521
+ }
1522
+
1523
+ _applyFocusStyle(element) {
1524
+ const props = this._getFocusProps(element)
1525
+
1526
+ const originalStyles = {
1527
+ transform: element.style.transform,
1528
+ outline: element.style.outline,
1529
+ outlineOffset: element.style.outlineOffset,
1530
+ boxShadow: element.style.boxShadow,
1531
+ transition: element.style.transition,
1532
+ zIndex: element.style.zIndex,
1533
+ borderRadius: element.style.borderRadius,
1534
+ backgroundColor: element.style.backgroundColor,
1535
+ color: element.style.color,
1536
+ display: element.style.display,
1537
+ visibility: element.style.visibility,
1538
+ }
1539
+ this.focusedElementOriginalStyles.set(element, originalStyles)
1540
+
1541
+ element.style.transition = this.defaultFocusStyle.transition
1542
+
1543
+ if (props.scale !== 1.0) {
1544
+ element.style.transform = `scale(${props.scale})`
1545
+ element.style.zIndex = '10'
1546
+ }
1547
+
1548
+ if (props.enableBorder) {
1549
+ element.style.borderRadius = props.borderRadius + 'px'
1550
+ const spread = props.borderWidth
1551
+ const innerSpread = props.innerBorderWidth
1552
+ // Only add inner border if enabled
1553
+ const innerShadow = props.innerBorderEnabled
1554
+ ? `inset 0 0 0 ${innerSpread}px ${props.innerBorderColor}`
1555
+ : ''
1556
+ element.style.boxShadow = `0 0 0 ${spread}px ${props.borderColor}, ${innerShadow}, 0 4px 12px rgba(0,0,0,0.3)`
1557
+ element.style.outline = 'none'
1558
+ }
1559
+
1560
+ // Apply focusBackgroundColor if specified
1561
+ if (props.focusBackgroundColor) {
1562
+ element.style.backgroundColor = props.focusBackgroundColor
1563
+ }
1564
+
1565
+ // Apply focusColor if specified (for elements that have text)
1566
+ if (props.focusColor) {
1567
+ element.style.color = props.focusColor
1568
+ }
1569
+
1570
+ // Handle duplicateParentState children - apply focus styles to children with duplicateParentState="true"
1571
+ this._applyDuplicateParentStateStyles(element)
1572
+
1573
+ // Handle showOnState - update visibility based on focused state
1574
+ this._updateShowOnStateVisibility(element, 'focused')
1575
+
1576
+ requestAnimationFrame(() => syncDomAutoWidthIn(element))
1577
+ }
1578
+
1579
+ // Apply focus styles to children with duplicateParentState attribute
1580
+ // Supports: duplicateParentState, duplicateParentState="true", duplicateParentState=""
1581
+ _applyDuplicateParentStateStyles(parentElement) {
1582
+ // Match elements with duplicateParentState attribute present (regardless of value)
1583
+ // This handles both <div duplicateParentState> and <div duplicateParentState="true">
1584
+ const duplicateChildren = parentElement.querySelectorAll('[duplicateparentstate]')
1585
+
1586
+ duplicateChildren.forEach((child) => {
1587
+ // Skip if explicitly set to false
1588
+ const attrValue = child.getAttribute('duplicateparentstate')
1589
+ if (attrValue === 'false' || attrValue === '0') {
1590
+ return
1591
+ }
1592
+
1593
+ // Get focus styles from multiple sources:
1594
+ // 1. Child's own focus attributes
1595
+ // 2. Data attributes (from style extraction)
1596
+ // 3. CSS custom properties (--focus-color, --focus-background-color)
1597
+
1598
+ const computedStyle = window.getComputedStyle(child)
1599
+
1600
+ // Helper to get focus attribute from multiple sources
1601
+ const getFocusAttr = (attrName, kebabName) => {
1602
+ return (
1603
+ child.getAttribute(attrName) ||
1604
+ child.getAttribute(kebabName) ||
1605
+ child.getAttribute('data-' + kebabName) ||
1606
+ computedStyle.getPropertyValue('--' + kebabName)?.trim()
1607
+ )
1608
+ }
1609
+
1610
+ // Get all focus style attributes
1611
+ const focusColor =
1612
+ getFocusAttr('focusColor', 'focus-color') ||
1613
+ getFocusAttr('focusTextColor', 'focus-text-color')
1614
+ const focusBackgroundColor = getFocusAttr('focusBackgroundColor', 'focus-background-color')
1615
+ const focusBorderRadius = getFocusAttr('focusBorderRadius', 'focus-border-radius')
1616
+ const focusScale = getFocusAttr('focusScale', 'focus-scale')
1617
+ const focusBorderWidth = getFocusAttr('focusBorderWidth', 'focus-border-width')
1618
+ const focusBorderColor = getFocusAttr('focusBorderColor', 'focus-border-color')
1619
+
1620
+ // Check if enableFocusBorder is set
1621
+ const enableFocusBorder =
1622
+ child.getAttribute('enableFocusBorder') || child.getAttribute('enable-focus-border')
1623
+ const shouldShowBorder =
1624
+ enableFocusBorder === 'true' || enableFocusBorder === '1' || enableFocusBorder === ''
1625
+
1626
+ // Store original styles
1627
+ const originalStyles = {
1628
+ backgroundColor: child.style.backgroundColor,
1629
+ color: child.style.color,
1630
+ borderRadius: child.style.borderRadius,
1631
+ transform: child.style.transform,
1632
+ zIndex: child.style.zIndex,
1633
+ transition: child.style.transition,
1634
+ outline: child.style.outline,
1635
+ outlineOffset: child.style.outlineOffset,
1636
+ }
1637
+ this.focusedElementOriginalStyles.set(child, originalStyles)
1638
+
1639
+ // Apply transition
1640
+ child.style.transition = this.defaultFocusStyle.transition
1641
+
1642
+ // Apply focus styles
1643
+ if (focusBackgroundColor) {
1644
+ child.style.backgroundColor = focusBackgroundColor
1645
+ }
1646
+
1647
+ if (focusColor) {
1648
+ child.style.color = focusColor
1649
+ }
1650
+
1651
+ if (focusBorderRadius) {
1652
+ const radius = parseFloat(focusBorderRadius)
1653
+ if (!isNaN(radius) && radius >= 0) {
1654
+ child.style.borderRadius = radius + 'px'
1655
+ }
1656
+ }
1657
+
1658
+ if (focusScale) {
1659
+ const scale = parseFloat(focusScale)
1660
+ if (!isNaN(scale) && scale > 0 && scale !== 1) {
1661
+ child.style.transform = `scale(${scale})`
1662
+ child.style.zIndex = '10'
1663
+ }
1664
+ }
1665
+
1666
+ // Apply border if enableFocusBorder is set
1667
+ if (shouldShowBorder) {
1668
+ const borderWidth = focusBorderWidth ? parseFloat(focusBorderWidth) : 3
1669
+ const borderColor = focusBorderColor || '#FFFFFF'
1670
+ const borderRadius = focusBorderRadius ? parseFloat(focusBorderRadius) : 8
1671
+
1672
+ child.style.outline = `${borderWidth}px solid ${borderColor}`
1673
+ child.style.outlineOffset = '0px'
1674
+ child.style.borderRadius = borderRadius + 'px'
1675
+ }
1676
+
1677
+ // Add focused class to child as well (for CSS-based styling)
1678
+ child.classList.add('focused-child')
1679
+
1680
+ // Handle showOnState for duplicateParentState children
1681
+ // When parent is focused, update child visibility based on showOnState
1682
+ const childShowOnState = this._shouldShowOnState(child, 'focused')
1683
+ if (childShowOnState !== null) {
1684
+ // Store original display value
1685
+ const originalDisplay = child.style.display
1686
+ if (!this.focusedElementOriginalStyles.has(child)) {
1687
+ this.focusedElementOriginalStyles.set(child, { display: originalDisplay })
1688
+ } else {
1689
+ const existing = this.focusedElementOriginalStyles.get(child)
1690
+ existing.display = originalDisplay
1691
+ }
1692
+
1693
+ if (childShowOnState) {
1694
+ child.style.display = ''
1695
+ } else {
1696
+ child.style.display = 'none'
1697
+ }
1698
+ }
1699
+ })
1700
+ }
1701
+
1702
+ _removeFocusStyle(element) {
1703
+ const originalStyles = this.focusedElementOriginalStyles.get(element)
1704
+ if (originalStyles) {
1705
+ element.style.transform = originalStyles.transform
1706
+ element.style.outline = originalStyles.outline
1707
+ element.style.outlineOffset = originalStyles.outlineOffset
1708
+ element.style.boxShadow = originalStyles.boxShadow
1709
+ element.style.transition = originalStyles.transition
1710
+ element.style.zIndex = originalStyles.zIndex
1711
+ element.style.borderRadius = originalStyles.borderRadius
1712
+ if (originalStyles.backgroundColor !== undefined) {
1713
+ element.style.backgroundColor = originalStyles.backgroundColor
1714
+ }
1715
+ if (originalStyles.color !== undefined) {
1716
+ element.style.color = originalStyles.color
1717
+ }
1718
+ } else {
1719
+ element.style.transform = ''
1720
+ element.style.outline = ''
1721
+ element.style.outlineOffset = ''
1722
+ element.style.boxShadow = ''
1723
+ element.style.zIndex = ''
1724
+ }
1725
+ element.classList.remove('focused')
1726
+
1727
+ // Remove focus styles from duplicateParentState children
1728
+ this._removeDuplicateParentStateStyles(element)
1729
+
1730
+ // Handle showOnState - update visibility based on normal state
1731
+ this._updateShowOnStateVisibility(element, 'normal')
1732
+
1733
+ requestAnimationFrame(() => syncDomAutoWidthIn(element))
1734
+ }
1735
+
1736
+ // Remove focus styles from children with duplicateParentState attribute
1737
+ _removeDuplicateParentStateStyles(parentElement) {
1738
+ const duplicateChildren = parentElement.querySelectorAll('[duplicateparentstate]')
1739
+
1740
+ duplicateChildren.forEach((child) => {
1741
+ // Skip if explicitly set to false
1742
+ const attrValue = child.getAttribute('duplicateparentstate')
1743
+ if (attrValue === 'false' || attrValue === '0') {
1744
+ return
1745
+ }
1746
+ const originalStyles = this.focusedElementOriginalStyles.get(child)
1747
+ if (originalStyles) {
1748
+ child.style.backgroundColor = originalStyles.backgroundColor
1749
+ child.style.color = originalStyles.color
1750
+ child.style.borderRadius = originalStyles.borderRadius
1751
+ child.style.transform = originalStyles.transform
1752
+ child.style.zIndex = originalStyles.zIndex
1753
+ child.style.transition = originalStyles.transition
1754
+ child.style.outline = originalStyles.outline
1755
+ child.style.outlineOffset = originalStyles.outlineOffset
1756
+ } else {
1757
+ child.style.backgroundColor = ''
1758
+ child.style.color = ''
1759
+ child.style.borderRadius = ''
1760
+ child.style.transform = ''
1761
+ child.style.zIndex = ''
1762
+ child.style.outline = ''
1763
+ child.style.outlineOffset = ''
1764
+ }
1765
+ child.classList.remove('focused-child')
1766
+
1767
+ // Handle showOnState for duplicateParentState children
1768
+ // When parent loses focus, update child visibility based on showOnState for 'normal' state
1769
+ const childShowOnState = this._shouldShowOnState(child, 'normal')
1770
+ if (childShowOnState !== null) {
1771
+ if (childShowOnState) {
1772
+ child.style.display = ''
1773
+ } else {
1774
+ child.style.display = 'none'
1775
+ }
1776
+ }
1777
+
1778
+ this.focusedElementOriginalStyles.delete(child)
1779
+ })
1780
+ }
1781
+
1782
+ clearFocus() {
1783
+ if (this.focusedElement) {
1784
+ this._removeFocusStyle(this.focusedElement)
1785
+ }
1786
+ this.focusedElement = null
1787
+ }
1788
+
1789
+ clickFocused() {
1790
+ if (this.focusedElement) {
1791
+ const element = this.focusedElement
1792
+
1793
+ let handledByComponent = false
1794
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
1795
+ if (componentRegistry && componentRegistry.has(element)) {
1796
+ const component = componentRegistry.get(element)
1797
+ if (component.events && component.events.onClick) {
1798
+ component.dispatchEvent('onClick', { nativeEvent: { target: element } })
1799
+ handledByComponent = true
1800
+ }
1801
+ }
1802
+
1803
+ if (!handledByComponent) {
1804
+ const componentId = element.id
1805
+ if (componentId && global.Hippy) {
1806
+ const uiManager = global.Hippy?.context?.getModuleByName?.('UIManagerModule')
1807
+ if (uiManager && uiManager.findViewById) {
1808
+ const component = uiManager.findViewById(componentId)
1809
+ if (component && component.events && component.events.onClick) {
1810
+ component.dispatchEvent('onClick', { nativeEvent: { target: element } })
1811
+ handledByComponent = true
1812
+ }
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ // 只有当组件没有处理 onClick 事件时,才调用原生 DOM click
1818
+ // 这样避免 onClick 事件被触发多次(component.dispatchEvent + element.click + handleClick)
1819
+ if (!handledByComponent) {
1820
+ element.click()
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ handleClick(e) {
1826
+ // Find the closest focusable element that was clicked
1827
+ let target = e.target
1828
+ const componentRegistry = window.__HIPPY_COMPONENT_REGISTRY__
1829
+
1830
+ while (target && target !== document.body) {
1831
+ // Check if this element is focusable
1832
+ if (target.hasAttribute && target.hasAttribute('focusable')) {
1833
+ // console.log('[TVFocus] Mouse click on focusable element:', target)
1834
+
1835
+ // Update focus to this element
1836
+ if (this.focusableElements.includes(target)) {
1837
+ this._doFocusElement(target)
1838
+ }
1839
+
1840
+ // Dispatch onClick event via Hippy component system
1841
+ if (componentRegistry && componentRegistry.has(target)) {
1842
+ const component = componentRegistry.get(target)
1843
+ // console.log('[TVFocus] Found component for click:', component.tagName)
1844
+
1845
+ if (component.events && component.events.onClick) {
1846
+ // console.log('[TVFocus] Dispatching onClick from mouse click')
1847
+ component.dispatchEvent('onClick', { nativeEvent: e })
1848
+ // Don't prevent default - let the DOM click also propagate
1849
+ }
1850
+ }
1851
+ break
1852
+ }
1853
+
1854
+ // Also check if this is a registered Hippy component
1855
+ if (componentRegistry && componentRegistry.has(target)) {
1856
+ const component = componentRegistry.get(target)
1857
+ // console.log('[TVFocus] Mouse click on Hippy component:', component.tagName)
1858
+
1859
+ // Update focus if the element is focusable
1860
+ if (this.focusableElements.includes(target)) {
1861
+ this._doFocusElement(target)
1862
+ }
1863
+
1864
+ if (component.events && component.events.onClick) {
1865
+ // console.log('[TVFocus] Dispatching onClick from mouse click')
1866
+ component.dispatchEvent('onClick', { nativeEvent: e })
1867
+ }
1868
+ break
1869
+ }
1870
+
1871
+ target = target.parentElement
1872
+ }
1873
+ }
1874
+
1875
+ dispatchBackPressed(e) {
1876
+ console.log('[TVFocusManager] dispatchBackPressed called - emitting hardwareBackPress event')
1877
+
1878
+ // 模拟 Android 平台的硬件返回按键事件
1879
+ // 通过 EventBus 发射 hardwareBackPress 事件
1880
+ // ESCore 监听此事件并调用 router.back()
1881
+ try {
1882
+ // 从 es3-vue 动态获取 EventBus(避免模块级别导入问题)
1883
+ const { EventBus } = require('@extscreen/es3-vue')
1884
+ if (EventBus && EventBus.$emit) {
1885
+ console.log('[TVFocusManager] Emitting hardwareBackPress event')
1886
+ EventBus.$emit('hardwareBackPress')
1887
+ }
1888
+ } catch (err) {
1889
+ console.error('[TVFocusManager] Error emitting hardwareBackPress:', err)
1890
+ }
1891
+ }
1892
+
1893
+ // Focus direction constants (matching QTFocusDirection enum)
1894
+ static FOCUS_DIRECTION = {
1895
+ BACKWARD: 1,
1896
+ FORWARD: 2,
1897
+ LEFT: 17,
1898
+ UP: 33,
1899
+ RIGHT: 66,
1900
+ DOWN: 130,
1901
+ }
1902
+
1903
+ // Convert direction number to direction name
1904
+ _directionNumberToName(directionNum) {
1905
+ const { FOCUS_DIRECTION } = TVFocusManager
1906
+ switch (directionNum) {
1907
+ case FOCUS_DIRECTION.LEFT:
1908
+ return 'left'
1909
+ case FOCUS_DIRECTION.UP:
1910
+ return 'up'
1911
+ case FOCUS_DIRECTION.RIGHT:
1912
+ return 'right'
1913
+ case FOCUS_DIRECTION.DOWN:
1914
+ return 'down'
1915
+ case FOCUS_DIRECTION.FORWARD:
1916
+ return 'forward'
1917
+ case FOCUS_DIRECTION.BACKWARD:
1918
+ return 'backward'
1919
+ default:
1920
+ return null
1921
+ }
1922
+ }
1923
+
1924
+ // Focus a specific DOM element
1925
+ // Used by requestFocus API
1926
+ focusElement(element) {
1927
+ if (!element) return false
1928
+
1929
+ // Ensure focusable elements are up to date
1930
+ if (!this.focusableElements.includes(element)) {
1931
+ this.updateFocusableElements()
1932
+ }
1933
+
1934
+ if (this.focusableElements.includes(element)) {
1935
+ this._doFocusElement(element)
1936
+ return true
1937
+ }
1938
+
1939
+ return false
1940
+ }
1941
+
1942
+ // Move focus from a specific element in a given direction
1943
+ // direction can be a number (QTFocusDirection enum) or string ('left', 'up', 'right', 'down')
1944
+ moveFocusFromElement(element, direction) {
1945
+ if (!element) return false
1946
+
1947
+ // Convert direction number to name if needed
1948
+ let directionName = direction
1949
+ if (typeof direction === 'number') {
1950
+ directionName = this._directionNumberToName(direction)
1951
+ }
1952
+
1953
+ if (!directionName) {
1954
+ // No direction specified, just focus the element
1955
+ return this.focusElement(element)
1956
+ }
1957
+
1958
+ // Handle 'forward' and 'backward' as sequential navigation
1959
+ if (directionName === 'forward' || directionName === 'backward') {
1960
+ // Ensure focusable elements are up to date
1961
+ if (!this.focusableElements.includes(element)) {
1962
+ this.updateFocusableElements()
1963
+ }
1964
+
1965
+ const currentIndex = this.focusableElements.indexOf(element)
1966
+ if (currentIndex < 0) {
1967
+ return this.focusElement(element)
1968
+ }
1969
+
1970
+ let nextIndex
1971
+ if (directionName === 'forward') {
1972
+ nextIndex = (currentIndex + 1) % this.focusableElements.length
1973
+ } else {
1974
+ nextIndex =
1975
+ (currentIndex - 1 + this.focusableElements.length) % this.focusableElements.length
1976
+ }
1977
+
1978
+ this._doFocusElement(this.focusableElements[nextIndex])
1979
+ return true
1980
+ }
1981
+
1982
+ // Ensure the element is in focusable list
1983
+ if (!this.focusableElements.includes(element)) {
1984
+ this.updateFocusableElements()
1985
+ }
1986
+
1987
+ if (!this.focusableElements.includes(element)) {
1988
+ return false
1989
+ }
1990
+
1991
+ // Check if direction is blocked for this element
1992
+ if (this._isDirectionBlocked(element, directionName)) {
1993
+ this._log('[TVFocus] Direction', directionName, 'is blocked for element')
1994
+ return false
1995
+ }
1996
+
1997
+ // 先正确聚焦到指定元素
1998
+ this._doFocusElement(element)
1999
+
2000
+ // 在指定方向上移动焦点
2001
+ this.moveFocus(directionName)
2002
+
2003
+ return true
2004
+ }
2005
+
2006
+ // Clear focus from a specific element
2007
+ clearFocusFromElement(element) {
2008
+ if (this.focusedElement === element) {
2009
+ this.clearFocus()
2010
+ return true
2011
+ }
2012
+ return false
2013
+ }
2014
+ }