VulpesOS/gaia/shared/js/smart-screen/spatial_navigator.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

870 lines
26 KiB
JavaScript
Executable file

'use strict';
/* global evt */
(function(exports) {
/**
* SpatialNavigator simulates four-direction navigation in Javascript level.
*
* Navigation is the ability to navigate between focusable elements
* within a structured document or user interface according to the spatial
* location. Users are assumed navigating among elements on a 2D plane by
* arrow keys (up/down/left/right).
*
* SpatialNavigator keeps a 'focused' element by itself. When navigating,
* focus/unfocus events are triggered automatically. Notice the focus is just
* an internal state rather than actual focus of DOM Element. User should
* add event listeners of those events, and design required behaviors.
*
* @class SpatialNavigator
* @param {Array.<SpatialNavigatorElement>} [collection=[]]
* An initial set of traversable elements.
* @param {Object} [config]
* An initial set of configurations.
*/
/**
* SpatialNavigatorElement is a navigable element which can be traversed
* by {@link SpatialNavigator}. Valid types are as follows:
*
* 1. a standard HTMLElement.
* 2. an Object contains at least 4 properties: left, top, width, and height.
* 3. an Object implementing getBoundingRect() which returns an object of 2.
*
* @typedef {Object} SpatialNavigatorElement
*/
/**
* Fired when an element is focused.
* @event SpatialNavigator#focus
* @property {SpatialNavigatorElement} elem The element which got focus.
*/
/**
* Fired when an element is unfocused.
* @event SpatialNavigator#unfocus
* @property {SpatialNavigatorElement} elem The element which lost focus.
*/
function SpatialNavigator(collection, config) {
this._focus = null;
this._previous = null;
this.setCollection(collection);
if (config) {
for (var key in config) {
this[key] = config[key];
}
}
}
SpatialNavigator.prototype = evt({
/**
* Limit the navigating direction to vertical and horizontal only. Targets
* in oblique (left-top, right-top, left-bottom, and right-bottom)
* directions are always ignored.
* @type {Boolean}
* @default false
* @memberof SpatialNavigator.prototype
*/
straightOnly: false,
/**
* This threshold is used to determine whether an element is considered in
* straight (vertical or horizontal) directions. Valid number is between 0
* to 1.0. Setting it to 0.3 means an element is counted in the straight
* directions if it overlaps the straight area at least 0.3x of width of the
* area.
* @type {Number}
* @default 0.5
* @memberof SpatialNavigator.prototype
*/
straightOverlapThreshold: 0.5,
/**
* Ignore elements with "display: none", "visibility: hidden" or
* "aria-hidden=true".
* @type {Boolean}
* @default false
* @memberof SpatialNavigator.prototype
*/
ignoreHiddenElement: false,
/**
* The previous focused element has high priority to be chosen as the next
* candidate.
* @type {Boolean}
* @default false
* @memberof SpatialNavigator.prototype
*/
rememberSource: false,
/**
* A callback function that accepcts an element as the first argument will
* be triggered everytime when SpatialNavigator tries to traverse every
* single candidate. You can ignore arbitrary elements by returning "false"
* in this function.
* @type {Function}
* @default null
* @memberof SpatialNavigator.prototype
*/
navigableFilter: null,
/**
* setting this property true prevents spatialNavigator from emitting any
* events.
* @type {Boolean}
*/
silent: false,
/**
* Rect represents position and dimension of a 2D object.
* @typedef {Object} Rect
* @property {Integer} left Left position
* @property {Integer} top Top position
* @property {Integer} right Right position
* @property {Integer} bottom Bottom position
* @property {Integer} width Width dimension
* @property {Integer} height Height dimension
* @property {Rect} [center] Center position
* @property {Integer} [x] same as left
* @property {Integer} [y] same as top
* @access private
* @memberof SpatialNavigator.prototype
*/
/**
* Get {@link Rect} of a {@link SpatialNavigatorElement}.
*
* @param {SpatialNavigatorElement} elem
*
* @return {Rect} dimension of elem.
*
* @access private
* @memberof SpatialNavigator.prototype
*/
_getRect: function snGetRect(elem) {
var rect = null;
if (!this._isNavigable(elem)) {
return null;
}
if (elem.getBoundingClientRect) {
var cr = elem.getBoundingClientRect();
rect = {
left: cr.left,
top: cr.top,
width: cr.width,
height: cr.height
};
} else if (elem.left !== undefined) {
rect = {
left: parseInt(elem.left || 0, 10),
top: parseInt(elem.top || 0, 10),
width: parseInt(elem.width || 0, 10),
height: parseInt(elem.height || 0, 10)
};
} else {
return null;
}
rect.element = elem;
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
rect.center = {
x: rect.left + Math.floor(rect.width / 2),
y: rect.top + Math.floor(rect.height / 2)
};
rect.center.left = rect.center.right = rect.center.x;
rect.center.top = rect.center.bottom = rect.center.y;
return rect;
},
/**
* Get all {@link Rect} objects from the collection.
*
* @param {SpatialNavigatorElement} [excludedElem]
* You can pass excludedElem here to ignore it from calculating.
* (most likely, the currently focused element is passed).
*
* @return {Array.<Rect>} {@link Rect} objects of all traversable elements.
*
* @access private
* @memberof SpatialNavigator.prototype
*/
_getAllRects: function snGetAllRects(excludedElem) {
var rects = [];
this._collection.forEach(function(elem) {
if (!excludedElem || excludedElem !== elem) {
var rect = this._getRect(elem);
if (rect) {
rects.push(rect);
}
}
}, this);
return rects;
},
/**
* Check whether a {@link SpatialNavigatorElement} is navigable.
*
* @param {SpatialNavigatorElement} elem
*
* @return {Boolean} true if it's navigable.
*
* @access private
* @memberof SpatialNavigator.prototype
*/
_isNavigable: function snIsNavigable(elem) {
if (this.ignoreHiddenElement && elem instanceof HTMLElement) {
var computedStyle = window.getComputedStyle(elem);
if ((elem.offsetWidth <= 0 && elem.offsetHeight <= 0) ||
computedStyle.getPropertyValue('visibility') == 'hidden' ||
computedStyle.getPropertyValue('display') == 'none' ||
elem.hasAttribute('aria-hidden')) {
return false;
}
}
if (this.navigableFilter && !this.navigableFilter(elem)) {
return false;
}
return true;
},
/**
* Given a set of {@link Rect} array, divide them into 9 groups with
* respect to the position of targetRect. Rects centered inside targetRect
* are grouped as 4th group; straight left as 3rd group; straight right as
* 5th group; ..... and so on. See below for the corresponding group number:
*
* <pre>
* |---+---+---|
* | 0 | 1 | 2 |
* |---+---+---|
* | 3 | 4 | 5 |
* |---+---+---|
* | 6 | 7 | 8 |
* |---+---+---|
* </pre>
*
* @param {Array.<Rect>} rects
* {@link RectS} to be divided.
* @param {Rect} targetRect
* Reference position for groups.
*
* @return {Array.Array.<SpatialNavigatorElement>}
* A 9-elements array of array, where rects are categorized into
* these 9 arrays by their group number.
*
* @access private
* @memberof SpatialNavigator.prototype
*
*/
_partition: function snDemarcate(rects, targetRect) {
var groups = [[], [], [], [], [], [], [], [], []];
var threshold = this.straightOverlapThreshold;
if (threshold > 1 || threshold < 0) {
// Fallback to default value
threshold = 0.5;
}
rects.forEach(function(rect) {
var center = rect.center;
var x, y, groupId;
if (center.x < targetRect.left) {
x = 0;
} else if (center.x <= targetRect.right) {
x = 1;
} else {
x = 2;
}
if (center.y < targetRect.top) {
y = 0;
} else if (center.y <= targetRect.bottom) {
y = 1;
} else {
y = 2;
}
groupId = y * 3 + x;
groups[groupId].push(rect);
// Although a rect is in the oblique directions, we categorize it in
// the straight area as well if it overlaps the straight directions more
// than a specified threshold (0.5 by default).
if ([0, 2, 6, 8].indexOf(groupId) !== -1) {
if (rect.left <= targetRect.right - targetRect.width * threshold) {
if (groupId === 2) {
groups[1].push(rect);
} else if (groupId === 8) {
groups[7].push(rect);
}
}
if (rect.right >= targetRect.left + targetRect.width * threshold) {
if (groupId === 0) {
groups[1].push(rect);
} else if (groupId === 6) {
groups[7].push(rect);
}
}
if (rect.top <= targetRect.bottom - targetRect.height * threshold) {
if (groupId === 6) {
groups[3].push(rect);
} else if (groupId === 8) {
groups[5].push(rect);
}
}
if (rect.bottom >= targetRect.top + targetRect.height * threshold) {
if (groupId === 0) {
groups[3].push(rect);
} else if (groupId === 2) {
groups[5].push(rect);
}
}
}
});
return groups;
},
/**
* Bind targetRect to a set of distance functions for ranking. These
* functions work with another {@link Rect} object passed to get a ranking
* value relative to targetRect.
*
* @param {Rect} targetRect
*
* @return {Object.<function>}
* An object containing a bunch of functions bound with targetRect.
*
* @access private
* @memberof SpatialNavigator.prototype
*/
_getDistanceFunction: function snGetDistanceFunction(targetRect) {
return {
/* Plumb Line: a vertical line through the center of the
targetRect. */
nearPlumbLineIsBetter: function(rect) {
var d;
if (rect.center.x < targetRect.center.x) {
d = targetRect.center.x - rect.right;
} else {
d = rect.left - targetRect.center.x;
}
return d < 0 ? 0 : d;
},
/* Horizon: a horizontal line through the center of the
"targetRect". */
nearHorizonIsBetter: function(rect) {
var d;
if (rect.center.y < targetRect.center.y) {
d = targetRect.center.y - rect.bottom;
} else {
d = rect.top - targetRect.center.y;
}
return d < 0 ? 0 : d;
},
/* Target Left: a coincident line of the left edge of the
"targetRect". */
nearTargetLeftIsBetter: function(rect) {
var d;
if (rect.center.x < targetRect.center.x) {
d = targetRect.left - rect.right;
} else {
d = rect.left - targetRect.left;
}
return d < 0 ? 0 : d;
},
/* Target Top: a coincident line of the top edge of the
"targetRect". */
nearTargetTopIsBetter: function(rect) {
var d;
if (rect.center.y < targetRect.center.y) {
d = targetRect.top - rect.bottom;
} else {
d = rect.top - targetRect.top;
}
return d < 0 ? 0 : d;
},
/* top, bottom, left, and right: Just ranking by absolute coordinate
without respect to targetRect. Usually they are used as fallback
rules when ranks above are draw. */
topIsBetter: function(rect) {
return rect.top;
},
bottomIsBetter: function(rect) {
return -1 * rect.bottom;
},
leftIsBetter: function(rect) {
return rect.left;
},
rightIsBetter: function(rect) {
return -1 * rect.right;
}
};
},
/**
* PrioritySet contains a set of elements with distance functions that
* should be used to rank them (obtained from {@link
* SpatialNavigator#_getDistanceFunction}).
*
* @typedef PrioritySet
* @property {Array.<Rects>} group
* {@link Rects} of elements that need to be prioritized.
* @property {Array.<function>} distance
* Distance functions. Primary ranking rule should be put in index
* 0; secondary in index 1 (fallback rule when primary rule draws
* ); and so on.
* @access private
* @memberof SpatialNavigator.prototype
*/
/**
* Pick a {@link Rect} with highest priority.
*
* @param {Array.<PrioritySet>} priorities
* An array of {@link PrioritySet} that need to be prioritized. The
* set with lowest index containing non-empty {PrioritySet.group}
* would be prioritized.
* @param {SpatialNavigatorElement} target
* The origin of coordinates for traversal.
* @param {String} direction
* It should be "left", "right", "up" or "down".
*
* @return {Rect} the {@link Rect} of highest priority.
*
* @access private
* @memberof SpatialNavigator.prototype
*/
_prioritize: function snPrioritize(priorities, target, direction) {
var destPriority = priorities.find(function(p) {
return !!p.group.length;
});
if (!destPriority) {
return null;
}
if (this.rememberSource &&
this._previous &&
target == this._previous.destination &&
direction == this._previous.reverse) {
var source = this._previous.source;
var found = destPriority.group.find(function(dest) {
return dest.element == source;
});
if (found) {
return found;
}
}
destPriority.group.sort(function(a, b) {
return destPriority.distance.reduce(function(answer, distance) {
return answer || (distance(a) - distance(b));
}, 0);
});
return destPriority.group[0];
},
/**
* Replace the set of traversable elements.
*
* @param {Array.<SpatialNavigatorElement>} [collection=[]]
elements to be replaced. The array is deep-copied and never
be changed directly by SpatialNavigator.
*
* @fires SpatialNavigator#unfocus
* @memberof SpatialNavigator.prototype
*/
setCollection: function snSetCollection(collection) {
this.unfocus();
this._collection = [];
if (collection) {
this.multiAdd(collection);
}
},
/**
* Add an element to traversable set.
*
* @param {SpatialNavigatorElement} elem
* @return {Boolean} true if succeed.
*
* @memberof SpatialNavigator.prototype
*/
add: function snAdd(elem) {
var index = this._collection.indexOf(elem);
if (index >= 0) {
return false;
}
this._collection.push(elem);
return true;
},
/**
* Add a bunch of elements to traversable set.
*
* @param {Array.<SpatialNavigatorElement>} elements
* @return {Boolean} true if all elements are added successfully.
*
* @memberof SpatialNavigator.prototype
*/
multiAdd: function snMultiAdd(elements) {
return Array.from(elements).every(this.add, this);
},
/**
* Remove an element from traversable set.
*
* @param {SpatialNavigatorElement} elem
* @return {Boolean} true if succeed. false if elem does not exist.
*
* @fires SpatialNavigator#unfocus
* @memberof SpatialNavigator.prototype
*/
remove: function snRemove(elem) {
var index = this._collection.indexOf(elem);
if (index < 0) {
return false;
}
if (this._focus === elem) {
this.unfocus();
}
this._collection.splice(index, 1);
return true;
},
/**
* Remove a bunch of elements to traversable set.
*
* @param {Array.<SpatialNavigatorElement>} elements
* @return {Boolean} true if all elements are removed successfully.
*
* @memberof SpatialNavigator.prototype
*/
multiRemove: function snMultiRemove(elements) {
return Array.from(elements).every(this.remove, this);
},
/**
* Move focus to an existing element.
*
* @param {SpatialNavigatorElement} [elem]
* when omitted, it focuses the last focused element or the first
* navigable element if no previously-focused element is found.
*
* @return {Boolean} true if succeed. false if element doesn't exist.
*
* @fires SpatialNavigator#focus
* @fires SpatialNavigator#unfocus
* @memberof SpatialNavigator.prototype
*/
focus: function snFocus(elem) {
// If no arguments are received, let current focused element be the
// default.
if (!elem && this._focus && this._isNavigable(this._focus)) {
elem = this._focus;
}
if (!this._collection) {
return false;
}
if (!elem) {
var navigableElems = this._collection.filter(this._isNavigable, this);
if (!navigableElems.length) {
return false;
}
elem = navigableElems[0];
} else if (this._collection.indexOf(elem) < 0 ||
!this._isNavigable(elem)) {
return false;
}
this.unfocus();
this._focus = elem;
if (!this.silent) {
this.fire('focus', this._focus);
}
return true;
},
/**
* Remove focus if any.
*
* It will trigger "unfocus" event.
*
* @return {Boolean} succeed or not.
*
* @fires SpatialNavigator#unfocus
* @memberof SpatialNavigator.prototype
*/
unfocus: function snUnfocus() {
if (this._focus) {
var elem = this._focus;
this._focus = null;
if (!this.silent) {
this.fire('unfocus', elem);
}
}
return true;
},
/**
* Get the currently focused element.
*
* @return {SpatialNavigatorElement} or null if nothing focused.
*
* @memberof SpatialNavigator.prototype
*/
getFocusedElement: function snGetFocusedElement() {
return this._focus;
},
/**
* Given a direction, find the element nearest to the focus element in that
* direction. This is equivalent to {@link SpatialNavigator#navigate} with
* focused element passed as target.
*
* @param {String} direction
* It should be "left", "right", "up" or "down".
*
* @return {Boolean} true if succeed, false if nothing can be focused.
*
* @memberof SpatialNavigator.prototype
*/
move: function snMove(direction) {
var reverse = {
'left': 'right',
'up': 'down',
'right': 'left',
'down': 'up'
};
if (!this._focus) {
this._previous = null;
this.focus();
} else {
var elem = this.navigate(this._focus, direction);
if (!elem) {
return false;
}
if (this.rememberSource) {
this._previous = {
source: this._focus,
destination: elem,
reverse: reverse[direction.toLowerCase()]
};
}
this.unfocus();
this.focus(elem);
}
return true;
},
/**
* Given a direction, find an element nearest to the target element in that
* direction.
*
* @param {SpatialNavigatorElement} target
* The origin of coordinates for traversal.
* @param {String} direction
* It should be "left", "right", "up" or "down".
*
* @return {SpatialNavigatorElement}
* The destination of the element which has the highest priority.
*
* @memberof SpatialNavigator.prototype
*/
navigate: function snNavigate(target, direction) {
if (!target || !direction || !this._collection) {
return null;
}
direction = direction.toLowerCase();
var rects = this._getAllRects(target);
var targetRect = this._getRect(target);
if (!targetRect || !rects.length) {
return null;
}
/* Get distance functions for ranking priorities relative to targetRect */
var distanceFunction = this._getDistanceFunction(targetRect);
/* Candidate {@link Rect}s are divided into nine regions based on its
position with respect to targetRect. */
var groups = this._partition(rects, targetRect);
/* {@link Rect}s in group 4 overlaps with targetRect. We distribute them
further into 9 regions based on its position with respect to the
center point of targetRect. */
var internalGroups = this._partition(groups[4], targetRect.center);
/* priorities: This big array carrys candidate elements with related
* distance functions by appropriate priority we want. Depenging on the
* direction, 3 kinds of elements are added separately in the following
* order:
*
* - 1st: candidates centered inside targetRect (group 4)
* (so we pick up corresponding internalGroups).
* - 2nd: in groups of straight direction (group 1, 3, 5, 7).
* - 3rd: in groups of oblique direction (group 0, 2, 6, 8).
*
* For each kind of element above, ranking is performed by the following
* rules (distance functions) in order:
*
* - 1st: distance between candidate and target.
* - 2nd: absolute coordinate of candidates.
* - 3rd: distance of left or top coordinate between candidate and
* target (for oblique direction only)
*
* The switch...case block below is just to construct this array.
* We just pick the required order into array here, then call
* {SpatialNavigator#_prioritize} to do the trick.
*/
var priorities;
switch (direction) {
case 'left':
priorities = [
{
group: internalGroups[0].concat(internalGroups[3])
.concat(internalGroups[6]),
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.topIsBetter
]
},
{
group: groups[3],
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.topIsBetter
]
},
{
group: groups[0].concat(groups[6]),
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.rightIsBetter,
distanceFunction.nearTargetTopIsBetter
]
}
];
break;
case 'right':
priorities = [
{
group: internalGroups[2].concat(internalGroups[5])
.concat(internalGroups[8]),
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.topIsBetter
]
},
{
group: groups[5],
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.topIsBetter
]
},
{
group: groups[2].concat(groups[8]),
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.leftIsBetter,
distanceFunction.nearTargetTopIsBetter
]
}
];
break;
case 'up':
priorities = [
{
group: internalGroups[0].concat(internalGroups[1])
.concat(internalGroups[2]),
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.leftIsBetter
]
},
{
group: groups[1],
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.leftIsBetter
]
},
{
group: groups[0].concat(groups[2]),
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.bottomIsBetter,
distanceFunction.nearTargetLeftIsBetter
]
}
];
break;
case 'down':
priorities = [
{
group: internalGroups[6].concat(internalGroups[7])
.concat(internalGroups[8]),
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.leftIsBetter
]
},
{
group: groups[7],
distance: [
distanceFunction.nearHorizonIsBetter,
distanceFunction.leftIsBetter
]
},
{
group: groups[6].concat(groups[8]),
distance: [
distanceFunction.nearPlumbLineIsBetter,
distanceFunction.topIsBetter,
distanceFunction.nearTargetLeftIsBetter
]
}
];
break;
default:
return null;
}
if (this.straightOnly) {
// Ignore candidates in oblique direction.
priorities.pop();
}
var dest = this._prioritize(priorities, target, direction);
if (!dest) {
return null;
}
return dest.element;
}
});
exports.SpatialNavigator = SpatialNavigator;
})(window);