@chancestv/tv-focus 0.3.0 → 0.4.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.
@@ -112,300 +112,196 @@ var $: any = null;
112
112
  return rect;
113
113
  }
114
114
 
115
- function partition(rects, targetRect, straightOverlapThreshold) {
116
- var groups = [[], [], [], [], [], [], [], [], []];
117
-
118
- for (var i = 0; i < rects.length; i++) {
119
- var rect = rects[i];
120
- var center = rect.center;
121
- var x, y, groupId;
122
-
123
- if (center.x < targetRect.left) {
124
- x = 0;
125
- } else if (center.x <= targetRect.right) {
126
- x = 1;
127
- } else {
128
- x = 2;
129
- }
130
-
131
- if (center.y < targetRect.top) {
132
- y = 0;
133
- } else if (center.y <= targetRect.bottom) {
134
- y = 1;
135
- } else {
136
- y = 2;
137
- }
138
-
139
- groupId = y * 3 + x;
140
- groups[groupId].push(rect);
141
-
142
- if ([0, 2, 6, 8].indexOf(groupId) !== -1) {
143
- var threshold = straightOverlapThreshold;
144
-
145
- if (rect.left <= targetRect.right - targetRect.width * threshold) {
146
- if (groupId === 2) {
147
- groups[1].push(rect);
148
- } else if (groupId === 8) {
149
- groups[7].push(rect);
150
- }
151
- }
152
-
153
- if (rect.right >= targetRect.left + targetRect.width * threshold) {
154
- if (groupId === 0) {
155
- groups[1].push(rect);
156
- } else if (groupId === 6) {
157
- groups[7].push(rect);
158
- }
159
- }
160
-
161
- if (rect.top <= targetRect.bottom - targetRect.height * threshold) {
162
- if (groupId === 6) {
163
- groups[3].push(rect);
164
- } else if (groupId === 8) {
165
- groups[5].push(rect);
166
- }
167
- }
168
-
169
- if (rect.bottom >= targetRect.top + targetRect.height * threshold) {
170
- if (groupId === 0) {
171
- groups[3].push(rect);
172
- } else if (groupId === 2) {
173
- groups[5].push(rect);
174
- }
175
- }
176
- }
115
+ /*****************************************/
116
+ /* Android FocusFinder 加权距离模型(ES5)*/
117
+ /*****************************************/
118
+ // 移植自 android.view.FocusFinder:候选先过 isCandidate 方向门,
119
+ // 再两两 isBetterCandidate 比较(beam 内优先 + 13*major²+minor² 加权距离)。
120
+ // direction 用字符串 'left'|'right'|'up'|'down';rect 用 getRect 结构(含 center)。
121
+
122
+ function snMajorAxisDistanceRaw(direction, source, dest) {
123
+ switch (direction) {
124
+ case 'left': return source.left - dest.right;
125
+ case 'right': return dest.left - source.right;
126
+ case 'up': return source.top - dest.bottom;
127
+ case 'down': return dest.top - source.bottom;
128
+ default: return 0;
177
129
  }
178
-
179
- return groups;
180
130
  }
181
-
182
- function generateDistanceFunction(targetRect) {
183
- return {
184
- nearPlumbLineIsBetter: function(rect) {
185
- var d;
186
- if (rect.center.x < targetRect.center.x) {
187
- d = targetRect.center.x - rect.right;
188
- } else {
189
- d = rect.left - targetRect.center.x;
190
- }
191
- return d < 0 ? 0 : d;
192
- },
193
- nearHorizonIsBetter: function(rect) {
194
- var d;
195
- if (rect.center.y < targetRect.center.y) {
196
- d = targetRect.center.y - rect.bottom;
197
- } else {
198
- d = rect.top - targetRect.center.y;
199
- }
200
- return d < 0 ? 0 : d;
201
- },
202
- nearTargetLeftIsBetter: function(rect) {
203
- var d;
204
- if (rect.center.x < targetRect.center.x) {
205
- d = targetRect.left - rect.right;
206
- } else {
207
- d = rect.left - targetRect.left;
208
- }
209
- return d < 0 ? 0 : d;
210
- },
211
- nearTargetTopIsBetter: function(rect) {
212
- var d;
213
- if (rect.center.y < targetRect.center.y) {
214
- d = targetRect.top - rect.bottom;
215
- } else {
216
- d = rect.top - targetRect.top;
217
- }
218
- return d < 0 ? 0 : d;
219
- },
220
- topIsBetter: function(rect) {
221
- return rect.top;
222
- },
223
- bottomIsBetter: function(rect) {
224
- return -1 * rect.bottom;
225
- },
226
- leftIsBetter: function(rect) {
227
- return rect.left;
228
- },
229
- rightIsBetter: function(rect) {
230
- return -1 * rect.right;
231
- }
232
- };
131
+ function snMajorAxisDistance(direction, source, dest) {
132
+ return Math.max(0, snMajorAxisDistanceRaw(direction, source, dest));
133
+ }
134
+ function snMajorAxisDistanceToFarEdgeRaw(direction, source, dest) {
135
+ switch (direction) {
136
+ case 'left': return source.left - dest.left;
137
+ case 'right': return dest.right - source.right;
138
+ case 'up': return source.top - dest.top;
139
+ case 'down': return dest.bottom - source.bottom;
140
+ default: return 0;
141
+ }
142
+ }
143
+ function snMajorAxisDistanceToFarEdge(direction, source, dest) {
144
+ return Math.max(1, snMajorAxisDistanceToFarEdgeRaw(direction, source, dest));
145
+ }
146
+ function snMinorAxisDistance(direction, source, dest) {
147
+ // 引擎 center.x/y 已是中心点,等价 Android 的 top+height/2 / left+width/2
148
+ switch (direction) {
149
+ case 'left':
150
+ case 'right':
151
+ return Math.abs(source.center.y - dest.center.y); // 垂直中心偏移
152
+ case 'up':
153
+ case 'down':
154
+ return Math.abs(source.center.x - dest.center.x); // 水平中心偏移
155
+ default:
156
+ return 0;
157
+ }
158
+ }
159
+ function snGetWeightedDistanceFor(major, minor) {
160
+ return 13 * major * major + minor * minor;
233
161
  }
234
162
 
235
- function prioritize(priorities) {
236
- var destPriority = null;
237
- for (var i = 0; i < priorities.length; i++) {
238
- if (priorities[i].group.length) {
239
- destPriority = priorities[i];
240
- break;
241
- }
163
+ // dest 是否完全在 source 的 direction 方向(边界版,Android isToDirectionOf)
164
+ function snIsToDirectionOf(direction, source, dest) {
165
+ switch (direction) {
166
+ case 'left': return source.left >= dest.right;
167
+ case 'right': return source.right <= dest.left;
168
+ case 'up': return source.top >= dest.bottom;
169
+ case 'down': return source.bottom <= dest.top;
170
+ default: return false;
242
171
  }
172
+ }
243
173
 
244
- if (!destPriority) {
245
- return null;
174
+ // dest 是否至少部分在 source 的 direction 方向(Android isCandidate)
175
+ function snIsCandidate(source, dest, direction) {
176
+ switch (direction) {
177
+ case 'left':
178
+ return (source.right > dest.right || source.left >= dest.right) &&
179
+ source.left > dest.left;
180
+ case 'right':
181
+ return (source.left < dest.left || source.right <= dest.left) &&
182
+ source.right < dest.right;
183
+ case 'up':
184
+ return (source.bottom > dest.bottom || source.top >= dest.bottom) &&
185
+ source.top > dest.top;
186
+ case 'down':
187
+ return (source.top < dest.top || source.bottom <= dest.top) &&
188
+ source.bottom < dest.bottom;
189
+ default:
190
+ return false;
246
191
  }
192
+ }
247
193
 
248
- var destDistance = destPriority.distance;
194
+ // 次轴投影是否重叠(Android beamsOverlap)
195
+ function snBeamsOverlap(direction, rect1, rect2) {
196
+ switch (direction) {
197
+ case 'left':
198
+ case 'right':
199
+ return (rect2.bottom >= rect1.top) && (rect2.top <= rect1.bottom);
200
+ case 'up':
201
+ case 'down':
202
+ return (rect2.right >= rect1.left) && (rect2.left <= rect1.right);
203
+ default:
204
+ return false;
205
+ }
206
+ }
249
207
 
250
- destPriority.group.sort(function(a, b) {
251
- for (var i = 0; i < destDistance.length; i++) {
252
- var distance = destDistance[i];
253
- var delta = distance(a) - distance(b);
254
- if (delta) {
255
- return delta;
256
- }
257
- }
258
- return 0;
259
- });
208
+ // rect1 是否凭「beam 内」胜出 rect2(Android beamBeats)
209
+ function snBeamBeats(direction, source, rect1, rect2) {
210
+ var r1In = snBeamsOverlap(direction, source, rect1);
211
+ var r2In = snBeamsOverlap(direction, source, rect2);
212
+ if (r2In || !r1In) {
213
+ return false;
214
+ }
215
+ if (!snIsToDirectionOf(direction, source, rect2)) {
216
+ return true;
217
+ }
218
+ if (direction === 'left' || direction === 'right') {
219
+ return true;
220
+ }
221
+ return snMajorAxisDistance(direction, source, rect1) <
222
+ snMajorAxisDistanceToFarEdge(direction, source, rect2);
223
+ }
260
224
 
261
- return destPriority.group;
225
+ // rect1 是否比 rect2 更优(Android isBetterCandidate)
226
+ function snIsBetterCandidate(direction, source, rect1, rect2) {
227
+ if (!snIsCandidate(source, rect1, direction)) {
228
+ return false;
229
+ }
230
+ if (!snIsCandidate(source, rect2, direction)) {
231
+ return true;
232
+ }
233
+ if (snBeamBeats(direction, source, rect1, rect2)) {
234
+ return true;
235
+ }
236
+ if (snBeamBeats(direction, source, rect2, rect1)) {
237
+ return false;
238
+ }
239
+ return snGetWeightedDistanceFor(
240
+ snMajorAxisDistance(direction, source, rect1),
241
+ snMinorAxisDistance(direction, source, rect1)) <
242
+ snGetWeightedDistanceFor(
243
+ snMajorAxisDistance(direction, source, rect2),
244
+ snMinorAxisDistance(direction, source, rect2));
262
245
  }
263
246
 
264
- // preferNearest=true:跨 section 查找,直线/斜向合并按距离竞争(就近原则)。
265
- // preferNearest=false(默认):section 内查找,保留上游分层(直线强于斜向)。
247
+ // preferNearest 入参保留以兼容现有调用(navigateWithinScrollScope 仍传 true),
248
+ // Android 模型下不再控制打分分层,仅占位。
266
249
  function navigate(target, direction, candidates, config, preferNearest) {
267
250
  if (!target || !direction || !candidates || !candidates.length) {
268
251
  return null;
269
252
  }
253
+ var targetRect = getRect(target);
254
+ if (!targetRect) {
255
+ return null;
256
+ }
270
257
 
258
+ // 1) 转 rect + 过 isCandidate 方向门(straightOnly 时额外要求 beam 内)
271
259
  var rects = [];
272
260
  for (var i = 0; i < candidates.length; i++) {
273
- var rect = getRect(candidates[i]);
274
- if (rect) {
275
- rects.push(rect);
261
+ var r = getRect(candidates[i]);
262
+ if (!r) {
263
+ continue;
276
264
  }
265
+ if (!snIsCandidate(targetRect, r, direction)) {
266
+ continue;
267
+ }
268
+ if (config.straightOnly && !snBeamsOverlap(direction, targetRect, r)) {
269
+ continue;
270
+ }
271
+ rects.push(r);
277
272
  }
278
273
  if (!rects.length) {
279
274
  return null;
280
275
  }
281
276
 
282
- var targetRect = getRect(target);
283
- if (!targetRect) {
284
- return null;
277
+ // 2) 两两 isBetterCandidate 维护当前最优
278
+ var best = rects[0];
279
+ for (var k = 1; k < rects.length; k++) {
280
+ if (snIsBetterCandidate(direction, targetRect, rects[k], best)) {
281
+ best = rects[k];
282
+ }
285
283
  }
286
284
 
287
- var distanceFunction = generateDistanceFunction(targetRect);
288
-
289
- var groups = partition(
290
- rects,
291
- targetRect,
292
- config.straightOverlapThreshold
293
- );
294
285
 
295
- var internalGroups = partition(
296
- groups[4],
297
- targetRect.center,
298
- config.straightOverlapThreshold
299
- );
300
286
 
301
- var priorities;
302
- var df = distanceFunction;
303
- var internalGroup, straightGroup, diagonalGroups;
304
- var internalDist, straightDist, diagonalDist, mergedDist;
305
287
 
306
- switch (direction) {
307
- case 'left':
308
- internalGroup = internalGroups[0].concat(internalGroups[3])
309
- .concat(internalGroups[6]);
310
- straightGroup = groups[3];
311
- diagonalGroups = groups[0].concat(groups[6]);
312
- internalDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
313
- straightDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
314
- diagonalDist = [df.nearHorizonIsBetter, df.rightIsBetter,
315
- df.nearTargetTopIsBetter];
316
- // 合并时主轴=水平最近,次轴=垂直对齐
317
- mergedDist = [df.nearPlumbLineIsBetter, df.nearHorizonIsBetter,
318
- df.topIsBetter];
319
- break;
320
- case 'right':
321
- internalGroup = internalGroups[2].concat(internalGroups[5])
322
- .concat(internalGroups[8]);
323
- straightGroup = groups[5];
324
- diagonalGroups = groups[2].concat(groups[8]);
325
- internalDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
326
- straightDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
327
- diagonalDist = [df.nearHorizonIsBetter, df.leftIsBetter,
328
- df.nearTargetTopIsBetter];
329
- mergedDist = [df.nearPlumbLineIsBetter, df.nearHorizonIsBetter,
330
- df.topIsBetter];
331
- break;
332
- case 'up':
333
- internalGroup = internalGroups[0].concat(internalGroups[1])
334
- .concat(internalGroups[2]);
335
- straightGroup = groups[1];
336
- diagonalGroups = groups[0].concat(groups[2]);
337
- internalDist = [df.nearHorizonIsBetter, df.leftIsBetter];
338
- straightDist = [df.nearHorizonIsBetter, df.leftIsBetter];
339
- diagonalDist = [df.nearPlumbLineIsBetter, df.bottomIsBetter,
340
- df.nearTargetLeftIsBetter];
341
- // 合并时主轴=垂直最近,次轴=水平对齐
342
- mergedDist = [df.nearHorizonIsBetter, df.nearPlumbLineIsBetter,
343
- df.leftIsBetter];
344
- break;
345
- case 'down':
346
- internalGroup = internalGroups[6].concat(internalGroups[7])
347
- .concat(internalGroups[8]);
348
- straightGroup = groups[7];
349
- diagonalGroups = groups[6].concat(groups[8]);
350
- internalDist = [df.nearHorizonIsBetter, df.leftIsBetter];
351
- straightDist = [df.nearHorizonIsBetter, df.leftIsBetter];
352
- diagonalDist = [df.nearPlumbLineIsBetter, df.topIsBetter,
353
- df.nearTargetLeftIsBetter];
354
- mergedDist = [df.nearHorizonIsBetter, df.nearPlumbLineIsBetter,
355
- df.leftIsBetter];
356
- break;
357
- default:
358
- return null;
359
- }
360
-
361
- if (preferNearest) {
362
- // 跨 section:直线与斜向候选合并为一层按距离竞争,最近一行/一列胜出,
363
- // 修复「远处同列元素抢焦点」(如按下从按钮行越过相邻行跳到远处对齐元素)。
364
- priorities = [
365
- { group: internalGroup, distance: internalDist },
366
- {
367
- group: config.straightOnly
368
- ? straightGroup
369
- : straightGroup.concat(diagonalGroups),
370
- distance: mergedDist
371
- }
372
- ];
373
- } else {
374
- // section 内:保留上游分层(直线候选强于斜向),保证同行/同列相邻项优先,
375
- // 不被「不同行/不同列但像素更近」的元素抢走(如变宽卡片墙里同行相邻卡)。
376
- priorities = [
377
- { group: internalGroup, distance: internalDist },
378
- { group: straightGroup, distance: straightDist },
379
- { group: diagonalGroups, distance: diagonalDist }
380
- ];
381
- if (config.straightOnly) {
382
- priorities.pop();
383
- }
384
- }
385
-
386
- var destGroup = prioritize(priorities);
387
- if (!destGroup) {
388
- return null;
389
- }
390
-
391
- var dest = null;
288
+ // 3) rememberSource:previous.target 仍是合法候选且不被 best 严格击败 → 复焦,
289
+ // 保留「左右往返不抖动」语义(Android 原生无此机制,此处嫁接)。
392
290
  if (config.rememberSource &&
393
291
  config.previous &&
394
292
  config.previous.destination === target &&
395
293
  config.previous.reverse === direction) {
396
- for (var j = 0; j < destGroup.length; j++) {
397
- if (destGroup[j].element === config.previous.target) {
398
- dest = destGroup[j].element;
294
+ for (var j = 0; j < rects.length; j++) {
295
+ if (rects[j].element === config.previous.target) {
296
+ if (!snIsBetterCandidate(direction, targetRect, best, rects[j])) {
297
+ return rects[j].element;
298
+ }
399
299
  break;
400
300
  }
401
301
  }
402
302
  }
403
303
 
404
- if (!dest) {
405
- dest = destGroup[0].element;
406
- }
407
-
408
- return dest;
304
+ return best.element;
409
305
  }
410
306
 
411
307
  /********************/
@@ -770,6 +666,50 @@ var $: any = null;
770
666
  return navigate(target, direction, candidates, config, preferNearest);
771
667
  }
772
668
 
669
+ // section 级方向剪枝:跨区收集候选前,按「该 section 所有项的 union 包围盒」
670
+ // 过 Android isCandidate 方向门,只剪掉「纯反方向」section(如按左时整个在右侧的)。
671
+ // beam 内/外不在此区分,留给项级 snBeamBeats。容器/空 section 天然跳过。
672
+ // sectionNavMap:focusNext 已构造的 { sectionId: navigableElements[] },复用不重查。
673
+ function filterCandidatesByDirection(source, direction, sectionNavMap, excludeSectionId) {
674
+ var srcRect = getRect(source);
675
+ var kept = [];
676
+ for (var id in sectionNavMap) {
677
+ if (id === excludeSectionId) {
678
+ continue;
679
+ }
680
+ var elems = sectionNavMap[id];
681
+ if (!elems || !elems.length) {
682
+ continue; // 容器 section / 空 section 跳过
683
+ }
684
+ var left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
685
+ var rects = [];
686
+ for (var i = 0; i < elems.length; i++) {
687
+ var r = getRect(elems[i]);
688
+ if (!r) {
689
+ continue;
690
+ }
691
+ rects.push(r);
692
+ if (r.left < left) { left = r.left; }
693
+ if (r.top < top) { top = r.top; }
694
+ if (r.right > right) { right = r.right; }
695
+ if (r.bottom > bottom) { bottom = r.bottom; }
696
+ }
697
+ if (!rects.length) {
698
+ continue;
699
+ }
700
+ var unionRect = {
701
+ left: left, top: top, right: right, bottom: bottom,
702
+ width: right - left, height: bottom - top
703
+ };
704
+ if (snIsCandidate(srcRect, unionRect, direction)) {
705
+ for (var j = 0; j < rects.length; j++) {
706
+ kept.push(rects[j].element);
707
+ }
708
+ }
709
+ }
710
+ return kept;
711
+ }
712
+
773
713
  function focusNext(direction, currentFocusedElement, currentSectionId) {
774
714
  var extSelector =
775
715
  currentFocusedElement.getAttribute('data-sn-' + direction);
@@ -805,11 +745,24 @@ var $: any = null;
805
745
  );
806
746
 
807
747
  if (!next && config.restrict == 'self-first') {
808
- // 离开本 section 跨区查找:就近原则 + 滚动容器感知
748
+ // 离开本 section 跨区查找:先做 section 级方向剪枝(剪掉纯反方向 section),
749
+ // 再走滚动容器感知 + 项级 Android 打分。
750
+ var prunedCandidates = filterCandidatesByDirection(
751
+ currentFocusedElement,
752
+ direction,
753
+ sectionNavigableElements,
754
+ currentSectionId
755
+ );
756
+ if (!prunedCandidates.length) {
757
+ // 剪光兜底:回退全量(极端布局下某方向无任何 section 过门时不退化为死焦点)
758
+ prunedCandidates = exclude(
759
+ allNavigableElements, currentSectionNavigableElements
760
+ );
761
+ }
809
762
  next = navigateWithinScrollScope(
810
763
  currentFocusedElement,
811
764
  direction,
812
- exclude(allNavigableElements, currentSectionNavigableElements),
765
+ prunedCandidates,
813
766
  config,
814
767
  true
815
768
  );