VulpesOS/gaia/shared/elements/gaia_grid/js/grid_view.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

588 lines
18 KiB
JavaScript
Executable file

'use strict';
/* global GridDragDrop */
/* global GaiaGrid */
/* global GridLayout */
/* global GridZoom */
/* global LazyLoader */
(function(exports) {
/* The time for which we'll disable launching other icons after tapping on
* an app icon. This will also disable edit mode.
*/
const APP_LAUNCH_TIMEOUT = 3000;
/**
* GridView is a generic class to render and display a grid of items.
* @param {Object} config Configuration object containing:
* - element: The shadow root of the grid
*/
function GridView(config) {
this.config = config;
this.clickIcon = this.clickIcon.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
if (config.features.zoom) {
this.zoom = new GridZoom(this);
}
this.layout = new GridLayout(this);
// Set columns if we have a 'cols' attribute
if (config.element.hasAttribute('cols')) {
this.layout.cols = parseInt(config.element.getAttribute('cols'), 10);
}
// Enable event listeners when instantiated.
this.start();
}
GridView.prototype = {
/**
* List of all application icons.
* Maps an icon identifier to an icon object.
*/
icons: {},
/**
* Lists of all displayed objects in the homescreen.
* Includes app icons, dividers, and bookmarks.
*/
items: [],
/**
* Returns a reference to the gaia-grid element.
*/
get element() {
return this.config.element;
},
/**
* Sets the number of columns.
*/
set cols(value) {
this.layout.cols = value;
},
/**
* We are in the state of launching an app.
*/
_launchingApp: false,
/**
* Adds an item into the items array.
* If the item is an icon, add it to icons.
* @param {Object} item The grid object, should inherit from GridItem.
* @param {Object} insertTo The position to insert the item into our list.
* @param {Boolean} expandGroup Expand the group this item is will be in.
*/
add: function(item, insertTo, expandGroup) {
if (!item) {
return;
}
if (item.identifier) {
// If we already have an item with this identifier, exit.
// This avoids a potential race condition where we might have duplicate
// items with the same identifiers in the grid. This should not happen.
if (this.icons[item.identifier]) {
console.log('Error, duplicate identifier: ',
item.identifier, new Error().stack);
return;
}
this.icons[item.identifier] = item;
} else if (item.detail.type !== 'divider' &&
item.detail.type !== 'placeholder') {
// If the item does not have an identifier, and is not a placeholder
// or divider, do not add it to the grid.
console.log('Error, could not load identifier for object: ',
JSON.stringify(item.detail));
return;
}
// If insertTo it is a number, splice.
if (!isNaN(parseFloat(insertTo)) && isFinite(insertTo)) {
this.items.splice(insertTo, 0, item);
} else {
insertTo = this.items.length;
this.items.push(item);
}
if (expandGroup) {
for (var i = insertTo + 1, iLen = this.items.length;
i < iLen; i++) {
var divider = this.items[i];
if (divider.detail.type === 'divider') {
if (divider.detail.collapsed) {
divider.expand();
}
break;
}
}
}
},
/**
* Finds nearest item by and returns an index, or null if an item isn't
* found.
* @param {Number} x relative to the screen
* @param {Number} y relative to the screen
* @param {Boolean} isRow whether the position is to be considered as a row
* instead of an individual point.
*/
getNearestItemIndex: function(x, y, isRow) {
var foundIndex = null;
var leastDistance = null;
var itemMiddleOffset = this.layout.gridItemWidth / 2;
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
var item = this.items[i];
// Don't consider items that can't be dragged
if (!item.isDraggable()) {
continue;
}
// If sections are disabled, don't consider dividers
if (this.config.features.disableSections &&
item.detail.type === 'divider') {
continue;
}
// Do not consider collapsed items, unless they are dividers.
if (item.detail.type !== 'divider' &&
item.element.classList.contains('collapsed')) {
continue;
}
var middleX = item.x + itemMiddleOffset;
var middleY = item.y + item.pixelHeight / 2;
var xDistance = (isRow || item.detail.type === 'divider') ?
0 : x - middleX;
var yDistance = y - middleY;
var distance = Math.sqrt(
xDistance * xDistance +
yDistance * yDistance);
if (leastDistance === null || distance < leastDistance) {
leastDistance = distance;
foundIndex = i;
}
}
return foundIndex;
},
start: function() {
this.element.addEventListener('click', this.clickIcon);
window.addEventListener('visibilitychange', this.onVisibilityChange);
},
stop: function() {
this.element.removeEventListener('click', this.clickIcon);
window.removeEventListener('visibilitychange', this.onVisibilityChange);
},
findItemFromElement: function(element, excludeCollapsedIcons) {
while (element && element.parentNode !== this.element) {
element = element.parentNode;
}
if (!element) {
return null;
}
var i, iLen = this.items.length;
var identifier = element.dataset.identifier;
var icon = this.icons[identifier];
// If the element didn't have an identifier, try to search for it
// manually.
if (!icon) {
for (i = 0; i < iLen; i++) {
if (this.items[i].element === element) {
icon = this.items[i];
break;
}
}
}
if (icon && excludeCollapsedIcons) {
// If this is a collapsed item, return its group instead
if (icon.detail.type !== 'divider' &&
icon.detail.type !== 'placeholder' &&
icon.element.classList.contains('collapsed')) {
for (i = icon.detail.index + 1; i < iLen; i++) {
if (this.items[i].detail.type === 'divider') {
return this.items[i];
}
}
console.warn('Collapsed icon found with no group');
icon = null;
}
}
return icon;
},
onVisibilityChange: function() {
this._launchingApp = false;
},
/**
* Launches an app.
*/
clickIcon: function(e) {
e.preventDefault();
var inEditMode = this.dragdrop && this.dragdrop.inEditMode;
var action = 'launch';
if (e.target.classList.contains('remove')) {
action = 'remove';
}
var icon = this.findItemFromElement(e.target);
if (!icon) {
return;
}
if (action === 'launch') {
// We do not allow users to launch icons in edit mode
if (inEditMode && e.target.classList.contains('icon')) {
// Check if we're trying to edit a bookmark
if (!icon.isEditable()) {
return;
}
action = 'edit';
} else {
// If the icon can't be launched, bail out early
if (!icon[action]) {
return;
}
// Add a 'launching' class to the icon to style it with CSS.
icon.element.classList.add('launching');
// XXX: We can't have nice things. Remove the launching class after an
// arbitrary time to restore the state. We want the icon to return
// to its original state after launching the app, but visibilitychange
// will not work because activities do not fire it.
var returnTimeout = 500;
setTimeout(function stateReturn() {
if (icon.element) {
icon.element.classList.remove('launching');
}
}, returnTimeout);
}
}
if ((icon.detail.type === 'app' || icon.detail.type === 'bookmark') &&
this._launchingApp) {
return;
}
if ((icon.detail.type === 'app' && icon.appState === 'ready') ||
icon.detail.type === 'bookmark') {
this._launchingApp = true;
if (this._launchingTimeout) {
window.clearTimeout(this._launchingTimeout);
this._launchingTimeout = null;
}
// This avoids some edge cases if we didn't get visibilitychange anyway.
this._launchingTimeout = window.setTimeout(function() {
this._launchingTimeout = null;
this._launchingApp = false;
}.bind(this), APP_LAUNCH_TIMEOUT);
}
icon[action](e.target);
},
/**
* Scrubs the list of items, removing empty sections.
*/
cleanItems: function(skipDivider) {
var appCount = 0;
var toRemove = [];
this.items.forEach(function(item, idx) {
if (item instanceof GaiaGrid.Divider) {
if (appCount === 0) {
toRemove.push(idx);
}
appCount = 0;
} else {
appCount++;
}
}, this);
toRemove.reverse();
toRemove.forEach(function(idx) {
var removed = this.items.splice(idx, 1)[0];
removed.remove();
}, this);
// There should always be a divider at the end, it's hidden in CSS when
// not in edit mode.
if (skipDivider) {
return;
}
// If the last item is not a divider, add a new divider at the end.
var lastItem = this.items[this.items.length - 1];
if (!lastItem || !(lastItem instanceof GaiaGrid.Divider)) {
this.items.push(new GaiaGrid.Divider());
}
},
/**
* Removes placeholders from the grid.
*/
removeAllPlaceholders: function() {
var toSplice = [];
var previousItem;
this.items.forEach(function(item, idx) {
if (item instanceof GaiaGrid.Placeholder) {
// If the previous item is a divider, or there is no previous item,
// and we are in edit mode, we do not remove the placeholder.
// This is so the section will remain even if the user drags the
// icon around. Bug 1014982
if ((!previousItem ||
(previousItem && previousItem instanceof GaiaGrid.Divider)) &&
this.dragdrop && this.dragdrop.inDragAction) {
return;
}
toSplice.push(idx);
}
previousItem = item;
}, this);
toSplice.reverse().forEach(function(idx) {
this.items.splice(idx, 1)[0].remove();
}, this);
},
/**
* Clears the grid view of all items.
*/
clear: function() {
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
var item = this.items[i];
if (item.element) {
this.element.removeChild(item.element);
// We must de-reference element explicitly so we can re-use item
// objects the next time we call render.
item.element = null;
item.lastX = null;
item.lastY = null;
item.lastScale = null;
}
}
this.items = [];
this.icons = {};
},
/**
* Creates placeholders and injects them into the grid.
* @param {Array} coordinates [x,y] coordinates on the grid of the first
* item in grid units.
* @param {Integer} idx The position of the first placeholder.
* @param {Integer} idx The number of placeholders to create.
*/
createPlaceholders: function(coordinates, idx, count) {
var isRTL = (document.documentElement.dir === 'rtl');
for (var i = 0; i < count; i++) {
var item = new GaiaGrid.Placeholder();
this.items.splice(idx + i, 0, item);
item.setPosition(idx + i);
var xPosition = (coordinates[0] + i) * this.layout.gridItemWidth;
if (isRTL) {
xPosition =
(this.layout.constraintSize - this.layout.gridItemWidth) -
xPosition;
}
item.setCoordinates(xPosition, this.layout.offsetY);
item.render();
}
},
/**
* Renders all icons.
* Positions app icons and dividers accoriding to available space
* on the grid.
* @param {Object} options Options to render with including:
* - skipDivider {Boolean} Whether or not to skip the divider
* - rerender {Boolean} Whether we should clean elements and re-render.
*/
render: function(options) {
var self = this;
options = options || {};
this.removeAllPlaceholders();
this.cleanItems(options.skipDivider);
// Reset offset steps
var oldHeight = this.layout.offsetY;
this.layout.offsetY = 0;
// Grid render coordinates
var x = 0;
var y = 0;
/**
* Steps the y-axis.
* @param {Object} item
*/
function step(item) {
var pixelHeight = item.pixelHeight;
self.layout.stepYAxis(pixelHeight);
x = 0;
y++;
}
var pendingCachedIcons = 0;
var onCachedIconRendered = () => {
if (--pendingCachedIcons <= 0) {
this.element.removeEventListener('cached-icon-rendered',
onCachedIconRendered);
this.element.dispatchEvent(new CustomEvent('cached-icons-rendered'));
}
};
this.element.addEventListener('cached-icon-rendered',
onCachedIconRendered);
var nextDivider = null;
var oddDivider = true;
var isRTL = (document.documentElement.dir === 'rtl');
for (var idx = 0; idx < this.items.length; idx++) {
var item = this.items[idx];
// Remove the element if we are re-rendering.
if (options.rerender && item.element) {
this.element.removeChild(item.element);
item.element = null;
}
if (item.detail.type === 'divider') {
nextDivider = null;
} else {
if (!nextDivider) {
for (var i = idx + 1; i < this.items.length; i++) {
if (this.items[i].detail.type === 'divider') {
nextDivider = this.items[i];
oddDivider = !oddDivider;
break;
}
}
// Make sure to leave room for group headers
if (nextDivider &&
!nextDivider.detail.collapsed) {
this.layout.offsetY += nextDivider.headerHeight;
}
}
// If this item is in a collapsed group, we need to skip rendering.
if (nextDivider && nextDivider.detail.collapsed) {
item.setPosition(idx);
continue;
}
}
// If the item would go over the boundary before rendering,
// step the y-axis.
if (x > 0 && item.gridWidth > 1 &&
x + item.gridWidth >= this.layout.cols) {
// Insert placeholders to fill remaining space
var remaining = this.layout.cols - x;
this.createPlaceholders([x, y], idx, remaining);
// Increment the current index due to divider insertion
idx += remaining;
item = this.items[idx];
// Step the y-axis by the size of the last row.
// For now we just check the height of the last item.
var lastItemInRow = this.items[idx - 1];
step(lastItemInRow);
}
item.setPosition(idx);
if (!options.skipItems) {
item.hasCachedIcon && ++pendingCachedIcons;
var xPosition = x * this.layout.gridItemWidth;
if (isRTL) {
xPosition =
(this.layout.constraintSize - this.layout.gridItemWidth) -
xPosition;
}
item.setCoordinates(xPosition, this.layout.offsetY);
if (!item.active) {
item.render();
}
if (item.detail.type === 'divider') {
if (oddDivider) {
item.element.classList.add('odd');
} else {
item.element.classList.remove('odd');
}
}
}
// Increment the x-step by the sizing of the item.
// If we go over the current boundary, reset it, and step the y-axis.
x += item.gridWidth;
if ((x >= this.layout.cols) && (idx < this.items.length - 1)) {
step(item);
}
}
// All the children of this element are absolutely positioned and then
// transformed, so the grid actually has no height. Fire an event that
// embedders can listen to discover the grid height.
if (this.layout.offsetY != oldHeight) {
if (this.dragdrop && this.dragdrop.inDragAction) {
// Delay size changes during drags to avoid jankiness when dragging
// items around due to touch positions changing.
this.layout.offsetY = oldHeight;
} else {
this.element.dispatchEvent(new CustomEvent('gaiagrid-resize',
{ detail: this.layout.offsetY }));
}
}
this.element.setAttribute('cols', this.layout.cols);
pendingCachedIcons === 0 && onCachedIconRendered();
this.loadDragDrop();
},
/**
* Loads dragdrop libraries and instantiates if necessary.
* DragDrop libraries are lazy laoded to save on startup time. They are
* loaded after the initial paint in order to paint the icons as fast
* as possible.
*/
loadDragDrop: function() {
if (!this.dragdrop && this.config.features.dragdrop) {
LazyLoader.load('shared/elements/gaia_grid/js/grid_dragdrop.js', () => {
if (this.dragdrop) {
return;
}
this.dragdrop = new GridDragDrop(this);
});
}
}
};
exports.GridView = GridView;
}(window));