Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1128 lines
36 KiB
JavaScript
Executable file
1128 lines
36 KiB
JavaScript
Executable file
/* global MozActivity, AppsMetadata, Datastore, LazyLoader, FirstRun,
|
|
IconsHelper, Settings */
|
|
'use strict';
|
|
|
|
(function(exports) {
|
|
/**
|
|
* Timeout before resizing the apps grid after apps change.
|
|
*/
|
|
const RESIZE_TIMEOUT = 500;
|
|
|
|
/**
|
|
* Timeout before showing a dialog. Without this, the click that comes through
|
|
* after an activate event from gaia-container will close the dialog.
|
|
*/
|
|
const DIALOG_SHOW_TIMEOUT = 50;
|
|
|
|
/**
|
|
* The distance at the top and bottom of the icon container that when hovering
|
|
* an icon in will cause scrolling.
|
|
*/
|
|
const AUTOSCROLL_DISTANCE = 40;
|
|
|
|
/**
|
|
* The timeout before auto-scrolling a page when hovering at the edges
|
|
* of the grid.
|
|
*/
|
|
const AUTOSCROLL_DELAY = 750;
|
|
|
|
/**
|
|
* The time to wait after setting a scroll-position before disabling
|
|
* overflow during drag-and-drop.
|
|
*/
|
|
const AUTOSCROLL_OVERFLOW_DELAY = 500;
|
|
|
|
/**
|
|
* App roles that will be skipped on the homescreen.
|
|
*/
|
|
const HIDDEN_ROLES = [
|
|
'system', 'input', 'homescreen', 'theme', 'addon', 'langpack'
|
|
];
|
|
|
|
/**
|
|
* Strings that are matched against to black-list app origins.
|
|
* TODO: This should not be hard-coded.
|
|
*/
|
|
const BLACKLIST = [];
|
|
|
|
function Apps() {
|
|
// Chrome is displayed
|
|
window.performance.mark('navigationLoaded');
|
|
|
|
// Element references
|
|
this.panel = document.getElementById('apps-panel');
|
|
this.meta = document.head.querySelector('meta[name="theme-color"]');
|
|
this.scrollable = document.querySelector('#apps-panel > .scrollable');
|
|
this.icons = document.getElementById('apps');
|
|
this.remove = document.getElementById('remove');
|
|
this.rename = document.getElementById('rename');
|
|
this.done = document.getElementById('done');
|
|
this.cancelDownload = document.getElementById('cancel-download');
|
|
this.resumeDownload = document.getElementById('resume-download');
|
|
this.dialogs = [this.cancelDownload, this.resumeDownload];
|
|
|
|
// XXX Working around gaia-components issue #8
|
|
var dialog;
|
|
for (dialog of this.dialogs) {
|
|
dialog.hide();
|
|
}
|
|
|
|
// Change the colour of the statusbar when showing dialogs
|
|
var dialogVisibilityCallback = () => {
|
|
for (var dialog of this.dialogs) {
|
|
if (dialog.opened) {
|
|
this.meta.content = 'white';
|
|
document.body.classList.add('dialog-active');
|
|
return;
|
|
}
|
|
}
|
|
this.meta.content = 'transparent';
|
|
document.body.classList.remove('dialog-active');
|
|
};
|
|
for (dialog of this.dialogs) {
|
|
var observer = new MutationObserver(dialogVisibilityCallback);
|
|
observer.observe(dialog,
|
|
{ attributes: true, attributeFilter: ['style'] });
|
|
}
|
|
|
|
// Paging
|
|
this.resizeTimeout = null;
|
|
this.pageHeight = 1;
|
|
this.gridHeight = 1;
|
|
this.pendingGridHeight = 1;
|
|
this.iconsPerPage = 0;
|
|
this.iconsLeft = 0;
|
|
this.iconsRight = 0;
|
|
|
|
// Scroll behaviour
|
|
this.appsVisible = false;
|
|
|
|
// Drag-and-drop
|
|
this.dragging = false;
|
|
this.draggedIndex = -1;
|
|
this.autoScrollInterval = null;
|
|
this.autoScrollOverflowTimeout = null;
|
|
this.hoverIcon = null;
|
|
|
|
// Edit mode
|
|
this.editMode = false;
|
|
this.shouldEnterEditMode = false;
|
|
this.selectedIcon = null;
|
|
this.rename.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
this.renameSelectedIcon();
|
|
});
|
|
this.remove.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
this.removeSelectedIcon();
|
|
});
|
|
this.done.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
this.exitEditMode();
|
|
});
|
|
|
|
// Icon and grid sizing behaviour
|
|
this._iconSize = 0;
|
|
this.lastWindowWidth = window.innerWidth;
|
|
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);
|
|
navigator.mozApps.mgmt.addEventListener('install', this);
|
|
navigator.mozApps.mgmt.addEventListener('uninstall', this);
|
|
window.addEventListener('localized', this);
|
|
window.addEventListener('online', this);
|
|
window.addEventListener('resize', this);
|
|
window.addEventListener('settings-changed', this);
|
|
|
|
// Settings
|
|
this.settings = new Settings();
|
|
this.icons.classList.toggle('small', this.settings.small);
|
|
this.scrollable.classList.toggle('snapping', this.settings.scrollSnapping);
|
|
|
|
// Populate apps and bookmarks asynchronously
|
|
this.metadataLoaded = 0;
|
|
this.startupMetadata = [];
|
|
this.iconsToRetry = [];
|
|
this.pendingIcons = {};
|
|
this.metadata = new AppsMetadata();
|
|
this.bookmarks = new Datastore('bookmarks_store');
|
|
|
|
// Make sure icons isn't doing lots of unnecessary work while we're
|
|
// loading the first screen of apps.
|
|
this.visualLoadComplete = false;
|
|
this.icons.freeze();
|
|
this.icons.classList.add('loading');
|
|
|
|
Promise.all([
|
|
// Load app metadata. If metadata loading fails, continue populating apps
|
|
// anyway - it means they'll be in the default order and their order
|
|
// won't save, but it's better than showing a blank screen.
|
|
// If this is the first run, get the app order from the first-run script
|
|
// after initialising the metadata database.
|
|
this.metadata.init().then(this.settings.firstRun ? () => {
|
|
return LazyLoader.load('js/firstrun.js').then(
|
|
() => {
|
|
return FirstRun().then((results) => {
|
|
this.toggleSmall(results.small);
|
|
this.startupMetadata = results.order;
|
|
this.settings.small = results.small;
|
|
this.settings.save();
|
|
return Promise.resolve();
|
|
}, (e) => {
|
|
console.error('Error running first-run script', e);
|
|
return Promise.resolve();
|
|
});
|
|
},
|
|
(e) => {
|
|
console.error('Failed to load first-run script');
|
|
return Promise.resolve();
|
|
});
|
|
} :
|
|
() => {
|
|
return this.metadata.getAll(result => {
|
|
this.startupMetadata.push(result);
|
|
this.metadataLoaded++;
|
|
|
|
// Process results in batches as they come in
|
|
var processResult = data => {
|
|
if (this.pendingIcons[data.id]) {
|
|
this.addAppIcon.apply(this, this.pendingIcons[data.id]);
|
|
delete this.pendingIcons[data.id];
|
|
}
|
|
};
|
|
|
|
if (!this.visualLoadComplete) {
|
|
processResult(result);
|
|
this.refreshGridSize();
|
|
} else if ((this.metadataLoaded % this.iconsPerPage) === 0) {
|
|
this.icons.freeze();
|
|
for (var entry of this.startupMetadata) {
|
|
processResult(entry);
|
|
}
|
|
this.refreshGridSize();
|
|
this.icons.thaw();
|
|
}
|
|
}).then(Promise.resolve(),
|
|
e => {
|
|
console.error('Failed to retrieve metadata entries', e);
|
|
return Promise.resolve();
|
|
});
|
|
},
|
|
(e) => {
|
|
console.error('Failed to initialise metadata db', e);
|
|
return Promise.resolve();
|
|
}),
|
|
|
|
// Populate apps
|
|
new Promise((resolve, reject) => {
|
|
var request = navigator.mozApps.mgmt.getAll();
|
|
request.onsuccess = (e) => {
|
|
for (var app of request.result) {
|
|
this.addApp(app);
|
|
}
|
|
resolve();
|
|
};
|
|
request.onerror = (e) => {
|
|
console.error('Error calling getAll: ' + request.error.name);
|
|
resolve();
|
|
};
|
|
}),
|
|
|
|
// Initialise and populate bookmarks
|
|
this.bookmarks.init().then(() => {
|
|
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;
|
|
if (icon.bookmark && icon.bookmark.id === id) {
|
|
icon.bookmark = bookmark.data;
|
|
icon.refresh();
|
|
return;
|
|
}
|
|
}
|
|
this.addAppIcon(bookmark.data);
|
|
this.storeAppOrder();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('bookmarks_store-removed', (e) => {
|
|
var id = e.detail.id;
|
|
for (var child of this.icons.children) {
|
|
var icon = child.firstElementChild;
|
|
if (icon.bookmark && icon.bookmark.id === id) {
|
|
this.icons.removeChild(child, () => {
|
|
this.storeAppOrder();
|
|
this.refreshGridSize();
|
|
this.snapScrollPosition();
|
|
});
|
|
this.metadata.remove(id);
|
|
|
|
if (this.selectedIcon === icon) {
|
|
this.updateSelectedIcon(null);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('bookmarks_store-cleared', () => {
|
|
for (var child of this.icons.children) {
|
|
var icon = child.firstElementChild;
|
|
if (icon.bookmark) {
|
|
this.icons.removeChild(child);
|
|
}
|
|
}
|
|
this.storeAppOrder();
|
|
this.refreshGridSize();
|
|
this.snapScrollPosition();
|
|
});
|
|
}, (e) => {
|
|
console.error('Error initialising bookmarks', e);
|
|
return Promise.resolve();
|
|
}).then(() => {
|
|
return this.bookmarks.getAll().then((bookmarks) => {
|
|
for (var bookmark of bookmarks) {
|
|
this.addAppIcon(bookmark.data);
|
|
}
|
|
}, (e) => {
|
|
console.error('Error getting bookmarks', e);
|
|
return Promise.resolve();
|
|
});
|
|
})
|
|
]).then(() => {
|
|
// Add any applications that were missed and are in the startup metadata
|
|
var id;
|
|
var pendingIcons = this.pendingIcons;
|
|
this.pendingIcons = {};
|
|
for (id in pendingIcons) {
|
|
this.addAppIcon.apply(this, pendingIcons[id]);
|
|
}
|
|
|
|
// Make sure icons has been thawed
|
|
if (!this.visualLoadComplete) {
|
|
this.onVisualLoad();
|
|
}
|
|
|
|
// Remove unknown entries from the startup metadata
|
|
if (!this.settings.firstRun) {
|
|
for (var data of this.startupMetadata) {
|
|
console.log('Removing unknown app metadata entry', data.id);
|
|
this.metadata.remove(data.id).then(
|
|
() => {},
|
|
(e) => {
|
|
console.error('Error removing unknown app metadata entry', e);
|
|
});
|
|
}
|
|
}
|
|
this.startupMetadata = null;
|
|
|
|
// 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.refreshIcon(icon);
|
|
}
|
|
|
|
// Add any applications that aren't in the startup metadata
|
|
var newIcons = false;
|
|
pendingIcons = this.pendingIcons;
|
|
for (id in pendingIcons) {
|
|
this.addAppIcon.apply(this, pendingIcons[id]);
|
|
newIcons = true;
|
|
}
|
|
this.pendingIcons = null;
|
|
|
|
// Store app order of new icons
|
|
if (newIcons) {
|
|
this.storeAppOrder();
|
|
} else {
|
|
// Grid size is only refreshed in the non-startup path, so unless we
|
|
// added new icons post-startup, refresh here to make sure it's the
|
|
// correct size.
|
|
this.refreshGridSize();
|
|
}
|
|
|
|
// All asynchronous loading has finished
|
|
window.performance.mark('fullyLoaded');
|
|
});
|
|
}
|
|
|
|
Apps.prototype = {
|
|
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') {
|
|
this._iconSize = container.firstElementChild.size;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return this._iconSize;
|
|
},
|
|
|
|
toggleSmall: function(small) {
|
|
if (this.icons.classList.contains('small') === small) {
|
|
return;
|
|
}
|
|
|
|
this.icons.classList.toggle('small', small);
|
|
this.icons.synchronise();
|
|
this.refreshGridSize();
|
|
this.snapScrollPosition();
|
|
},
|
|
|
|
toggleScrollSnapping: function(scrollSnapping) {
|
|
if (this.scrollable.classList.contains('snapping') === scrollSnapping) {
|
|
return;
|
|
}
|
|
|
|
this.scrollable.classList.toggle('snapping', scrollSnapping);
|
|
this.snapScrollPosition();
|
|
},
|
|
|
|
onVisualLoad: function() {
|
|
this.visualLoadComplete = true;
|
|
this.icons.thaw();
|
|
this.icons.classList.remove('loading');
|
|
|
|
// App/bookmark loading happens asynchronously and we call this function
|
|
// when we've loaded as many apps as necessary to fill the screen.
|
|
window.performance.mark('visuallyLoaded');
|
|
window.performance.mark('contentInteractive');
|
|
},
|
|
|
|
addApp: function(app) {
|
|
var manifest = app.manifest || app.updateManifest;
|
|
if (!manifest) {
|
|
//console.log('Skipping app with no manifest', app);
|
|
return;
|
|
}
|
|
|
|
// Do not add blacklisted apps
|
|
if (BLACKLIST.includes(app.origin)) {
|
|
return;
|
|
}
|
|
|
|
if (manifest.entry_points) {
|
|
for (var entryPoint in manifest.entry_points) {
|
|
this.addAppIcon(app, entryPoint);
|
|
}
|
|
} else {
|
|
this.addAppIcon(app);
|
|
}
|
|
},
|
|
|
|
addIconContainer: function(icon, entry) {
|
|
var container = document.createElement('div');
|
|
container.classList.add('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;
|
|
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);
|
|
if (this.startupMetadata === null) {
|
|
this.iconAdded(container);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!container.parentNode) {
|
|
this.icons.appendChild(container);
|
|
if (this.startupMetadata === null) {
|
|
this.iconAdded(container);
|
|
}
|
|
}
|
|
|
|
return container;
|
|
},
|
|
|
|
getIconId: function(appOrBookmark, entryPoint) {
|
|
if (appOrBookmark.id) {
|
|
return appOrBookmark.id;
|
|
} else {
|
|
return appOrBookmark.manifestURL + '/' + (entryPoint ? entryPoint : '');
|
|
}
|
|
},
|
|
|
|
addAppIcon: function(appOrBookmark, entryPoint) {
|
|
var id = this.getIconId(appOrBookmark, entryPoint);
|
|
var entry = -1;
|
|
|
|
if (this.startupMetadata !== null) {
|
|
entry = this.startupMetadata.findIndex(data => {
|
|
return data.id === id;
|
|
});
|
|
if (entry === -1) {
|
|
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);
|
|
|
|
if (appOrBookmark.id) {
|
|
icon.bookmark = appOrBookmark;
|
|
this.refreshIcon(icon);
|
|
} else {
|
|
icon.app = appOrBookmark;
|
|
|
|
// Hide/show the icon if the role changes to/from a hidden role
|
|
var handleRoleChange = function(app, container) {
|
|
var manifest = app.manifest || app.updateManifest;
|
|
var hidden = (manifest && manifest.role &&
|
|
HIDDEN_ROLES.includes(manifest.role));
|
|
container.style.display = hidden ? 'none' : '';
|
|
};
|
|
|
|
icon.app.addEventListener('downloadapplied',
|
|
function(app, container) {
|
|
handleRoleChange(app, container);
|
|
this.icons.synchronise();
|
|
}.bind(this, icon.app, container));
|
|
|
|
handleRoleChange(icon.app, container);
|
|
}
|
|
|
|
// Save the new icon if it gets refreshed.
|
|
this.iconsToRetry.push(id);
|
|
icon.addEventListener('icon-loaded', function(icon, id) {
|
|
if (icon.isUserSet) {
|
|
// If the icon was set manually, we don't cache it.
|
|
return;
|
|
}
|
|
|
|
icon.icon.then((blob) => {
|
|
// Remove icon from list of icons to retry when we go online
|
|
var retryIndex = this.iconsToRetry.indexOf(id);
|
|
if (retryIndex !== -1) {
|
|
this.iconsToRetry.splice(retryIndex, 1);
|
|
}
|
|
|
|
this.metadata.set([{ id: id, icon: blob }]).then(
|
|
() => {},
|
|
(e) => {
|
|
console.error('Error saving icon', e);
|
|
});
|
|
});
|
|
}.bind(this, icon, id));
|
|
|
|
// Refresh icon image and title
|
|
icon.size = this.iconSize ? this.iconSize : icon.size;
|
|
if (entry !== -1) {
|
|
// Load the cached icon and update name
|
|
icon.icon = this.startupMetadata[entry].icon;
|
|
this.startupMetadata.splice(entry, 1);
|
|
icon.updateName();
|
|
if (window.VulpesCompat && icon.app) icon.refresh(); // Replace legacy rasterized cache with original app assets.
|
|
} else {
|
|
// Start loading a new icon and update name
|
|
icon.refresh();
|
|
}
|
|
|
|
// Override default launch behaviour
|
|
icon.addEventListener('activated', e => {
|
|
e.preventDefault();
|
|
this.handleEvent({ type: 'activate',
|
|
detail: { target: e.target.parentNode },
|
|
preventDefault: () => {}});
|
|
});
|
|
},
|
|
|
|
refreshIcon: function(icon) {
|
|
icon.size = this.iconSize ? this.iconSize : icon.size;
|
|
if (icon.bookmark) {
|
|
IconsHelper.setElementIcon(icon, this.iconSize).then(() => {},
|
|
e => {
|
|
console.error('Error refreshing bookmark icon', e);
|
|
|
|
// Refreshing a bookmark icon will set the default icon image if
|
|
// no user icon has been set.
|
|
if (!icon.isUserSet) {
|
|
icon.refresh();
|
|
}
|
|
});
|
|
} else {
|
|
icon.refresh();
|
|
}
|
|
},
|
|
|
|
storeAppOrder: function() {
|
|
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 });
|
|
}
|
|
this.metadata.set(storedOrders).then(
|
|
() => {},
|
|
(e) => {
|
|
console.error('Error storing app order', e);
|
|
});
|
|
},
|
|
|
|
iconAdded: function(container) {
|
|
// Refresh the grid size if this child is visible
|
|
if (container.style.display === 'none') {
|
|
return;
|
|
}
|
|
|
|
this.refreshGridSize();
|
|
},
|
|
|
|
refreshGridSize: function() {
|
|
var children = this.icons.children;
|
|
var cols = this.settings.small ? 4 : 3;
|
|
|
|
var visibleChildren = 0;
|
|
var firstVisibleChild = -1;
|
|
for (var i = 0, iLen = children.length; i < iLen; i++) {
|
|
if (children[i].style.display !== 'none') {
|
|
visibleChildren ++;
|
|
if (firstVisibleChild === -1) {
|
|
firstVisibleChild = i;
|
|
this.iconsLeft = this.icons.getChildOffsetRect(children[i]).left;
|
|
} else if (visibleChildren === cols) {
|
|
this.iconsRight = this.icons.getChildOffsetRect(children[i]).right;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (visibleChildren < 1) {
|
|
// Reset these to default values when all children have been removed
|
|
this.pendingGridHeight = this.gridHeight = 0;
|
|
this.pageHeight = this.scrollable.clientHeight;
|
|
} else {
|
|
var iconHeight = Math.round(children[firstVisibleChild].offsetHeight);
|
|
var scrollHeight = this.scrollable.clientHeight;
|
|
var rowsPerPage = Math.floor(scrollHeight / iconHeight);
|
|
var pageHeight = rowsPerPage * iconHeight;
|
|
var gridHeight;
|
|
|
|
if (this.settings.scrollSnapping) {
|
|
gridHeight = (Math.ceil((iconHeight *
|
|
Math.ceil(visibleChildren / cols)) / pageHeight) *
|
|
pageHeight) + (scrollHeight - pageHeight);
|
|
} else {
|
|
gridHeight = (Math.ceil(visibleChildren / cols) + 1) * iconHeight;
|
|
}
|
|
|
|
this.pageHeight = pageHeight;
|
|
this.pendingGridHeight = gridHeight;
|
|
this.iconsPerPage = rowsPerPage * cols;
|
|
|
|
// When a full screen of apps is visible, we mark that as visual loading
|
|
// being complete.
|
|
if (!this.visualLoadComplete &&
|
|
Math.floor(visibleChildren / cols) * iconHeight >= scrollHeight) {
|
|
this.onVisualLoad();
|
|
}
|
|
}
|
|
|
|
// Reset scroll-snap points
|
|
this.scrollable.style.scrollSnapPointsY = `repeat(${this.pageHeight}px)`;
|
|
|
|
// Set page border background
|
|
this.icons.style.backgroundSize = '100% ' + (this.pageHeight * 2) + 'px';
|
|
|
|
// Make sure the grid is a multiple of the page size. If the size has
|
|
// shrunk we do this in a timeout so that the page scrolls has time
|
|
// to scroll into place before we shrink the container.
|
|
if (this.resizeTimeout !== null) {
|
|
clearTimeout(this.resizeTimeout);
|
|
}
|
|
var setGridHeight = () => {
|
|
this.resizeTimeout = null;
|
|
this.icons.style.height = gridHeight + 'px';
|
|
this.gridHeight = this.pendingGridHeight;
|
|
};
|
|
if (this.pendingGridHeight > this.gridHeight) {
|
|
setGridHeight();
|
|
} else if (this.pendingGridHeight !== this.gridHeight) {
|
|
this.resizeTimeout = setTimeout(setGridHeight, RESIZE_TIMEOUT);
|
|
}
|
|
},
|
|
|
|
snapScrollPosition: function(bias) {
|
|
bias = bias || 0;
|
|
var gridHeight = this.pendingGridHeight;
|
|
var currentScroll = this.scrollable.scrollTop;
|
|
var scrollHeight = this.scrollable.clientHeight;
|
|
|
|
var destination;
|
|
if (this.settings.scrollSnapping) {
|
|
destination = Math.min(gridHeight - scrollHeight,
|
|
Math.round(currentScroll / this.pageHeight + bias) * this.pageHeight);
|
|
} else {
|
|
destination = Math.min(gridHeight - scrollHeight,
|
|
currentScroll + (this.pageHeight * bias));
|
|
}
|
|
|
|
if (Math.abs(destination - currentScroll) > 1) {
|
|
this.scrollable.style.overflow = '';
|
|
this.scrollable.scrollTo(
|
|
{ left: 0, top: destination, behavior: 'smooth' });
|
|
|
|
if (this.autoScrollOverflowTimeout !== null) {
|
|
clearTimeout(this.autoScrollOverflowTimeout);
|
|
this.autoScrollOverflowTimeout = null;
|
|
}
|
|
|
|
if (this.dragging) {
|
|
document.body.classList.add('autoscroll');
|
|
this.autoScrollOverflowTimeout = setTimeout(() => {
|
|
this.autoScrollOverflowTimeout = null;
|
|
this.scrollable.style.overflow = 'hidden';
|
|
document.body.classList.remove('autoscroll');
|
|
this.scrollable.scrollTop = destination;
|
|
}, AUTOSCROLL_OVERFLOW_DELAY);
|
|
}
|
|
}
|
|
},
|
|
|
|
showActionDialog: function(dialog, args, callbacks) {
|
|
// XXX Working around gaia-components issue #8.
|
|
if (dialog.style.display !== 'none') {
|
|
return;
|
|
}
|
|
|
|
function executeCallback(dialog, callback) {
|
|
callback();
|
|
dialog.close();
|
|
}
|
|
|
|
var actions = dialog.getElementsByClassName('action');
|
|
for (var i = 0, iLen = Math.min(actions.length, callbacks.length);
|
|
i < iLen; i++) {
|
|
actions[i].onclick = executeCallback.bind(this, dialog, callbacks[i]);
|
|
}
|
|
if (args) {
|
|
dialog.querySelector('.body').setAttribute('data-l10n-args', args);
|
|
}
|
|
setTimeout(() => { dialog.open(); }, DIALOG_SHOW_TIMEOUT);
|
|
},
|
|
|
|
getChildIndex: function(child) {
|
|
// XXX Note, we're taking advantage of gaia-container using
|
|
// Array instead of HTMLCollection here.
|
|
return this.icons.children.indexOf(child);
|
|
},
|
|
|
|
removeSelectedIcon: function() {
|
|
if (!this.selectedIcon) {
|
|
return;
|
|
}
|
|
|
|
if (this.selectedIcon.app) {
|
|
if (!this.selectedIcon.app.removable) {
|
|
return;
|
|
}
|
|
navigator.mozApps.mgmt.uninstall(this.selectedIcon.app);
|
|
} else if (this.selectedIcon.bookmark) {
|
|
var remove = new MozActivity({
|
|
name: 'remove-bookmark',
|
|
data: { type: 'url', url: this.selectedIcon.bookmark.id }
|
|
});
|
|
|
|
// Re-enter edit mode because the activity will hide the document,
|
|
// which exits edit mode
|
|
var icon = this.selectedIcon;
|
|
remove.onsuccess = () => {
|
|
this.enterEditMode(null);
|
|
};
|
|
remove.onerror = () => {
|
|
this.enterEditMode(icon);
|
|
};
|
|
}
|
|
},
|
|
|
|
renameSelectedIcon: function() {
|
|
if (!this.selectedIcon || !this.selectedIcon.bookmark) {
|
|
return;
|
|
}
|
|
|
|
var rename = new MozActivity({
|
|
name: 'save-bookmark',
|
|
data: { type: 'url', url: this.selectedIcon.bookmark.id }
|
|
});
|
|
|
|
// Re-enter edit mode because the activity will hide the document,
|
|
// which exits edit mode
|
|
var icon = this.selectedIcon;
|
|
rename.onsuccess = rename.onerror = () => {
|
|
this.enterEditMode(icon);
|
|
};
|
|
},
|
|
|
|
iconIsEditable: function(icon) {
|
|
return (icon.bookmark || (icon.app && icon.app.removable)) ? true : false;
|
|
},
|
|
|
|
updateSelectedIcon: function(icon) {
|
|
if (this.selectedIcon === icon) {
|
|
return;
|
|
}
|
|
|
|
if (this.selectedIcon && (!icon || this.iconIsEditable(icon))) {
|
|
this.selectedIcon.classList.remove('selected');
|
|
this.selectedIcon.removeEventListener('touchstart', this);
|
|
this.selectedIcon = null;
|
|
}
|
|
|
|
var selectedRenameable = false;
|
|
var selectedRemovable = false;
|
|
|
|
if (icon) {
|
|
selectedRenameable = !!icon.bookmark;
|
|
selectedRemovable = selectedRenameable || icon.app.removable;
|
|
|
|
if (selectedRenameable || selectedRemovable) {
|
|
this.selectedIcon = icon;
|
|
icon.classList.add('selected');
|
|
icon.addEventListener('touchstart', this);
|
|
this.rename.classList.toggle('active', selectedRenameable);
|
|
this.remove.classList.toggle('active', selectedRemovable);
|
|
} else if (!icon.classList.contains('uneditable')) {
|
|
icon.classList.add('uneditable');
|
|
icon.addEventListener('animationend', function animEnd() {
|
|
icon.removeEventListener('animationend', animEnd);
|
|
icon.classList.remove('uneditable');
|
|
});
|
|
}
|
|
} else {
|
|
this.rename.classList.remove('active');
|
|
this.remove.classList.remove('active');
|
|
}
|
|
},
|
|
|
|
enterEditMode: function(icon) {
|
|
console.debug('Entering edit mode on ' + (icon ? icon.name : 'no icon'));
|
|
this.updateSelectedIcon(icon);
|
|
|
|
if (this.editMode) {
|
|
return;
|
|
}
|
|
|
|
this.editMode = true;
|
|
document.body.classList.add('edit-mode');
|
|
},
|
|
|
|
exitEditMode: function() {
|
|
if (!this.editMode) {
|
|
return;
|
|
}
|
|
console.debug('Exiting edit mode');
|
|
|
|
this.editMode = false;
|
|
|
|
document.body.classList.remove('edit-mode');
|
|
this.rename.classList.remove('active');
|
|
this.remove.classList.remove('active');
|
|
this.updateSelectedIcon(null);
|
|
},
|
|
|
|
handleEvent: function(e) {
|
|
var icon, child, id;
|
|
|
|
switch (e.type) {
|
|
// App launching
|
|
case 'activate':
|
|
e.preventDefault();
|
|
icon = e.detail.target.firstElementChild;
|
|
|
|
// If we're in edit mode, remap taps to selection
|
|
if (this.editMode) {
|
|
this.enterEditMode(icon);
|
|
return;
|
|
}
|
|
|
|
switch (icon.state) {
|
|
case 'unrecoverable':
|
|
navigator.mozApps.mgmt.uninstall(icon.app);
|
|
break;
|
|
|
|
case 'installing':
|
|
this.showActionDialog(this.cancelDownload,
|
|
JSON.stringify({ name: icon.name }),
|
|
[() => {
|
|
icon.app.cancelDownload();
|
|
}]);
|
|
break;
|
|
|
|
case 'error':
|
|
case 'paused':
|
|
this.showActionDialog(this.resumeDownload,
|
|
JSON.stringify({ name: icon.name }),
|
|
[() => {
|
|
icon.app.download();
|
|
}]);
|
|
break;
|
|
|
|
default:
|
|
icon.launch();
|
|
break;
|
|
}
|
|
break;
|
|
|
|
// Activate drag-and-drop immediately for selected icons
|
|
case 'touchstart':
|
|
this.icons.dragAndDropTimeout = 0;
|
|
break;
|
|
|
|
// Disable scrolling during dragging, and display bottom-bar
|
|
case 'drag-start':
|
|
console.debug('Drag-start on ' +
|
|
e.detail.target.firstElementChild.name);
|
|
this.dragging = true;
|
|
this.shouldEnterEditMode = true;
|
|
document.body.classList.add('dragging');
|
|
this.scrollable.style.overflow = 'hidden';
|
|
this.draggedIndex = this.getChildIndex(e.detail.target);
|
|
break;
|
|
|
|
case 'drag-finish':
|
|
console.debug('Drag-finish');
|
|
this.dragging = false;
|
|
document.body.classList.remove('dragging');
|
|
document.body.classList.remove('autoscroll');
|
|
this.scrollable.style.overflow = '';
|
|
|
|
if (this.autoScrollInterval !== null) {
|
|
clearInterval(this.autoScrollInterval);
|
|
this.autoScrollInterval = null;
|
|
}
|
|
|
|
if (this.autoScrollOverflowTimeout !== null) {
|
|
clearTimeout(this.autoScrollOverflowTimeout);
|
|
this.autoScrollOverflowTimeout = null;
|
|
}
|
|
|
|
if (this.hoverIcon) {
|
|
this.hoverIcon.classList.remove('hover-before', 'hover-after');
|
|
this.hoverIcon = null;
|
|
}
|
|
|
|
// Restore normal drag-and-drop after dragging selected icons
|
|
this.icons.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'));
|
|
if (e.detail.dropTarget === null &&
|
|
e.detail.clientX >= this.iconsLeft &&
|
|
e.detail.clientX < this.iconsRight) {
|
|
// 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));
|
|
break;
|
|
}
|
|
|
|
if (e.detail.dropTarget === e.detail.target) {
|
|
icon = e.detail.target.firstElementChild;
|
|
if (this.editMode || this.shouldEnterEditMode) {
|
|
e.preventDefault();
|
|
this.enterEditMode(icon);
|
|
}
|
|
}
|
|
break;
|
|
|
|
// Save the app grid after rearrangement
|
|
case 'drag-rearrange':
|
|
console.debug('Drag rearrange');
|
|
this.storeAppOrder();
|
|
break;
|
|
|
|
// Handle app-uninstall bar highlight and auto-scroll
|
|
case 'drag-move':
|
|
var inAutoscroll = false;
|
|
|
|
if (e.detail.clientY > this.lastWindowHeight - AUTOSCROLL_DISTANCE) {
|
|
// User is dragging in the lower auto-scroll area
|
|
inAutoscroll = true;
|
|
if (this.autoScrollInterval === null) {
|
|
this.autoScrollInterval = setInterval(() => {
|
|
this.shouldEnterEditMode = false;
|
|
this.snapScrollPosition(1);
|
|
return true;
|
|
}, AUTOSCROLL_DELAY);
|
|
}
|
|
} else if (e.detail.clientY < AUTOSCROLL_DISTANCE) {
|
|
// User is dragging in the upper auto-scroll area
|
|
inAutoscroll = true;
|
|
if (this.autoScrollInterval === null) {
|
|
this.autoScrollInterval = setInterval(() => {
|
|
this.shouldEnterEditMode = false;
|
|
this.snapScrollPosition(-1);
|
|
return true;
|
|
}, AUTOSCROLL_DELAY);
|
|
}
|
|
} else {
|
|
// User is dragging in the grid, provide some visual feedback
|
|
var hoverIcon = this.icons.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.hoverIcon = (hoverIcon !== e.detail.target) ? hoverIcon : null;
|
|
|
|
if (this.hoverIcon) {
|
|
var offset = this.draggedIndex -
|
|
this.getChildIndex(this.hoverIcon);
|
|
this.hoverIcon.classList.add((offset >= 0) ?
|
|
'hover-before' : 'hover-after');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!inAutoscroll && this.autoScrollInterval !== null) {
|
|
clearInterval(this.autoScrollInterval);
|
|
this.autoScrollInterval = null;
|
|
}
|
|
break;
|
|
|
|
// Add apps installed after startup
|
|
case 'install':
|
|
// 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;
|
|
if (icon.app && icon.app.manifestURL === e.application.manifestURL) {
|
|
icon.app = e.application;
|
|
icon.refresh();
|
|
existing = true;
|
|
}
|
|
}
|
|
if (existing) {
|
|
return;
|
|
}
|
|
|
|
this.addApp(e.application);
|
|
this.storeAppOrder();
|
|
break;
|
|
|
|
// Remove apps uninstalled after startup
|
|
case 'uninstall':
|
|
var callback = () => {
|
|
this.storeAppOrder();
|
|
this.refreshGridSize();
|
|
this.snapScrollPosition();
|
|
};
|
|
|
|
for (child of this.icons.children) {
|
|
icon = child.firstElementChild;
|
|
if (icon.app && icon.app.manifestURL === e.application.manifestURL) {
|
|
id = this.getIconId(e.application, icon.entryPoint);
|
|
this.metadata.remove(id).then(() => {},
|
|
(e) => {
|
|
console.error('Error removing uninstalled app', e);
|
|
});
|
|
|
|
this.icons.removeChild(child, callback);
|
|
|
|
// We only want to store the app order once, so clear the callback
|
|
callback = null;
|
|
|
|
if (this.selectedIcon === icon) {
|
|
this.updateSelectedIcon(null);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'localized':
|
|
for (icon of this.icons.children) {
|
|
icon.firstElementChild.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);
|
|
if (id === this.iconsToRetry[i]) {
|
|
this.refreshIcon(icon);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'resize':
|
|
if (this.lastWindowWidth === window.innerWidth &&
|
|
this.lastWindowHeight === window.innerHeight) {
|
|
break;
|
|
}
|
|
|
|
this.lastWindowWidth = window.innerWidth;
|
|
this.lastWindowHeight = window.innerHeight;
|
|
|
|
// Reset icon-size
|
|
var oldIconSize = this.iconSize;
|
|
this._iconSize = 0;
|
|
|
|
// If the icon size has changed, refresh icons
|
|
if (oldIconSize !== this.iconSize) {
|
|
for (child of this.icons.children) {
|
|
icon = child.firstElementChild;
|
|
this.refreshIcon(icon);
|
|
}
|
|
}
|
|
|
|
// Re-synchronise icon position
|
|
this.icons.synchronise();
|
|
|
|
// Recalculate grid size/snap points
|
|
this.refreshGridSize();
|
|
this.snapScrollPosition();
|
|
break;
|
|
|
|
case 'settings-changed':
|
|
this.toggleSmall(this.settings.small);
|
|
this.toggleScrollSnapping(this.settings.scrollSnapping);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.Apps = Apps;
|
|
|
|
}(window));
|