diff --git a/apps/homescreen/bower_components/gaia-container/script.js b/apps/homescreen/bower_components/gaia-container/script.js index 2732c6d005cf..4b57981ad5f1 100644 --- a/apps/homescreen/bower_components/gaia-container/script.js +++ b/apps/homescreen/bower_components/gaia-container/script.js @@ -33,6 +33,7 @@ window.GaiaContainer = (function(exports) { shadow.appendChild(this._template); this._frozen = false; + this._immediateLock = 0; this._pendingStateChanges = []; this._children = []; this._dnd = { @@ -103,9 +104,13 @@ window.GaiaContainer = (function(exports) { Object.defineProperty(proto, 'children', { get: function() { - return this._children.map((child) => { - return child.element; + var children = []; + this._children.forEach(child => { + if (!child.removed) { + children.push(child.element); + } }); + return children; }, enumerable: true }); @@ -117,6 +122,18 @@ window.GaiaContainer = (function(exports) { enumerable: true }); + Object.defineProperty(proto, 'firstElementChild', { + get: function() { + for (var child of this._children) { + if (child.element.nodeType === 1) { + return child.element; + } + } + return null; + }, + enumerable: true + }); + Object.defineProperty(proto, 'lastChild', { get: function() { var length = this._children.length; @@ -125,6 +142,18 @@ window.GaiaContainer = (function(exports) { enumerable: true }); + Object.defineProperty(proto, 'lastElementChild', { + get: function() { + for (var i = this._children.length; i >= 0; i--) { + if (this._children[i].element.nodeType === 1) { + return this._chidlren[i].element; + } + } + return null; + }, + enumerable: true + }); + Object.defineProperty(proto, 'dragAndDrop', { get: function() { return this.getAttribute('drag-and-drop') !== null; @@ -167,6 +196,7 @@ window.GaiaContainer = (function(exports) { throw 'removeChild called on unknown child'; } + childToRemove.removed = true; this.changeState(childToRemove, 'removed', () => { this.realRemoveChild(childToRemove.container); this.realRemoveChild(childToRemove.master); @@ -237,6 +267,10 @@ window.GaiaContainer = (function(exports) { throw 'reorderChild called with null element'; } + if (element === referenceElement) { + return; + } + var children = this._children; var child = null; var childIndex = null; @@ -330,6 +364,13 @@ window.GaiaContainer = (function(exports) { * next frame. */ proto.changeState = function(child, state, callback) { + if (this._immediateLock) { + if (callback) { + callback(); + } + return; + } + // Check that the child is still attached to this parent (can happen if // the child is removed while frozen). if (child.container.parentNode !== this) { @@ -355,8 +396,8 @@ window.GaiaContainer = (function(exports) { child.container.removeEventListener('animationstart', animStart); - window.clearTimeout(child[state]); - delete child[state]; + window.clearTimeout(child.states[state]); + delete child.states[state]; var self = this; child.container.addEventListener('animationend', function animEnd() { @@ -371,8 +412,8 @@ window.GaiaContainer = (function(exports) { child.container.addEventListener('animationstart', animStart); child.container.classList.add(state); - child[state] = window.setTimeout(() => { - delete child[state]; + child.states[state] = window.setTimeout(() => { + delete child.states[state]; child.container.removeEventListener('animationstart', animStart); child.container.classList.remove(state); if (callback) { @@ -381,6 +422,32 @@ window.GaiaContainer = (function(exports) { }, STATE_CHANGE_TIMEOUT); }; + /** + * Any DOM functions executed within callback will occur synchronously and + * immediately, without transitions. + */ + proto.immediate = function(callback) { + ++this.immediateLock; + callback(); + --this.immediateLock; + }; + + /** + * Controls whether a child element should use transforms or absolute + * positioning for layout. + */ + proto.setUseTransform = function(element, useTransform) { + var children = this._children; + for (var i = 0, iLen = children.length; i < iLen; i++) { + if (children[i].element === element) { + children[i].useTransform = useTransform; + return; + } + } + + throw 'setUseTransform called on unknown child'; + }; + proto.getChildOffsetRect = function(element) { var children = this._children; for (var i = 0, iLen = children.length; i < iLen; i++) { @@ -407,10 +474,10 @@ window.GaiaContainer = (function(exports) { proto.getChildFromPoint = function(x, y) { var children = this._children; - for (var parent = this.parentElement; parent; - parent = parent.parentElement) { - x += parent.scrollLeft - parent.offsetLeft; - y += parent.scrollTop - parent.offsetTop; + for (var parent = this.parentNode; parent; + parent = parent.parentNode || parent.host) { + x += (parent.scrollLeft || 0) - (parent.offsetLeft || 0); + y += (parent.scrollTop || 0) - (parent.offsetTop || 0); } for (var i = 0, iLen = children.length; i < iLen; i++) { var child = children[i]; @@ -442,11 +509,13 @@ window.GaiaContainer = (function(exports) { this._dnd.child.container.style.top = '0'; this._dnd.child.container.style.left = '0'; this._dnd.child.markDirty(); + var dragChild = this._dnd.child; this._dnd.child = null; this._dnd.active = false; this.synchronise(); this._dnd.clickCapture = true; - this.dispatchEvent(new CustomEvent('drag-finish')); + this.dispatchEvent(new CustomEvent('drag-finish', + { detail: { target: dragChild.element } })); } }; @@ -740,6 +809,9 @@ window.GaiaContainer = (function(exports) { function GaiaContainerChild(element) { this._element = element; + this._useTransform = true; + this.states = {}; + this.removed = false; this.markDirty(); } @@ -781,6 +853,24 @@ window.GaiaContainer = (function(exports) { return this._master; }, + set useTransform(useTransform) { + if (this._useTransform === useTransform) { + return; + } + this._useTransform = useTransform; + + // Guarantee the next call to synchronise works + this._lastMasterTop = null; + this._lastMasterLeft = null; + + if (this._container) { + this._container.style.transform = ''; + this._container.style.top = '0'; + this._container.style.left = '0'; + this.synchroniseContainer(); + } + }, + /** * Clears any cached style properties. To be used if elements are * manipulated outside of the methods of this object. @@ -802,7 +892,7 @@ window.GaiaContainer = (function(exports) { var element = this.element; var style = window.getComputedStyle(element); - var display = style.display; + var display = this.removed ? 'none' : style.display; var order = style.order; var width = element.offsetWidth; var height = element.offsetHeight; @@ -826,6 +916,12 @@ window.GaiaContainer = (function(exports) { * Synchronise the container's transform with the position of the master. */ synchroniseContainer() { + // Don't synchronise removed children (so they don't move around once + // removed). + if (this.removed) { + return; + } + var master = this.master; var container = this.container; @@ -837,7 +933,13 @@ window.GaiaContainer = (function(exports) { this._lastMasterTop = top; this._lastMasterLeft = left; - container.style.transform = 'translate(' + left + 'px, ' + top + 'px)'; + if (this._useTransform) { + container.style.transform = + 'translate(' + left + 'px, ' + top + 'px)'; + } else { + container.style.top = top + 'px'; + container.style.left = left + 'px'; + } } } }; diff --git a/apps/homescreen/index.html b/apps/homescreen/index.html index 86ce6455b684..cd567b1e2e0b 100644 --- a/apps/homescreen/index.html +++ b/apps/homescreen/index.html @@ -54,6 +54,7 @@ + diff --git a/apps/homescreen/js/apps.js b/apps/homescreen/js/apps.js index 08079bc5ccec..f6f17bf77b6b 100644 --- a/apps/homescreen/js/apps.js +++ b/apps/homescreen/js/apps.js @@ -98,15 +98,19 @@ this.appsVisible = false; // Drag-and-drop + this.container = null; this.dragging = false; this.draggedIndex = -1; this.autoScrollInterval = null; this.autoScrollOverflowTimeout = null; this.hoverIcon = null; + this.openGroup = null; // Edit mode this.editMode = false; this.shouldEnterEditMode = false; + this.shouldCreateGroup = false; + this.draggingGroup = false; this.selectedIcon = null; this.rename.addEventListener('click', e => { e.preventDefault(); @@ -127,12 +131,9 @@ this.lastWindowHeight = window.innerHeight; // Signal handlers - this.icons.addEventListener('activate', this); - this.icons.addEventListener('drag-start', this); - this.icons.addEventListener('drag-move', this); - this.icons.addEventListener('drag-end', this); - this.icons.addEventListener('drag-rearrange', this); - this.icons.addEventListener('drag-finish', this); + this.attachInputHandlers(this.icons); + this.touchSelectedIcon = this.touchSelectedIcon.bind(this); + this.icons.addEventListener('touchstart', this); navigator.mozApps.mgmt.addEventListener('install', this); navigator.mozApps.mgmt.addEventListener('uninstall', this); window.addEventListener('localized', this); @@ -239,14 +240,12 @@ document.addEventListener('bookmarks_store-set', (e) => { var id = e.detail.id; this.bookmarks.get(id).then((bookmark) => { - for (var child of this.icons.children) { - var icon = child.firstElementChild; + this.iterateIcons(icon => { if (icon.bookmark && icon.bookmark.id === id) { icon.bookmark = bookmark.data; icon.refresh(); - return; } - } + }); this.addAppIcon(bookmark.data); this.storeAppOrder(); }); @@ -254,10 +253,9 @@ document.addEventListener('bookmarks_store-removed', (e) => { var id = e.detail.id; - for (var child of this.icons.children) { - var icon = child.firstElementChild; + this.iterateIcons((icon, container, parent) => { if (icon.bookmark && icon.bookmark.id === id) { - this.icons.removeChild(child, () => { + parent.removeChild(container, () => { this.storeAppOrder(); this.refreshGridSize(); this.snapScrollPosition(); @@ -267,18 +265,16 @@ if (this.selectedIcon === icon) { this.updateSelectedIcon(null); } - return; } - } + }); }); document.addEventListener('bookmarks_store-cleared', () => { - for (var child of this.icons.children) { - var icon = child.firstElementChild; + this.iterateIcons((icon, container, parent) => { if (icon.bookmark) { - this.icons.removeChild(child); + parent.removeChild(container); } - } + }); this.storeAppOrder(); this.refreshGridSize(); this.snapScrollPosition(); @@ -325,10 +321,9 @@ // Update icons that we've added from the startup metadata in case their // icons have updated or the icon size has changed. - for (var child of this.icons.children) { - var icon = child.firstElementChild; + this.iterateIcons(icon => { this.refreshIcon(icon); - } + }); // Add any applications that aren't in the startup metadata var newIcons = false; @@ -355,12 +350,36 @@ } Apps.prototype = { + attachInputHandlers: function(container) { + if (this.container) { + if (this.container === container) { + return; + } + + this.container.removeEventListener('activate', this); + this.container.removeEventListener('drag-start', this); + this.container.removeEventListener('drag-move', this); + this.container.removeEventListener('drag-end', this); + this.container.removeEventListener('drag-rearrange', this); + this.container.removeEventListener('drag-finish', this); + } + + this.container = container; + container.addEventListener('activate', this); + container.addEventListener('drag-start', this); + container.addEventListener('drag-move', this); + container.addEventListener('drag-end', this); + container.addEventListener('drag-rearrange', this); + container.addEventListener('drag-finish', this); + }, + get iconSize() { // If this._iconSize is 0, let's refresh the value. if (!this._iconSize) { var children = this.icons.children; for (var container of children) { - if (container.style.display !== 'none') { + if (container.style.display !== 'none' && + !this.isGroup(container)) { this._iconSize = container.firstElementChild.size; break; } @@ -401,6 +420,38 @@ window.performance.mark('contentInteractive'); }, + /** + * Iterate over icons in the panel. + * @callback: Callback to call, given three parameters; + * icon: The icon element + * container: The top-level container of the icon + * parent: The parent of the container housing the icon + */ + iterateIcons: function(callback) { + for (var container of this.icons.children) { + var child = container.firstElementChild; + if (child.localName === 'homescreen-group') { + for (var subContainer of child.container.children) { + callback(subContainer.firstElementChild, + subContainer, child.container); + } + } else { + callback(child, container, this.icons); + } + } + }, + + addGroup: function(before) { + var group = document.createElement('homescreen-group'); + var container = document.createElement('div'); + container.classList.add('group-container'); + container.order = -1; + container.appendChild(group); + this.icons.insertBefore(container, before); + + return group; + }, + addApp: function(app) { var manifest = app.manifest || app.updateManifest; if (!manifest) { @@ -422,22 +473,23 @@ } }, - addIconContainer: function(icon, entry) { + addIconContainer: function(icon, entry, parent) { var container = document.createElement('div'); - container.classList.add('icon-container'); + container.classList.add((icon.localName === 'homescreen-group') ? + 'group-container' : 'icon-container'); container.order = -1; container.appendChild(icon); // Try to insert the container in the right order if (entry !== -1 && this.startupMetadata[entry].order >= 0) { container.order = this.startupMetadata[entry].order; - var children = this.icons.children; + var children = parent.children; for (var i = 0, iLen = children.length; i < iLen; i++) { var child = children[i]; if (child.order !== -1 && child.order < container.order) { continue; } - this.icons.insertBefore(container, child); + parent.insertBefore(container, child); if (this.startupMetadata === null) { this.iconAdded(container); } @@ -446,7 +498,7 @@ } if (!container.parentNode) { - this.icons.appendChild(container); + parent.appendChild(container); if (this.startupMetadata === null) { this.iconAdded(container); } @@ -477,11 +529,49 @@ } } + // Check if the icon is grouped and create a group, fetch a group or + // delay adding as necessary + var parent = this.icons; + var groupId = (entry !== -1) ? this.startupMetadata[entry].group : ''; + if (groupId !== '') { + if (groupId === id) { + // We need to create a group + var group = document.createElement('homescreen-group'); + this.addIconContainer(group, entry, this.icons); + parent = group.container; + } else { + // We need to add to an existing group, or delay if one doesn't exist. + // In the situation that a group doesn't exist and we've finished + // startup, just add the icon without a group. This shouldn't happen, + // but we shouldn't fail if it somehow does. + var groupFound = false; + this.iterateIcons((icon, container, iconParent) => { + if (groupFound) { + return; + } + var id = this.getIconId(icon.app ? icon.app : icon.bookmark, + icon.entryPoint); + if (id === groupId) { + parent = iconParent; + groupFound = true; + } + }); + + if (parent === this.icons && this.startupMetadata !== null) { + // We didn't find the group and we're still starting up, so delay + // adding this icon. + this.pendingIcons[id] = Array.slice(arguments); + return; + } + } + } + var icon = document.createElement('gaia-app-icon'); if (entryPoint) { icon.entryPoint = entryPoint; } - var container = this.addIconContainer(icon, entry); + + var container = this.addIconContainer(icon, entry, parent); if (appOrBookmark.id) { icon.bookmark = appOrBookmark; @@ -500,7 +590,7 @@ icon.app.addEventListener('downloadapplied', function(app, container) { handleRoleChange(app, container); - this.icons.synchronise(); + container.parentNode.synchronise(); }.bind(this, icon.app, container)); handleRoleChange(icon.app, container); @@ -569,14 +659,20 @@ }, storeAppOrder: function() { + var i = 0; var storedOrders = []; - var children = this.icons.children; - for (var i = 0, iLen = children.length; i < iLen; i++) { - var appIcon = children[i].firstElementChild; - var id = this.getIconId(appIcon.app ? appIcon.app : appIcon.bookmark, - appIcon.entryPoint); - storedOrders.push({ id: id, order: i }); - } + var group = ''; + this.iterateIcons((icon, container, parent) => { + var id = this.getIconId(icon.app ? icon.app : icon.bookmark, + icon.entryPoint); + if (parent === this.icons) { + group = ''; + } else if (group === '') { + group = id; + } + storedOrders.push({ id: id, order: i++, group: group }); + }); + this.metadata.set(storedOrders).then( () => {}, (e) => { @@ -656,7 +752,7 @@ } var setGridHeight = () => { this.resizeTimeout = null; - this.icons.style.height = gridHeight + 'px'; + this.icons.style.height = this.pendingGridHeight + 'px'; this.gridHeight = this.pendingGridHeight; }; if (this.pendingGridHeight > this.gridHeight) { @@ -728,7 +824,7 @@ getChildIndex: function(child) { // XXX Note, we're taking advantage of gaia-container using // Array instead of HTMLCollection here. - return this.icons.children.indexOf(child); + return this.container.children.indexOf(child); }, removeSelectedIcon: function() { @@ -781,6 +877,11 @@ return (icon.bookmark || (icon.app && icon.app.removable)) ? true : false; }, + touchSelectedIcon: function() { + // Activate drag-and-drop immediately for selected icons + this.container.dragAndDropTimeout = 0; + }, + updateSelectedIcon: function(icon) { if (this.selectedIcon === icon) { return; @@ -788,7 +889,8 @@ if (this.selectedIcon && (!icon || this.iconIsEditable(icon))) { this.selectedIcon.classList.remove('selected'); - this.selectedIcon.removeEventListener('touchstart', this); + this.selectedIcon.removeEventListener('touchstart', + this.touchSelectedIcon); this.selectedIcon = null; } @@ -802,7 +904,7 @@ if (selectedRenameable || selectedRemovable) { this.selectedIcon = icon; icon.classList.add('selected'); - icon.addEventListener('touchstart', this); + icon.addEventListener('touchstart', this.touchSelectedIcon); this.rename.classList.toggle('active', selectedRenameable); this.remove.classList.toggle('active', selectedRemovable); } else if (!icon.classList.contains('uneditable')) { @@ -822,7 +924,7 @@ console.debug('Entering edit mode on ' + (icon ? icon.name : 'no icon')); this.updateSelectedIcon(icon); - if (this.editMode) { + if (this.editMode || !this.selectedIcon) { return; } @@ -844,19 +946,58 @@ this.updateSelectedIcon(null); }, + elementName: function(element) { + if (!element) { + return 'none'; + } + + var child = element.firstElementChild; + return child.localName === 'homescreen-group' ? + 'group' : child.name; + }, + + isGroup: function(element) { + return element && + element.firstElementChild.localName === 'homescreen-group'; + }, + + closeOpenGroup: function() { + if (this.openGroup) { + this.icons.freeze(); + this.openGroup.collapse(this.icons, () => { + this.icons.thaw(); + this.openGroup = null; + this.attachInputHandlers(this.icons); + this.icons.setAttribute('drag-and-drop', ''); + }, + this.storeAppOrder.bind(this)); + } + }, + handleEvent: function(e) { - var icon, child, id; + var icon, id, rect; switch (e.type) { // App launching case 'activate': + if (e.detail.target.parentNode.parentNode !== this.container) { + break; + } + e.preventDefault(); icon = e.detail.target.firstElementChild; + if (icon.localName === 'homescreen-group') { + this.openGroup = icon; + icon.expand(this.icons); + this.icons.removeAttribute('drag-and-drop'); + this.attachInputHandlers(icon.container); + break; + } // If we're in edit mode, remap taps to selection if (this.editMode) { this.enterEditMode(icon); - return; + break; } switch (icon.state) { @@ -885,19 +1026,35 @@ icon.launch(); break; } + + this.closeOpenGroup(); break; - // Activate drag-and-drop immediately for selected icons + // Close open group if we touch something in a different container case 'touchstart': - this.icons.dragAndDropTimeout = 0; + if (!this.openGroup || e.target === this.openGroup) { + break; + } + + var parent = e.target.parentNode; + while (parent && parent.localName !== 'gaia-container') { + parent = parent.parentNode; + } + + if (parent !== this.container) { + this.closeOpenGroup(); + e.preventDefault(); + } break; // Disable scrolling during dragging, and display bottom-bar case 'drag-start': - console.debug('Drag-start on ' + - e.detail.target.firstElementChild.name); + console.debug('Drag-start on ' + this.elementName(e.detail.target)); this.dragging = true; - this.shouldEnterEditMode = true; + this.draggingGroup = this.isGroup(e.detail.target); + this.shouldEnterEditMode = this.openGroup ? false : true; + this.shouldCreateGroup = false; + this.container.classList.add('dragging'); document.body.classList.add('dragging'); this.scrollable.style.overflow = 'hidden'; this.draggedIndex = this.getChildIndex(e.detail.target); @@ -906,6 +1063,7 @@ case 'drag-finish': console.debug('Drag-finish'); this.dragging = false; + this.container.classList.remove('dragging'); document.body.classList.remove('dragging'); document.body.classList.remove('autoscroll'); this.scrollable.style.overflow = ''; @@ -921,31 +1079,51 @@ } if (this.hoverIcon) { - this.hoverIcon.classList.remove('hover-before', 'hover-after'); + this.hoverIcon.classList.remove( + 'hover-before', 'hover-after', 'hover-over'); this.hoverIcon = null; } + if (e.detail.target) { + e.detail.target.classList.remove('hover-over'); + } + // Restore normal drag-and-drop after dragging selected icons - this.icons.dragAndDropTimeout = -1; + this.container.dragAndDropTimeout = -1; break; // Handle app/site editing and dragging to the end of the icon grid. case 'drag-end': - console.debug('Drag-end, target: ' + (e.detail.dropTarget ? - e.detail.dropTarget.firstElementChild.name : 'none')); + console.debug('Drag-end, target: ' + + this.elementName(e.detail.dropTarget)); if (e.detail.dropTarget === null && e.detail.clientX >= this.iconsLeft && e.detail.clientX < this.iconsRight) { + e.preventDefault(); + + // If there's an open group, check if we're dropping the icon outside + // of the group. + if (this.openGroup) { + rect = this.openGroup.container.getBoundingClientRect(); + if (e.detail.clientY < rect.top || e.detail.clientY > rect.bottom) { + console.log('Removing from group'); + this.openGroup.transferToContainer(e.detail.target, this.icons); + if (this.openGroup.container.children.length <= 1) { + this.closeOpenGroup(); + } + break; + } + } + // If the drop target is null, and the client coordinates are // within the panel, we must be dropping over the start or end of // the container. - e.preventDefault(); var bottom = e.detail.clientY < this.lastWindowHeight / 2; console.debug('Reordering dragged icon to ' + (bottom ? 'bottom' : 'top')); - this.icons.reorderChild(e.detail.target, - bottom ? this.icons.firstChild : null, - this.storeAppOrder.bind(this)); + this.container.reorderChild(e.detail.target, + bottom ? this.container.firstChild : null, + this.storeAppOrder.bind(this)); break; } @@ -955,6 +1133,23 @@ e.preventDefault(); this.enterEditMode(icon); } + break; + } + + if (this.shouldCreateGroup) { + var group; + e.preventDefault(); + if (this.isGroup(e.detail.dropTarget)) { + group = e.detail.dropTarget.firstElementChild; + group.transferFromContainer(e.detail.target, this.icons); + } else { + group = this.addGroup(e.detail.dropTarget); + group.transferFromContainer(e.detail.dropTarget, this.icons); + group.transferFromContainer(e.detail.target, this.icons, + this.storeAppOrder.bind(this)); + } + this.refreshGridSize(); + this.snapScrollPosition(); } break; @@ -968,7 +1163,8 @@ case 'drag-move': var inAutoscroll = false; - if (e.detail.clientY > this.lastWindowHeight - AUTOSCROLL_DISTANCE) { + if (!this.openGroup && + e.detail.clientY > this.lastWindowHeight - AUTOSCROLL_DISTANCE) { // User is dragging in the lower auto-scroll area inAutoscroll = true; if (this.autoScrollInterval === null) { @@ -978,7 +1174,7 @@ return true; }, AUTOSCROLL_DELAY); } - } else if (e.detail.clientY < AUTOSCROLL_DISTANCE) { + } else if (!this.openGroup && e.detail.clientY < AUTOSCROLL_DISTANCE) { // User is dragging in the upper auto-scroll area inAutoscroll = true; if (this.autoScrollInterval === null) { @@ -990,12 +1186,14 @@ } } else { // User is dragging in the grid, provide some visual feedback - var hoverIcon = this.icons.getChildFromPoint(e.detail.clientX, - e.detail.clientY); + var hoverIcon = this.container.getChildFromPoint(e.detail.clientX, + e.detail.clientY); if (this.hoverIcon !== hoverIcon) { if (this.hoverIcon) { this.shouldEnterEditMode = false; - this.hoverIcon.classList.remove('hover-before', 'hover-after'); + this.shouldCreateGroup = false; + this.hoverIcon.classList.remove( + 'hover-before', 'hover-after', 'hover-over'); } this.hoverIcon = (hoverIcon !== e.detail.target) ? hoverIcon : null; @@ -1006,6 +1204,20 @@ 'hover-before' : 'hover-after'); } } + + if (this.hoverIcon && !this.draggingGroup && !this.openGroup) { + // Evaluate whether we should create a group + var before = this.hoverIcon.classList.contains('hover-before'); + rect = this.container.getChildOffsetRect(this.hoverIcon); + if ((before && e.detail.clientX > rect.right - (rect.width / 2)) || + (!before && e.detail.clientX < rect.left + (rect.width / 2))) { + this.hoverIcon.classList.add('hover-over'); + this.shouldCreateGroup = true; + } else { + this.hoverIcon.classList.remove('hover-over'); + this.shouldCreateGroup = false; + } + } } if (!inAutoscroll && this.autoScrollInterval !== null) { @@ -1019,14 +1231,13 @@ // Check if the app already exists, and if so, update it. // This happens when reinstalling an app via WebIDE. var existing = false; - for (child of this.icons.children) { - icon = child.firstElementChild; + this.iterateIcons(icon => { if (icon.app && icon.app.manifestURL === e.application.manifestURL) { icon.app = e.application; icon.refresh(); existing = true; } - } + }); if (existing) { return; } @@ -1043,8 +1254,7 @@ this.snapScrollPosition(); }; - for (child of this.icons.children) { - icon = child.firstElementChild; + this.iterateIcons((icon, container, parent) => { if (icon.app && icon.app.manifestURL === e.application.manifestURL) { id = this.getIconId(e.application, icon.entryPoint); this.metadata.remove(id).then(() => {}, @@ -1052,7 +1262,7 @@ console.error('Error removing uninstalled app', e); }); - this.icons.removeChild(child, callback); + parent.removeChild(container, callback); // We only want to store the app order once, so clear the callback callback = null; @@ -1061,28 +1271,27 @@ this.updateSelectedIcon(null); } } - } + }); break; case 'localized': - for (icon of this.icons.children) { - icon.firstElementChild.updateName(); - } + this.iterateIcons(icon => { + icon.updateName(); + }); this.icons.synchronise(); break; case 'online': - for (var i = 0, iLen = this.iconsToRetry.length; i < iLen; i++) { - for (child of this.icons.children) { - icon = child.firstElementChild; - id = this.getIconId(icon.app ? icon.app : icon.bookmark, - icon.entryPoint); + this.iterateIcons(icon => { + id = this.getIconId(icon.app ? icon.app : icon.bookmark, + icon.entryPoint); + for (var i = 0, iLen = this.iconsToRetry.length; i < iLen; i++) { if (id === this.iconsToRetry[i]) { this.refreshIcon(icon); break; } } - } + }); break; case 'resize': @@ -1100,10 +1309,7 @@ // If the icon size has changed, refresh icons if (oldIconSize !== this.iconSize) { - for (child of this.icons.children) { - icon = child.firstElementChild; - this.refreshIcon(icon); - } + this.iterateIcons(this.refreshIcon.bind(this)); } // Re-synchronise icon position diff --git a/apps/homescreen/js/appsmetadata.js b/apps/homescreen/js/appsmetadata.js index 279b7e52a404..899da45995de 100644 --- a/apps/homescreen/js/appsmetadata.js +++ b/apps/homescreen/js/appsmetadata.js @@ -4,7 +4,8 @@ const DB_NAME = 'home-metadata'; const DB_ORDER_STORE = 'order'; const DB_ICON_STORE = 'icon'; - const DB_VERSION = 1; + const DB_GROUP_STORE = 'group'; + const DB_VERSION = 2; function AppsMetadata() {} @@ -27,20 +28,27 @@ }, upgradeSchema: function(e) { + var store; var db = e.target.result; var fromVersion = e.oldVersion; + if (fromVersion < 1) { - var store = db.createObjectStore(DB_ORDER_STORE, { keyPath: 'id' }); + store = db.createObjectStore(DB_ORDER_STORE, { keyPath: 'id' }); store.createIndex('order', 'order', { unique: false }); store = db.createObjectStore(DB_ICON_STORE, { keyPath: 'id' }); store.createIndex('icon', 'icon', { unique: false }); } + + if (fromVersion < 2) { + store = db.createObjectStore(DB_GROUP_STORE, { keyPath: 'id' }); + store.createIndex('group', 'group', { unique: false }); + } }, set: function(data) { return new Promise((resolve, reject) => { - var txn = this.db.transaction([DB_ORDER_STORE, DB_ICON_STORE], - 'readwrite'); + var txn = this.db.transaction( + [DB_ORDER_STORE, DB_ICON_STORE, DB_GROUP_STORE], 'readwrite'); for (var entry of data) { if (!entry.id) { continue; @@ -54,6 +62,10 @@ txn.objectStore(DB_ICON_STORE). put({ id: entry.id, icon: entry.icon }); } + if (typeof entry.group !== 'undefined') { + txn.objectStore(DB_GROUP_STORE). + put({ id: entry.id, group: entry.group }); + } } txn.oncomplete = resolve; txn.onerror = reject; @@ -62,10 +74,11 @@ remove: function(id) { return new Promise((resolve, reject) => { - var txn = this.db.transaction([DB_ORDER_STORE, DB_ICON_STORE], - 'readwrite'); + var txn = this.db.transaction( + [DB_ORDER_STORE, DB_ICON_STORE, DB_GROUP_STORE], 'readwrite'); txn.objectStore(DB_ORDER_STORE).delete(id); txn.objectStore(DB_ICON_STORE).delete(id); + txn.objectStore(DB_GROUP_STORE).delete(id); txn.oncomplete = resolve; txn.onerror = reject; }); @@ -73,10 +86,11 @@ getAll: function(onResult) { return new Promise((resolve, reject) => { - var txn = this.db.transaction([DB_ORDER_STORE, DB_ICON_STORE], - 'readonly'); + var txn = this.db.transaction( + [DB_ORDER_STORE, DB_ICON_STORE, DB_GROUP_STORE], 'readonly'); var orderStore = txn.objectStore(DB_ORDER_STORE); var iconStore = txn.objectStore(DB_ICON_STORE); + var groupStore = txn.objectStore(DB_GROUP_STORE); var cursor = orderStore.index('order').openCursor(); var results = []; @@ -84,16 +98,30 @@ var cursor = e.target.result; if (cursor) { var result = cursor.value; + var groupRequest = groupStore.get(result.id); var iconRequest = iconStore.get(result.id); - iconRequest.onsuccess = function(result, e) { - if (e.target.result) { - result.icon = e.target.result.icon; - } - results.push(result); - if (onResult) { - onResult(result); - } - }.bind(this, result); + Promise.all([ + new Promise(function(result, resolve, reject) { + groupRequest.onsuccess = function(e) { + if (e.target.result) { + result.group = e.target.result.group; + } + resolve(); + }; + }.bind(this, result)), + new Promise(function(result, resolve, reject) { + iconRequest.onsuccess = function(e) { + if (e.target.result) { + result.icon = e.target.result.icon; + } + resolve(); + }; + }.bind(this, result))]).then(function(result) { + results.push(result); + if (onResult) { + onResult(result); + } + }.bind(this, result)); cursor.continue(); } }; diff --git a/apps/homescreen/js/group.js b/apps/homescreen/js/group.js new file mode 100644 index 000000000000..a76097adb853 --- /dev/null +++ b/apps/homescreen/js/group.js @@ -0,0 +1,283 @@ +'use strict'; + +(function(exports) { + // The ratio of the largest dimension the group should take. + const SIZE_RATIO = 0.7; + + // Maximum number of icons that can be contained in a group. + const MAX_CHILDREN = 6; + + // Possible states of a group. + const COLLAPSED = 0; + const EXPANDING = 1; + const EXPANDED = 2; + const COLLAPSING = 3; + + var proto = Object.create(HTMLElement.prototype); + + proto.createdCallback = function() { + this.container = document.createElement('gaia-container'); + this.container.id = 'group-container'; + this._template = template.content.cloneNode(true); + this._template.appendChild(this.container); + + var shadow = this.createShadowRoot(); + shadow.appendChild(this._template); + + this.background = shadow.getElementById('group-background'); + this.removedChildren = []; + this.state = 0; + }; + + proto.transferFromContainer = function(child, container, callback) { + container.removeChild(child, () => { + this.container.appendChild(child, callback); + var icon = child.firstElementChild; + icon.showName = false; + }); + }; + + proto.transferToContainer = function(child, container, callback) { + var icon = child.firstElementChild; + this.container.removeChild(child, () => { + icon.showName = true; + + this.removedChildren.push(child); + this.finishRemovingChildren(container, callback); + }); + }; + + proto.finishRemovingChildren = function(container, callback) { + var reparentRemovedChildren = (beforeChild, callback) => { + for (var i = 0, iLen = this.removedChildren.length; i < iLen; i++) { + var child = this.removedChildren[i]; + container.insertBefore(child, beforeChild, + (i === iLen - 1) ? callback : null); + } + this.removedChildren = []; + }; + + if (this.container.children.length === 1) { + this.transferToContainer(this.container.firstChild, container, callback); + } else if (this.state !== COLLAPSING && + this.container.children.length === 0) { + // The children will be added back to the parent container after the + // group is removed, so find the group's sibling to insert the children + // before. + var children = container.children; + var sibling = null; + for (var i = 0, iLen = children.length; i < iLen - 1; i++) { + if (children[i] === this.parentNode) { + sibling = children[i + 1]; + break; + } + } + + container.removeChild(this.parentNode, + reparentRemovedChildren.bind(this, sibling, callback)); + } else if (this.state === COLLAPSED) { + reparentRemovedChildren(this.parentNode, callback); + } + }; + + proto.expand = function(parent) { + // Make sure we transition from whatever state we're in correctly. + switch (this.state) { + case COLLAPSED: + break; + + default: + return; + } + + /* The expanding animation works like so: + * 1 Hide overflow on scroll container by setting a style class on the + * document body. + * 2 We record the current screen rect of the group. + * 3 Set offsets on the group to have it occupy the whole screen. + * 4 Set an offset and size on the background and container so they still + * remain in the same position. + * 5 Set transform on background so it expands into the full group area. + * 6 Set style class on the icons container fades out. + * 7 After the icon container fades out, change its style properties to + * fill the expanded group. + * 8 Synchronise icon container. + * 9 Set style property on icon container to fade it in. + */ + this.state = EXPANDING; + + // Part 1. + document.body.classList.add('expanding'); + + // Part 2. + var rect = this.getBoundingClientRect(); + var originalWidth = this.clientWidth; + var originalHeight = this.clientHeight; + var originalLeft = this.clientLeft; + var originalTop = this.clientTop; + + var parentOffsetTop = parent.offsetTop; + var targetLeft = Math.round(-rect.left); + var targetTop = Math.round(parentOffsetTop - rect.top); + var targetWidth = window.innerWidth; + var targetHeight = window.innerHeight - parentOffsetTop; + + // Use absolute positioning instead of a transform so the fixed-positioning + // used when dragging works correctly. + parent.setUseTransform(this.parentNode, false); + + // We need opened groups to appear above the app grid. We can only + // do this by setting a z-index on the gaia-container-child due to + // the established stacking order. + this.parentNode.parentNode.style.zIndex = '1'; + + // Shrink the target rect depending on SIZE_RATIO + if (targetHeight >= targetWidth) { + targetTop += (targetHeight * (1 - SIZE_RATIO)) / 2; + targetHeight *= SIZE_RATIO; + } else { + targetLeft += (targetWidth * (1 - SIZE_RATIO)) / 2; + targetWidth *= SIZE_RATIO; + } + + // Part 3. + this.style.left = targetLeft + 'px'; + this.style.top = targetTop + 'px'; + this.style.width = targetWidth + 'px'; + this.style.height = targetHeight + 'px'; + + // Part 4. + this.container.style.left = + this.background.style.left = (originalLeft - targetLeft) + 'px'; + this.container.style.top = + this.background.style.top = (originalTop - targetTop) + 'px'; + this.container.style.width = + this.background.style.width = originalWidth + 'px'; + this.container.style.height = + this.background.style.height = originalHeight + 'px'; + + // Part 5. + var bgOffsetLeft = Math.round((targetWidth / 2) - (originalWidth / 2)); + var bgOffsetTop = Math.round((targetHeight / 2) - (originalHeight / 2)); + var bgLeft = Math.round(bgOffsetLeft + targetLeft) - originalLeft; + var bgTop = Math.round(bgOffsetTop + targetTop) - originalTop; + var bgTargetSize = + Math.sqrt(2 * Math.pow(Math.max(targetWidth, targetHeight), 2)); + var bgScale = bgTargetSize / Math.max(originalWidth, originalHeight); + this.background.style.transform = + 'translate(' + bgLeft + 'px, ' + bgTop + 'px) scale(' + bgScale + ')'; + + var afterExpanding = () => { + // Part 7. + this.container.removeEventListener('transitionend', afterExpanding); + + document.body.classList.add('expanded'); + this.classList.add('expanded'); + this.container.classList.add('expanded'); + + this.container.style.left = this.container.style.top = + this.container.style.width = this.container.style.height = ''; + + // Part 8. + this.container.children.forEach(child => { + child.firstElementChild.showName = true; + }); + this.container.setAttribute('drag-and-drop', ''); + this.container.synchronise(); + + // Part 9. + document.body.classList.remove('expanding'); + this.classList.remove('expanding'); + this.container.classList.remove('expanding'); + + this.state = EXPANDED; + }; + this.container.addEventListener('transitionend', afterExpanding); + + // Part 6. + this.classList.add('expanding'); + this.container.classList.add('expanding'); + }; + + proto.collapse = function(parent, onPreComplete, onComplete) { + switch (this.state) { + case EXPANDED: + break; + + default: + return; + } + + /* The collapsing animations works like so: + * 1 Hide icon names and set style property on icon container to make it + * fade out. + * 2 Set style property on group background to have it return to its + * original position. + * 3 Remove offsets on group, container and background. + * 4 Tidy up and call pre-complete callback. + * 5 Remove collapsing classes on document body and self + * 6 Synchronise container and set style property to have it fade in. + * 7 Call complete callback. + */ + this.state = COLLAPSING; + this.classList.add('collapsing'); + document.body.classList.add('collapsing'); + this.classList.remove('expanded'); + document.body.classList.remove('expanded'); + + // Part 1. + this.container.removeAttribute('drag-and-drop'); + this.container.children.forEach(child => { + child.firstElementChild.showName = false; + }); + this.container.classList.add('collapsing'); + this.container.classList.remove('expanded'); + + // Part 2. + this.background.style.transform = ''; + + var afterCollapsing = () => { + this.background.removeEventListener('transitionend', afterCollapsing); + + // Part 3. + this.background.style.left = this.background.style.top = + this.background.style.width = this.background.style.height = + this.background.style.transform = this.style.left = this.style.top = + this.style.width = this.style.height = ''; + + // Part 4. + this.parentNode.parentNode.style.zIndex = ''; + parent.setUseTransform(this.parentNode, true); + if (onPreComplete) { + onPreComplete(); + } + + // Part 5. + this.classList.remove('collapsing'); + document.body.classList.remove('collapsing'); + + // Part 6. + this.container.classList.remove('collapsing'); + this.container.synchronise(); + + this.state = COLLAPSED; + this.finishRemovingChildren(parent, onComplete); + }; + this.background.addEventListener('transitionend', afterCollapsing); + }; + + Object.defineProperty(proto, 'full', { + get: function() { + return this.container.children.length >= MAX_CHILDREN; + }, + enumerable: true + }); + + var template = document.createElement('template'); + template.innerHTML = + ` +
`; + + exports.Group = document.registerElement('homescreen-group', + { prototype: proto }); +}(window)); diff --git a/apps/homescreen/style/apps-panel.css b/apps/homescreen/style/apps-panel.css index e8cfee8996d0..7bd0d009aeac 100644 --- a/apps/homescreen/style/apps-panel.css +++ b/apps/homescreen/style/apps-panel.css @@ -9,19 +9,36 @@ scroll-snap-type-y: mandatory; } +.expanding #apps-panel > .scrollable, +.expanded #apps-panel > .scrollable, +.collapsing #apps-panel > .scrollable { + overflow: hidden; +} + +.expanding #apps > .gaia-container-child, +.collapsing #apps > .gaia-container-child { + transition: unset; +} + /* This weird arrangement of constant sizes, transforms and containers is so * the gaia-container can animate fully between small and not-small states. * Without this animation, the container would be unnecessary and only the * width would need to be specified. */ -.icon-container { +#apps > .gaia-container-child > * { position: relative; display: inline-block; width: 32vw; height: calc(32vw + 1.9rem); } -gaia-app-icon { +#apps.small > .gaia-container-child > * { + width: 24vw; + height: calc(24vw + 1.9rem); +} + +gaia-app-icon, +homescreen-group { position: absolute; top: 0; left: 0; @@ -37,10 +54,21 @@ gaia-app-icon { text-shadow: rgba(0, 0, 0, 0.5) 0 0.1rem 0.3rem; } +homescreen-group { + height: 32vw; +} + +homescreen-group.expanding, +homescreen-group.expanded, +homescreen-group.collapsing { + border: 0; +} + /* Small icons are scaled down 0.75x, so apply the reverse transforms to the * font so that it conforms to spec. */ -#apps.small gaia-app-icon { +#apps.small gaia-app-icon, +#apps.small homescreen-group { font-size: calc(1.2rem / 0.75); text-shadow: rgba(0, 0, 0, 0.5) 0 calc(0.1rem / 0.75) calc(0.3rem / 0.75); } @@ -49,11 +77,13 @@ gaia-app-icon.launching { opacity: 0.8; } -.edit-mode gaia-app-icon { +.edit-mode gaia-app-icon, +.edit-mode homescreen-group { opacity: 0.5; } -.edit-mode gaia-app-icon.selected { +.edit-mode gaia-app-icon.selected, +.edit-mode homescreen-group.selected { transform: scale(1.1); opacity: 1; } @@ -62,43 +92,52 @@ gaia-app-icon.launching { visibility: hidden; } -#apps.small > .gaia-container-child > .icon-container { - width: 24vw; - height: calc(24vw + 1.9rem); -} - -#apps > .gaia-container-child.dragging > .icon-container > gaia-app-icon { +#apps > .gaia-container-child.dragging > .icon-container > *, +#apps > .gaia-container-child.dragging > .group-container > * { transform: scale(1.1); opacity: 0.8; } -#apps.small > .gaia-container-child > .icon-container > gaia-app-icon { +#apps > .gaia-container-child.dragging > .icon-container.hover-over { + transform: scale(0.5); +} + +#apps.small > .gaia-container-child > .icon-container > *, +#apps.small > .gaia-container-child > .group-container > * { transform: scale(0.75) translate(-4vw, -4vw); } -#apps.small > .gaia-container-child.dragging > .icon-container > gaia-app-icon { +#apps.small > .gaia-container-child.dragging > .icon-container > *, +#apps.small > .gaia-container-child.dragging > .group-container > * { transform: scale(0.8) translate(-3.2vw, -3.2vw); } -.dragging:not(.autoscroll) #apps > .gaia-container-child > .icon-container { +body:not(.autoscroll) #apps.dragging > .gaia-container-child > * { will-change: transform; } -#apps > .gaia-container-child > .icon-container.hover-before { +#apps > .gaia-container-child > .hover-before:not(.hover-over) { transform: translateX(3.5rem); } -#apps > .gaia-container-child > .icon-container.hover-after { +#apps > .gaia-container-child > .hover-after:not(.hover-over) { transform: translateX(-3.5rem); } -#apps > .gaia-container-child.added gaia-app-icon { +#apps > .gaia-container-child > .icon-container.hover-over { + transition: transform 0.2s 0.2s; + transform: scale(0.5) translate(-50%, 0); +} + +#apps > .gaia-container-child.added gaia-app-icon, +#apps > .gaia-container-child.added homescreen-group { animation-name: icon-added; animation-duration: 0.4s; z-index: 1; } -#apps.small > .gaia-container-child.added gaia-app-icon { +#apps.small > .gaia-container-child.added gaia-app-icon, +#apps.small > .gaia-container-child.added homescreen-group { animation-name: small-icon-added; } diff --git a/apps/homescreen/style/grid.css b/apps/homescreen/style/grid.css index 7a0d3474ef4c..f38a1745a6f6 100644 --- a/apps/homescreen/style/grid.css +++ b/apps/homescreen/style/grid.css @@ -19,7 +19,8 @@ gaia-container { background-repeat: repeat-y; } -gaia-container:not(.loading) > .gaia-container-child:not(.added):not(.dragging) { +body:not(.expanding):not(.collapsing) gaia-container:not(.loading) > +.gaia-container-child:not(.added):not(.dragging) { transition: transform 0.2s; } diff --git a/apps/homescreen/style/group.css b/apps/homescreen/style/group.css new file mode 100644 index 000000000000..c77baf0b2aa7 --- /dev/null +++ b/apps/homescreen/style/group.css @@ -0,0 +1,102 @@ +/* This is a web component, so animation keyframes are defined + * in apps-panel.css */ + +homescreen-group { + position: relative; +} + +homescreen-group.expanding, +homescreen-group.expanded, +homescreen-group.collapsing { + overflow: hidden; +} + +#group-background { + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.8); + border-radius: 50%; + transition: transform 0.4s; +} + +#group-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 50%; + + display: flex; + flex-flow: row wrap; + justify-content: center; + align-items: center; + align-content: center; + + transition: opacity 0.2s; +} + +#group-container.expanding, +#group-container.collapsing { + opacity: 0; +} + +#group-container.expanded { + border-radius: 0; + overflow: auto; + justify-content: flex-start; + align-items: flex-start; + align-content: flex-start; + width: calc(100% - 0.6rem * 2); + height: calc(100% - 0.6rem * 2); + padding: 0.6rem; +} + +#group-container > .gaia-container-child > * { + width: calc(32vw * 0.25); + height: calc(32vw * 0.25); +} + +#group-container.expanded > .gaia-container-child > *, +#group-container.collapsing > .gaia-container-child > * { + width: 32vw; + height: calc(32vw + 1.9rem); + box-sizing: border-box; + border: 0.8rem solid transparent; +} + +#group-container.expanded > .gaia-container-child gaia-app-icon, +#group-container.collapsing > .gaia-container-child gaia-app-icon { + outline: 0; + font-size: 1.4rem; + font-weight: 400; + color: white; + text-shadow: rgba(0, 0, 0, 0.5) 0 0.1rem 0.3rem; +} + +#group-container > .gaia-container-child:not(.added):not(.dragging), +#group-container > .gaia-container-child:not(.added):not(.removed) > * { + transition: transform 0.2s; +} + +#group-container > .gaia-container-child.added gaia-app-icon { + animation-name: icon-added; + animation-duration: 0.4s; + z-index: 1; +} + +#group-container > .gaia-container-child.dragging > .icon-container > * { + transform: scale(1.1); + opacity: 0.8; +} + +#group-container > .gaia-container-child > .hover-before:not(.hover-over) { + transform: translateX(3.5rem); +} + +#group-container > .gaia-container-child > .hover-after:not(.hover-over) { + transform: translateX(-3.5rem); +} diff --git a/apps/homescreen/style/window.css b/apps/homescreen/style/window.css index 8f767186dd5c..a6e8c3c1c556 100644 --- a/apps/homescreen/style/window.css +++ b/apps/homescreen/style/window.css @@ -83,7 +83,10 @@ body { } .dragging #panels, -.edit-mode #panels { +.edit-mode #panels, +.expanding #panels, +.expanded #panels, +.collapsing #panels { overflow: hidden; }