@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.
- package/package.json +24 -0
- package/src/adapters/es3-video-player.js +828 -0
- package/src/components/Modal.js +119 -0
- package/src/components/QtAnimationView.js +678 -0
- package/src/components/QtBaseComponent.js +165 -0
- package/src/components/QtFastListView.js +1920 -0
- package/src/components/QtFlexView.js +799 -0
- package/src/components/QtImage.js +203 -0
- package/src/components/QtItemFrame.js +239 -0
- package/src/components/QtItemStoreView.js +93 -0
- package/src/components/QtItemView.js +125 -0
- package/src/components/QtListView.js +331 -0
- package/src/components/QtLoadingView.js +55 -0
- package/src/components/QtPageRootView.js +19 -0
- package/src/components/QtPlayMark.js +168 -0
- package/src/components/QtProgressBar.js +199 -0
- package/src/components/QtQRCode.js +78 -0
- package/src/components/QtReplaceChild.js +149 -0
- package/src/components/QtRippleView.js +166 -0
- package/src/components/QtSeekBar.js +409 -0
- package/src/components/QtText.js +679 -0
- package/src/components/QtTransitionImage.js +170 -0
- package/src/components/QtView.js +706 -0
- package/src/components/QtWebView.js +613 -0
- package/src/components/TabsView.js +420 -0
- package/src/components/ViewPager.js +206 -0
- package/src/components/index.js +24 -0
- package/src/components/plugins/TextV2Component.js +70 -0
- package/src/components/plugins/index.js +7 -0
- package/src/core/SceneBuilder.js +58 -0
- package/src/core/TVFocusManager.js +2014 -0
- package/src/core/asyncLocalStorage.js +175 -0
- package/src/core/autoProxy.js +165 -0
- package/src/core/componentRegistry.js +84 -0
- package/src/core/constants.js +6 -0
- package/src/core/index.js +8 -0
- package/src/core/moduleUtils.js +36 -0
- package/src/core/patches.js +958 -0
- package/src/core/templateBinding.js +666 -0
- package/src/index.js +246 -0
- package/src/modules/AndroidDevelopModule.js +101 -0
- package/src/modules/AndroidDeviceModule.js +341 -0
- package/src/modules/AndroidNetworkModule.js +178 -0
- package/src/modules/AndroidSharedPreferencesModule.js +100 -0
- package/src/modules/ESDeviceInfoModule.js +450 -0
- package/src/modules/ESGroupDataModule.js +195 -0
- package/src/modules/ESIJKAudioPlayerModule.js +477 -0
- package/src/modules/ESLocalStorageModule.js +100 -0
- package/src/modules/ESLogModule.js +65 -0
- package/src/modules/ESModule.js +106 -0
- package/src/modules/ESNetworkSpeedModule.js +117 -0
- package/src/modules/ESToastModule.js +172 -0
- package/src/modules/EsNativeModule.js +117 -0
- package/src/modules/FastListModule.js +101 -0
- package/src/modules/FocusModule.js +145 -0
- 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
|
+
}
|