VulpesOS/gaia/shared/js/smart-screen/scrollable.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

740 lines
23 KiB
JavaScript
Executable file

/* global evt, SpatialNavigator */
(function(exports) {
'use strict';
/**
*The DOM hierarchy used in XScrollable looks like:
* <frame>
* <list>
* <node>
* <item> (choosed by selector)
* <other elements>
* Users need to specify <frame>, <list>, <node> elements through initialize
* parameters.
*
* User can also omit <item> element, and specify <node>s as focus target.
* In this case, <node> and <item> refer to the same dom structure.
* To use scrollable.js, the minimal required css style is:
* #frame {
* width: (any width you would like to set);
* height: (any height you would like to set);
* overflow: hidden;
* }
*
* #list {
* width: 100%
* transition: transform 0.2s ease;
* transform-origin: 0 50%;
* position: relative;
* }
*
* .node {
* margin-left: 0;
* margin-left: 0;
* margin-top: (any margin you would like to set);
* margin-bottom: (any margin you would like to set);
* left: 0;
* position: absolute;
* }
* The main reason for this is that scrollable.js uses transformX internally
* to locate position of nodes (to achieve edit/arrange feature).
*
* Left margin should be set through scrollable.leftMargin.
* (which is default to 2, and can be assigned through initialize params)
*/
var DEFAULT_SPACING = 2;
var DEFAULT_LEFT_MARGIN = 2;
var REVERSE_LIST_BOUNDARY = 0.5;
function XScrollable(param) {
this.translateX = 0;
this._colspanOnFocus = 0;
this.frameElem = (typeof param.frameElem === 'string') ?
document.getElementById(param.frameElem) : param.frameElem;
this.listElem = (typeof param.listElem === 'string') ?
document.getElementById(param.listElem) : param.listElem;
this.nodes = Array.prototype.slice.call(this.listElem.children);
this.spacing = param.spacing || DEFAULT_SPACING;
this.leftMargin = param.leftMargin || DEFAULT_LEFT_MARGIN;
this.itemClassName = param.itemClassName;
var items = Array.prototype.slice.call(
this.listElem.getElementsByClassName(param.itemClassName));
this.listElem.addEventListener('transitionend', this);
var defaultItem = this.listElem.dataset.defaultItem;
this.spatialNavigator = new SpatialNavigator(items);
this.spatialNavigator.focus(
items.length > defaultItem ? items[defaultItem] : null);
this.spatialNavigator.on('focus', this.handleSelection.bind(this));
this.spatialNavigator.on('unfocus', this.handleUnfocus.bind(this));
this._setNodesPosition();
this.setScale(param.scale || 1);
this.isSliding = false;
this.isHovering = false;
this.hoveringItem = null;
this.hoveredItem = null;
this._isRevesed = false;
if (param.referenceElement) {
this.setReferenceElement(param.referenceElement);
}
}
XScrollable.prototype = evt({
CLASS_NAME: 'XScrollable',
uninit: function(elem) {
this.listElem.removeEventListener('transitionend', this);
},
getItemRect: function(elem) {
var frameRect = this.frameElem.getBoundingClientRect();
var node = this.getNodeFromItem(elem);
var idx = parseInt(node.dataset.idx, 10);
var tabstop = this._getTabstop(idx);
var result = { left: (frameRect.left +
(node.offsetWidth + this.spacing * 10) * tabstop) * this.scale +
this.translateX,
top: (frameRect.top + elem.offsetTop) * this.scale,
width: (elem.offsetWidth) * this.scale,
height: (elem.offsetHeight) * this.scale
};
return result;
},
getBoundingClientRect: function() {
return this.frameElem.getBoundingClientRect();
},
endSlide: function() {
// remove transition we added
this.listElem.classList.add('no-transition');
// if an user close home app when card-list is sliding, we have to force
// close the sliding transition (transition-delay = 0 is not working)
this.forceReflow(this.listElem);
this.listElem.classList.remove('no-transition');
this.listElem.style.transitionDuration = null;
// set positions of other nodes to create moving effect
if (!this.isHovering) {
// If a card is hovering over a folder, the positions and focus will not
// be set here.
this._setOtherNodesPosition(this.newCardIndex);
this.focus(this.newCardIndex);
}
this.fire('slideEnd');
this.isSliding = false;
},
_slide: function(newItem, idx) {
this.isSliding = true;
this.newCardIndex = idx;
// Start sliding animation of card list.
// Also consider the case when the card-list does not need to scroll
// or the document is not visibile.
var prevTransform = this.listElem.style.transform;
var distance = Math.abs(this._getScrollOffset(newItem) -
this.translateX);
var duration = (distance < 960) ? 0.3 : distance / 2000;
this.listElem.style.transitionDuration = duration + 's';
this.scrollTo(newItem);
if (!prevTransform ||
prevTransform === this.listElem.style.transform ||
this.refElem ||
document.visibilityState !== 'visible') {
this.endSlide();
}
},
scrollTo: function(itemElem) {
if (!itemElem) {
return this.translateX;
}
this.translateX = this._getScrollOffset(itemElem);
this._setScrollStyle();
},
resetScroll: function() {
this.translateX = 0;
this.scrollTo(this.getItem(0));
},
_setScrollStyle: function() {
this.listElem.style.transform = 'translateX(' + this.translateX + 'px) ' +
'scale(' + this.scale + ')';
},
setScale: function(scale) {
scale = scale ? scale : 1;
this.scale = scale;
// We need to reset translateX and let getScrollOffset detect the
// scroll amont again against new scale to focused element.
this.translateX = 0;
this.scrollTo(this.currentItem);
},
setReferenceElement: function(elem) {
this.refElem = elem;
if (!this.length || !this.refElem) {
return false;
}
var refRect;
if (this.refElem.CLASS_NAME === 'XScrollable') {
refRect = this.refElem.getItemRect(this.refElem.currentItem);
} else {
refRect = this.refElem.getBoundingClientRect();
}
// If the reference element locates at right of the screen without enough
// space, we need to show the list reversedly toward left of the screen.
this._isReversed = refRect.left >
this.frameElem.offsetWidth * REVERSE_LIST_BOUNDARY;
this.listElem.classList.toggle('reversed', this._isReversed);
// Determine initial focus (depends on reversed or not)
var initNode = this.getItemFromNode(
this.getNode(this._isReversed ? this.length - 1 : 0));
this.silentFocus(initNode);
// Calculate initial position with respect to reference element. The
// initNode should get its center aligned with reference element
// on x-axis.
// this.refPoint saves the x-coordinate that makes initNode aligned.
var unitLength = (initNode.offsetWidth + this.spacing * 10) * this.scale;
this.refPoint =
refRect.left + (refRect.width - initNode.offsetWidth * this.scale) / 2;
if (this._isReversed) {
this.translateX = this.refPoint - unitLength * (this.length - 1);
} else {
this.translateX = this.refPoint;
}
this._setScrollStyle();
return true;
},
// Tabstop: real position of an index in multiple of item width.
_getTabstop: function(idx) {
if (this.isHovering) {
var item = this.getItemFromNode(this.nodes[idx]);
if (item === this.hoveringItem || item === this.hoveredItem) {
var node1 = this.getNodeFromItem(this.hoveringItem);
var node2 = this.getNodeFromItem(this.hoveredItem);
return (parseInt(node1.dataset.idx, 10) +
parseInt(node2.dataset.idx, 10)) / 2;
}
}
if (idx < this.currentIndex) {
return idx;
} else if (idx === this.currentIndex) {
return idx + (this._colspanOnFocus / 2);
} else {
return idx + this._colspanOnFocus;
}
},
_getScrollOffset: function(itemElem) {
if (this.refElem) {
return this._getScrollOffsetByReferenceElement(itemElem);
}
var node = this.getNodeFromItem(itemElem);
var idx = parseInt(node.dataset.idx, 10);
var tabstop = this._getTabstop(idx);
var tabcount = this.length + this._colspanOnFocus;
var unitLength = (node.offsetWidth + this.spacing * 10) * this.scale;
var frameWidth = this.frameElem.offsetWidth;
// If elements don't overflow, align them in center.
if (unitLength * tabcount + this.leftMargin * 10 < frameWidth) {
return -(unitLength * tabcount - this.spacing * 10 - frameWidth) / 2;
}
if (unitLength * tabstop < this.leftMargin * 10 - this.translateX) {
if (tabstop !== 0) {
return -unitLength * (tabstop - 0.5);
} else {
return this.leftMargin * 10;
}
} else if (unitLength * (tabstop + 1) > frameWidth - this.translateX) {
return frameWidth - unitLength * (tabstop + 1.5);
} else {
return this.translateX;
}
},
// There's always a node center-aligned with the reference element.
// (if exist)
// TODO: Evaluate whether we can merge this function with _getScrollOffset.
_getScrollOffsetByReferenceElement: function(itemElem) {
var node = this.getNodeFromItem(itemElem);
var idx = parseInt(node.dataset.idx, 10);
var tabstop = this._getTabstop(idx);
var unitLength = (node.offsetWidth + this.spacing * 10) * this.scale;
var frameWidth = this.frameElem.offsetWidth;
// Count maximum nodes that can be shown from the aligned node
// to left/right viewport (aligned node itself is counted to right)
var maxLeftNodes = Math.floor(this.refPoint / unitLength);
var maxRightNodes = Math.floor((frameWidth - this.refPoint) / unitLength);
// Calculate translate amount with respect to reference point.
if (unitLength * tabstop < this.leftMargin * 10 - this.translateX) {
return this.refPoint - (maxLeftNodes + tabstop) * unitLength;
} else if (unitLength * (tabstop + 1) > (frameWidth - this.translateX)) {
return this.refPoint - (tabstop - maxRightNodes + 1) * unitLength;
} else {
return this.translateX;
}
},
getNodeFromItem: function(itemElem) {
if (!itemElem) {
return null;
}
var nodeElem = itemElem;
while (nodeElem.parentElement !== this.listElem) {
nodeElem = nodeElem.parentElement;
}
return nodeElem;
},
getItem: function(index) {
var node;
if (index < this.nodes.length) {
node = this.nodes[index];
}
return node;
},
getItemFromNode: function(nodeElem) {
if (!nodeElem) {
return null;
}
if (nodeElem.classList.contains(this.itemClassName)) {
return nodeElem;
} else {
return nodeElem.getElementsByClassName(this.itemClassName)[0];
}
},
getNextItem: function(itemElem) {
return this.getItemFromNode(this._getNextNode(itemElem));
},
getPrevItem: function(itemElem) {
return this.getItemFromNode(this._getPrevNode(itemElem));
},
_getNextNode: function(itemElem) {
var nodeElem = this.getNodeFromItem(itemElem);
var idx = parseInt(nodeElem.dataset.idx, 10) + 1;
if (idx < 0 || idx >= this.nodes.length) {
return null;
}
return this.nodes[idx];
},
_getPrevNode: function(itemElem) {
var nodeElem = this.getNodeFromItem(itemElem);
var idx = parseInt(nodeElem.dataset.idx, 10) - 1;
if (idx < 0 || idx >= this.nodes.length) {
return null;
}
return this.nodes[idx];
},
handleSelection: function(itemElem) {
this.scrollTo(itemElem);
this.fire('focus', this, itemElem, this.getNodeFromItem(itemElem));
},
handleUnfocus: function(itemElem) {
this.fire('unfocus', this, itemElem, this.getNodeFromItem(itemElem));
},
addNode: function(nodeElem) {
var itemElem = this.getItemFromNode(nodeElem);
if (!itemElem) {
return false;
}
this.nodes.push(nodeElem);
if (this.spatialNavigator.add(itemElem) &&
!!this.listElem.appendChild(nodeElem)) {
this._setNodePosition(this.nodes.length - 1);
return true;
}
return false;
},
getNode: function(index) {
return this.nodes[index];
},
removeNode: function(node) {
if (typeof node === 'number') {
node = this.nodes[node];
}
var itemElem = this.getItemFromNode(node);
if(!itemElem) {
return false;
}
var focus = this.spatialNavigator.getFocusedElement();
// When the selected item is being removed, we set focus to next item.
// If next item doesn't exist, we set focus to previous item.
var newfocus = (focus == itemElem) ?
this.getNextItem(focus) || this.getPrevItem(focus) :
focus;
this.spatialNavigator.remove(itemElem);
this.listElem.removeChild(node);
this.nodes.splice(parseInt(node.dataset.idx, 10), 1);
this._setNodesPosition();
this.spatialNavigator.focus(newfocus);
return true;
},
removeNodes: function(indices) {
// Find nearest nodes that is not removed as new focus.
var focusIdx = this.nodes.indexOf(this.getNodeFromItem(this.currentItem));
var newFocusIdx;
var minDistance = Number.MAX_VALUE;
var newNodes = this.nodes.filter(function(node, oldindex) {
if(indices.indexOf(oldindex) !== -1) {
var itemElem = this.getItemFromNode(node);
this.spatialNavigator.remove(itemElem);
this.listElem.removeChild(node);
return false;
}
var distance = Math.abs(focusIdx - oldindex);
if(distance < minDistance) {
minDistance = distance;
newFocusIdx = oldindex;
}
return true;
}, this);
var newFocus = this.getItemFromNode(this.nodes[newFocusIdx]);
var isRemovingHoveringItem = this.isHovering &&
indices.length === 1 &&
this.hoveringItem === this.getItemFromNode(this.nodes[indices[0]]);
this.nodes = newNodes;
// XXX newFocus check is a workaround to check if there's no node
// in the scrollable, after discussion we leave it here temporarily.
if (!this.isHovering && newFocus) {
// When currently no node is hovering over a folder and the scrollable
// is not empty after removing nodes,
// reset node positions and the next focus item.
this._setNodesPosition();
this.spatialNavigator.focus(newFocus);
} else if (isRemovingHoveringItem) {
// In case of hovering, we only remove the hovering item.
// When the removed node was hovering over a folder, after it's removed,
// reset the idx of the remaining nodes, set the focus to the hovered
// folder, fire the event to notify the remaining move to folder actions
// and unhover silently without firing an event.
var hoveringItem = this.hoveringItem;
var hoveredItem = this.hoveredItem;
this._colspanOnFocus = 1;
this.silentFocus(this.hoveredItem);
this.unhoverSilently();
this._setNodesPosition();
this.fire('hovering-node-removed', hoveringItem, hoveredItem);
}
},
insertNodeBefore: function(newNode, startNode) {
if (typeof startNode === 'number') {
startNode = this.nodes[startNode];
}
var itemElem = this.getItemFromNode(newNode);
if (!itemElem) {
return false;
}
var newIdx = startNode ?
parseInt(startNode.dataset.idx, 10) : this.nodes.length;
this.nodes.splice(newIdx, 0, newNode);
this.listElem.appendChild(newNode);
this._setNodePosition(newIdx);
this.spatialNavigator.add(itemElem);
if (this.refElem) {
this.realignToReferenceElement();
this._shiftNodesPosition(1, newIdx);
}
this._slide(this.getItemFromNode(newNode), newIdx);
return true;
},
insertNodeOver: function(newNode, startNode) {
if (typeof startNode === 'number') {
startNode = this.nodes[startNode];
}
var itemElem = this.getItemFromNode(newNode);
if (!itemElem) {
return false;
}
this._colspanOnFocus = 0;
var newIdx = parseInt(startNode.dataset.idx, 10);
this.nodes.splice(newIdx, 0, newNode);
this.listElem.appendChild(newNode);
this._setNodesPosition();
this.spatialNavigator.add(itemElem);
this.hover(itemElem, this.getItemFromNode(startNode));
this.focus(newIdx);
this._slide(this.getItemFromNode(startNode), newIdx + 1);
this.fire('node-inserted-over-folder');
return true;
},
get currentItem() {
return this.spatialNavigator.getFocusedElement();
},
get currentIndex() {
return this.nodes.indexOf(
this.getNodeFromItem(this.spatialNavigator.getFocusedElement()));
},
get length() {
return this.nodes.length;
},
get isReversed() {
return this._isReversed;
},
get allItems() {
var that = this;
var items = [];
this.nodes.forEach(function(node) {
var item = that.getItemFromNode(node);
if (item) {
items.push(item);
}
});
return items;
},
_setOtherNodesPosition: function(skipIdx) {
this.nodes.forEach(function(node, idx) {
if (idx != skipIdx) {
this._setNodePosition(idx);
}
}, this);
},
_setNodesIdx: function() {
this.nodes.forEach(function(node, idx) {
this.nodes[idx].dataset.idx = idx;
}, this);
},
_setNodesPosition: function() {
this.nodes.forEach(function(node, idx) {
this._setNodePosition(idx);
}, this);
},
_setNodePosition: function(idx) {
this.nodes[idx].dataset.idx = idx;
var tabstop = this._getTabstop(idx);
this.getNodeFromItem(this.nodes[idx]).style.transform =
'translateX(calc((100% + ' + this.spacing + 'rem) * ' + tabstop + '))';
},
/**
* Shift node positions without animations.
* @param {[type]} offset The unit offset. Use positive integer to shift
* right and negative integer to shift left.
* @param {[type]} skipIdx The index of the node that needs not be shifted.
*/
_shiftNodesPosition: function(offset, skipIdx) {
this.nodes.forEach(function(node, idx) {
if (idx !== skipIdx) {
node.style.transitionProperty = 'none';
var tabstop = this._getTabstop(idx) + offset;
this.getNodeFromItem(node).style.transform =
'translateX(calc((100% + ' +
this.spacing + 'rem) * ' + tabstop + '))';
}
}.bind(this));
this.forceReflow(this.nodes[0]);
this.nodes.forEach(function(node) {
node.style.transitionProperty = '';
});
},
swap: function(node1, node2) {
if (typeof node1 === 'number') {
node1 = this.nodes[node1];
}
if (typeof node2 === 'number') {
node2 = this.nodes[node2];
}
if (!node1 || !node2) {
return false;
}
var idx1 = parseInt(node1.dataset.idx, 10);
var idx2 = parseInt(node2.dataset.idx, 10);
this.nodes[idx1] = node2;
this.nodes[idx2] = node1;
this._setNodePosition(idx1);
this._setNodePosition(idx2);
this.focus();
// TODO: handle cases that one of the swapped nodes is focused.
// ... should we really need to handle this case?
return true;
},
hover: function(item1, item2) {
var node1 = this.getNodeFromItem(item1);
var node2 = this.getNodeFromItem(item2);
if (!node1 || !node2) {
return false;
}
var idx1 = parseInt(node1.dataset.idx, 10);
var idx2 = parseInt(node2.dataset.idx, 10);
this.isHovering = true;
this.hoveringItem = item1;
this.hoveredItem = item2;
item1.classList.add('hover');
item2.classList.add('hovered');
node1.classList.add('hover');
node2.classList.add('hovered');
this._setNodePosition(idx1);
this._setNodePosition(idx2);
this.fire('hover', this);
return true;
},
unhover: function(shouldResetCardPositions) {
var node1 = this.getNodeFromItem(this.hoveringItem);
var node2 = this.getNodeFromItem(this.hoveredItem);
this.fire('unhover', this);
this.isHovering = false;
if (shouldResetCardPositions) {
this._setNodesPosition();
}
this.hoveringItem.classList.remove('hover');
this.hoveredItem.classList.remove('hovered');
node1.classList.remove('hover');
node2.classList.remove('hovered');
this.hoveringItem = null;
this.hoveredItem = null;
},
unhoverSilently: function() {
var node = this.getNodeFromItem(this.hoveredItem);
this.isHovering = false;
this.hoveredItem.classList.remove('hovered');
node.classList.remove('hovered');
this.hoveringItem = null;
this.hoveredItem = null;
},
getTargetItem: function(direction) {
if (direction === 'left') {
return this.getPrevItem(this.currentItem);
} else if (direction === 'right') {
return this.getNextItem(this.currentItem);
}
},
focus: function(item) {
if (typeof item === 'number') {
item = this.getItemFromNode(this.nodes[item]);
} else if (typeof item === 'undefined') {
item = this.currentItem || 0;
}
this.spatialNavigator.focus(item);
},
silentFocus: function(item) {
this.spatialNavigator.silent = true;
this.focus(item);
this.spatialNavigator.silent = false;
},
move: function(direction) {
return this.spatialNavigator.move(direction);
},
clean: function() {
this.spatialNavigator.setCollection();
this.spatialNavigator.unfocus();
this.listElem.innerHTML = '';
this.nodes.length = 0;
},
isEmpty: function() {
return !this.nodes.length;
},
handleEvent: function (evt) {
if (evt.type === 'transitionend') {
if (evt.target === this.listElem && evt.propertyName === 'transform') {
if (this.isSliding) {
this.endSlide();
}
this.fire('listTransformEnd', this.listElem);
} else if (evt.target.classList.contains('card') &&
evt.propertyName === 'transform') {
this.fire('nodeTransformEnd', evt.target);
}
}
},
setColspanOnFocus: function(colspanOnFocus) {
this._colspanOnFocus = colspanOnFocus;
this._setNodesPosition();
this.scrollTo(this.currentItem);
},
realignToReferenceElement: function() {
if (this.refElem) {
this.setReferenceElement(this.refElem);
}
},
forceReflow: function(element) {
getComputedStyle(element).width;
}
});
exports.XScrollable = XScrollable;
})(window);