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.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,132 @@
(function(exports) {
'use strict';
exports.Animations = {
createCircleAnimation: function ani_createCircleAnimation(
container, backgroundColor) {
var circleElem = document.createElement('div');
circleElem.className = 'animation-circle';
if (backgroundColor) {
circleElem.style.backgroundColor = backgroundColor;
}
var animationType;
var animationCallback;
var isPlaying = false;
circleElem.addEventListener('animationend', function(evt) {
if (evt.target === circleElem) {
if (animationCallback) {
animationCallback();
}
circleElem.classList.remove(animationType);
container.removeChild(circleElem);
isPlaying = false;
}
});
var play = function (type, callback) {
isPlaying = true;
animationType = type || 'grow';
animationCallback = callback;
circleElem.classList.add(animationType);
container.appendChild(circleElem);
};
return {
play: play,
isPlaying: function() {
return isPlaying;
}
};
},
_findChildInViewport: function(container, selector) {
// find the elements in viewport
var child;
var childRect;
var children = container.querySelectorAll(selector);
var childrenInViewport = [];
var i;
for(i = 0; i < children.length; i++) {
child = children[i];
childRect = child.getBoundingClientRect();
if (childRect.right >= 0 &&
childRect.left <= window.innerWidth) {
childrenInViewport.push({
left: childRect.left,
child: child
});
}
}
// sort child indexes. Card position may not be the same as index
// after a new child is pinned to home
childrenInViewport.sort(function(childObj1, childObj2) {
if (childObj1.left > childObj2.left) {
return 1;
}
if (childObj1.left < childObj2.left) {
return -1;
}
return 0;
});
return childrenInViewport;
},
/**
* Perform bubbling animation on the children elements 'relative to' the
* container.
* @param {HTMLElement} container Container for bubble animation.
* @param {Object} querySelector Representation for querySelector
* @param {Number} bubbleInterval Time interval between two bubbles.
* @param {Function} callback The function that will be called
* once all the children finish
* bubbling animation.
*/
doBubbleAnimation: function(container, querySelector, bubbleInterval,
callback) {
// only do the animation on elements in viewport
var childrenInViewport = this._findChildInViewport(
container, querySelector);
var endBubble = function() {
for(i = 0; i < childrenInViewport.length; i++) {
child = childrenInViewport[i].child;
child.classList.remove('animation-bubble-start');
child.removeEventListener('animationend', onBubbleEnd);
}
if (callback) {
callback();
}
}.bind(this);
// Need to wait until the last element has finished bubbling.
// Note: The class also overrides transform properties on the child,
// so we should not remove it until all the animations end.
var childTransitionEndCount = 0;
var onBubbleEnd = function(evt) {
childTransitionEndCount++;
if (childTransitionEndCount === childrenInViewport.length) {
endBubble();
}
};
// start adding bubble animation class on every child
var child;
var i;
for(i = 0; i < childrenInViewport.length; i++) {
child = childrenInViewport[i].child;
child.classList.add('animation-bubble-start');
child.style.animationDelay = ((i + 1) * bubbleInterval / 1000) + 's';
child.addEventListener('animationend', onBubbleEnd);
}
return endBubble;
}
};
})(window);

View file

@ -0,0 +1,469 @@
'use strict';
/* global evt, ManifestHelper */
(function(exports) {
/**
* Application Entry Point
*
* This object stands for an entry point of the app. There can be more than
* one entry point in one app. Each entry point can own its name and icon, and
* can be launched singly.
*
* @property {String} manifestURL The app's manifestURL.
* @property {String} entryPoint The entry point defined in app's manifest.
* @property {String} name The name of the entry.
* @typedef {Object} AppEntryPoint
*/
/**
* Applications is a helper for mozApps.mgmt. It is responsible for:
*
* - get all installed apps
* - get app icon blob
* - listen to app install/update/uninstall events and publish them
* - launch app
*
* @namespace Applications
* @requires ManifestHelper
*/
var Applications = evt({
/**
* App roles which we don't need to handle and monitor.
*
* @constant
* @type {Array}
* @memberof Applications
*/
HIDDEN_ROLES: [
'system', 'homescreen', 'input', 'deck', 'addon', 'langpack', 'widget'
],
/**
* Default icon url.
*
* @constant
* @type {String}
* @memberof Applications
*/
DEFAULT_ICON_URL: '/style/images/default.png',
/**
* Installed apps indexed by their "manifestURL".
*
* @readonly
* @type {Object}
* @memberof Applications
*/
installedApps: {},
_ready: false,
_readyCallbacks: [],
_isHiddenApp: function appIsHiddenApp(role) {
if (!role) {
return false;
}
return (this.HIDDEN_ROLES.indexOf(role) !== -1);
},
_loadIcon: function appLoadIcon(request) {
if (!request.url) {
if (request.onerror) {
request.onerror();
}
return;
}
var xhr = new XMLHttpRequest({
mozAnon: true,
mozSystem: true
});
var icon = request.url;
xhr.open('GET', icon, true);
xhr.responseType = 'blob';
xhr.onload = function onload(evt) {
var status = xhr.status;
if (status !== 0 && status !== 200) {
if (request.onerror) {
request.onerror();
}
return;
}
if (request.onsuccess) {
request.onsuccess(xhr.response);
}
};
xhr.ontimeout = xhr.onerror = function onerror(evt) {
if (request.onerror) {
request.onerror();
}
};
try {
xhr.send(null);
} catch (evt) {
if (request.onerror) {
request.onerror();
}
}
},
_bestMatchingIcon:
function appBestMatchingIcon(app, manifest, preferredSize) {
preferredSize = preferredSize || Number.MAX_VALUE;
var max = 0;
var closestSize = 0;
for (var size in manifest.icons) {
size = parseInt(size, 10);
if (size > max) {
max = size;
}
if (!closestSize && size >= preferredSize) {
closestSize = size;
}
}
if (! closestSize) {
closestSize = max;
}
var url = manifest.icons[closestSize];
if (!url) {
return;
}
if (url.indexOf('data:') === 0 ||
url.indexOf('app://') === 0 ||
url.indexOf('http://') === 0 ||
url.indexOf('https://') === 0) {
return url;
}
if (url.charAt(0) != '/') {
console.warn('`' + manifest.name + '` app icon is invalid. ' +
'Manifest `icons` attribute should contain URLs -or- ' +
'absolute paths from the origin field.');
return '';
}
if (app.origin.slice(-1) === '/') {
return app.origin.slice(0, -1) + url;
}
return app.origin + url;
},
/**
* An easy way to register callback functions for "ready" event.
*
* Use this method to initialize your code if it depends on Applications. It
* will be triggered immediately if Applications is already ready.
*
* @param {Function} callback This function will be called when initialized.
* @memberof Applications
*/
ready: function appReady(callback) {
if (this._ready) {
window.setTimeout(callback);
} else {
this._readyCallbacks.push(callback);
}
},
onAppInstall: function onAppInstall(evt) {
var app = evt.application;
var manifest = app.manifest || app.updateManifest;
if (!app.launch || !manifest || !manifest.icons ||
this._isHiddenApp(manifest.role)) {
return;
}
var message =
this.installedApps[app.manifestURL] ? 'update' : 'install';
this.installedApps[app.manifestURL] = app;
this.fire(message, this.getAppEntries(app.manifestURL));
},
onAppUninstall: function onAppUninstall(evt) {
var app = evt.application;
if (this.installedApps[app.manifestURL]) {
this.fire('uninstall', this.getAppEntries(app.manifestURL));
delete this.installedApps[app.manifestURL];
}
},
/**
* Initialize Applications.
*
* The callback functions registered by "ready" method will be triggered
* after it's done.
*
* @param {Function} callback This function will be called when "init" is
* done.
* @memberof Applications
*/
init: function appInit(callback) {
var appMgmt = navigator.mozApps.mgmt;
var self = this;
appMgmt.getAll().onsuccess = function onsuccess(event) {
event.target.result.forEach(function eachApp(app) {
var manifest = app.manifest;
if (!app.launch || !manifest || !manifest.icons ||
self._isHiddenApp(manifest.role)) {
return;
}
self.installedApps[app.manifestURL] = app;
});
self._ready = true;
if (callback) {
callback();
}
while (self._readyCallbacks.length) {
setTimeout(self._readyCallbacks.shift());
}
};
appMgmt.addEventListener('install', this);
appMgmt.addEventListener('uninstall', this);
},
/**
* Release all resources.
*
* @memberof Applications
*/
uninit: function appUninit() {
var appMgmt = navigator.mozApps.mgmt;
appMgmt.oninstall = null;
appMgmt.onuninstall = null;
this.installedApps = {};
this._ready = false;
this._readyCallbacks = [];
},
/* jshint -W004 */
// XXX: Uses this to prevent 'helper' is already defined jshint error
/**
* Get all "entry_point"s from the specified app.
*
* @param {String} manifestURL The app's manifestURL.
* @return {Array} An array contains all {@link AppEntryPoint} of the
* specified app.
* @memberof Applications
*/
getAppEntries: function appGetAppEntries(manifestURL) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return [];
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
var entryPoints = manifest.entry_points;
var entries = [];
var removable = this.installedApps[manifestURL].removable;
if (!entryPoints || manifest.type !== 'certified') {
var helper = new ManifestHelper(manifest);
entries.push({
manifestURL: manifestURL,
entryPoint: '',
name: helper.name,
removable: removable
});
} else {
for (var entryPoint in entryPoints) {
if (entryPoints[entryPoint].icons) {
var helper = new ManifestHelper(entryPoints[entryPoint]);
entries.push({
manifestURL: manifestURL,
entryPoint: entryPoint,
name: helper.name,
removable: removable
});
}
}
}
return entries;
},
/* jshint +W004 */
/**
* Get all "entry_point"s from all installed apps.
*
* @return {Array} An array contains all {@link AppEntryPoint} of the
* installed apps.
* @memberof Applications
*/
getAllAppEntries: function appGetAllAppEntries() {
if (!this._ready) {
return null;
}
var entries = [];
for (var manifestURL in this.installedApps) {
entries.push.apply(entries, this.getAppEntries(manifestURL));
}
return entries;
},
/**
* Launch an app by its manifestURL and entryPoint.
*
* @param {String} manifestURL The app's manifestURL.
* @param {String} [entryPoint] The specific "entry_point" you want to
* launch. Default launch path will be involved
* if it's omitted.
* @return {Boolean} true if succeed.
* @memberof Applications
*/
launch: function appLaunch(manifestURL, entryPoint) {
var installedApps = this.installedApps;
if (!manifestURL || !installedApps[manifestURL] ||
!installedApps[manifestURL].launch) {
return false;
}
entryPoint = entryPoint || '';
installedApps[manifestURL].launch(entryPoint);
return true;
},
/**
* Get manifest object by specified manifestURL and entryPoint.
*
* @param {String} manifestURL The app's manifestURL.
* @param {String} [entryPoint] Specify an "entry_point" if you want to get
* its part only. Otherwise, the whole manifest
* object will be returned.
* @return {Object} A manifest object or "null" if the app doesn't exist.
* @memberof Applications
*/
getEntryManifest: function appGetEntryManifest(manifestURL, entryPoint) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return null;
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
if (entryPoint) {
var entry_manifest = manifest.entry_points[entryPoint];
return entry_manifest || null;
}
return manifest;
},
/**
* Get app/entry's name.
*
* @param {String} manifestURL An app's manifestURL.
* @param {String} [entryPoint] Specify an "entry_point" if you want to get
* its part only. Otherwise, the default name
* will be returned.
* @return {String} The app/entry's name or empty string if the app doesn't
* exist.
* @memberof Applications
*/
getName: function appGetName(manifestURL, entryPoint) {
var entry_manifest = this.getEntryManifest(manifestURL, entryPoint);
if (!entry_manifest) {
return '';
}
return new ManifestHelper(entry_manifest).name;
},
/**
* Get blob data of the app/entry's icon.
*
* @param {String} manifestURL An app's manifestURL.
* @param {String} [entryPoint] Specify an "entry_point".
* @param {Number} [preferredSize=Number.MAX_VALUE]
* In pixels. This method will choose the smallest icon that its
* width greater than this value or the biggest one if all the icons'
* width are smaller than this value.
* @param {Function} [callback] The callback will contains blob data as the
* first argument if succeed or "undefined" if
* something failed.
* @return {Boolean} true if the process is started or false if no valid
* icon url can be found or the app doesn't exist.
* @memberof Applications
*/
getIconBlob: function appGetIconBlob(manifestURL, entryPoint, preferredSize,
callback) {
var entry_manifest = this.getEntryManifest(manifestURL, entryPoint);
if (! entry_manifest) {
return false;
}
var url = this._bestMatchingIcon(
this.installedApps[manifestURL], entry_manifest, preferredSize);
if (!url) {
if (callback) {
setTimeout(callback);
}
return true;
}
this._loadIcon({
url: url,
onsuccess: function(blob) {
if (callback) {
callback(blob);
}
},
onerror: function() {
if (callback) {
callback();
}
}
});
return true;
},
listAppsByRole: function listAppsByRole(role) {
var matched = [];
for (var manifestURL in this.installedApps) {
var app = this.installedApps[manifestURL];
if (!role || app.manifest.role === role) {
matched.push({
'manifestURL': manifestURL,
'app': app
});
}
}
return matched;
},
handleEvent: function handleEvent(evt) {
switch(evt.type) {
case 'install':
this.onAppInstall(evt);
break;
case 'uninstall':
this.onAppUninstall(evt);
break;
}
}
});
exports.Applications = Applications;
})(window);

View file

@ -0,0 +1,85 @@
/* global CardStore, evt */
(function(exports) {
'use strict';
var BookmarkManager = evt({
TABLE_NAME: 'appdeck_bookmarks',
init: function bm_init(ownerURL, mode) {
this._store = new CardStore(this.TABLE_NAME, mode, ownerURL);
this._store.on('change', this.fire.bind(this, 'change'));
},
iterate: function bm_iterate(cb) {
return this._store.iterateData(cb);
},
add: function bm_add(entry, id) {
if (!id) {
id = entry.url;
}
entry.date = new Date();
if (entry.iconUrl) {
return this.fetchIcon(entry.iconUrl).then(iconData => {
entry.icon = iconData;
delete entry.iconUrl;
return this._store.addData(entry, id);
}).catch(() => {
return this._store.addData(entry, id);
});
} else {
return this._store.addData(entry, id);
}
},
remove: function bm_remove(id) {
return this._store.removeData(id);
},
get: function bm_get(id) {
return this._store.getData(id);
},
set: function bm_set(id, entry) {
entry.date = new Date();
if (entry.iconUrl) {
return this.fetchIcon(entry.iconUrl).then(iconData => {
entry.icon = iconData;
delete entry.iconUrl;
return this._store.saveData(id, entry);
}).catch(() => {
return this._store.saveData(id, entry);
});
} else {
return this._store.saveData(id, entry);
}
},
fetchIcon: function bm_fetchIcon(url) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest({mozSystem: true});
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.send();
xhr.onload = function() {
resolve(xhr.response);
};
xhr.onerror = function() {
console.warn('Unable to retrive icon data. Leaving icon empty.');
reject();
};
});
},
getLength: function bm_getLength() {
return this._store.getLength();
}
});
exports.BookmarkManager = BookmarkManager;
}(window));

View file

@ -0,0 +1,815 @@
/* global evt, SharedUtils, Promise, PipedPromise, Application, CardStore,
Deck, Folder, AsyncSemaphore, AppBookmark */
(function(exports) {
'use strict';
var CardManager = function() {
};
CardManager.STATES = Object.freeze({
'READY': 'READY',
'SYNCING': 'SYNCING'
});
CardManager.prototype = evt({
HIDDEN_ROLES: ['system', 'homescreen', 'addon', 'langpack'],
TABLE_NAME: 'home_cards',
// Only two modes available: readonly and readwrite (default)
// 'readwrite' mode is for Smart-Home app only
// This '_mode' variable only affects CardStore.
_mode: 'readwrite',
_manifestURLOfCardStore: undefined,
_cardStore: undefined,
_cardList: [],
installedApps: {},
_asyncSemaphore: undefined,
_isHiddenApp: function cm_isHiddenApp(role) {
if (!role) {
return false;
}
return (this.HIDDEN_ROLES.indexOf(role) !== -1);
},
_isCardListLoaded: function cm_isCardListLoaded() {
return this._cardList && this._cardList.length > 0;
},
_serializeCard: function cm_serializeCard(card) {
return card && card.serialize();
},
_deserializeCardEntry: function cm_deserializeCardEntry(cardEntry) {
var cardInstance;
switch (cardEntry.type) {
case 'Application':
cardInstance = Application.deserialize(cardEntry, this.installedApps);
break;
case 'Deck':
cardInstance = Deck.deserialize(cardEntry, this.installedApps);
break;
case 'AppBookmark':
cardInstance = AppBookmark.deserialize(cardEntry);
break;
case 'Folder':
cardInstance = Folder.deserialize(cardEntry);
// Save the folder into card store whenever we receives
// folder-changed event
cardInstance.on('folder-changed', this._onFolderChange.bind(this));
break;
}
return cardInstance;
},
writeFolderInCardStore: function cm_writeFolderInCardStore(folder) {
var that = this;
return new Promise(function(resolve, reject) {
if (folder instanceof Folder) {
that._asyncSemaphore.v();
var cardEntriesInFolder =
folder.getCardList().map(that._serializeCard.bind(that));
that._cardStore.saveData(folder.folderId,
cardEntriesInFolder).then(function() {
folder.state = Folder.STATES.NORMAL;
}).then(function() {
that._asyncSemaphore.p();
resolve();
}, reject);
} else {
reject();
}
});
},
// XXX: Call this function when you need to write cardList back to datastore
// Because when we write cardList to datastore, we need to seperate
// first level cards and cards under folder into sperated records in
// datastore. A better way whould pull out logic related to cardList
// into a standalone module. We will do this later.
writeCardlistInCardStore: function cm_writeCardlistInCardStore(options) {
var that = this;
var emptyFolderIndices = [];
return new Promise(function(resolve, reject) {
var saveDataPromises = [];
var newCardList;
that._asyncSemaphore.v();
// The cards inside of folder are not saved nested in cardList
// but we explicit save them under key of folderId.
// Here we save content of each folder one by one
newCardList = that._cardList.filter(function(card, index) {
if (card instanceof Folder) {
if (card.getCardList().length > 0) {
saveDataPromises.push(that.writeFolderInCardStore(card));
} else {
emptyFolderIndices.push(index);
return false;
}
}
return true;
});
if (options && options.cleanEmptyFolder) {
that._cardList = newCardList;
}
Promise.all(saveDataPromises).then(function() {
resolve();
});
}).then(function() {
var cardEntries =
that._cardList.map(that._serializeCard.bind(that));
return that._cardStore.saveData('cardList', cardEntries);
}).then(function() {
that._asyncSemaphore.p();
if (options && options.cleanEmptyFolder) {
that.fire('card-removed', emptyFolderIndices);
}
}).catch(function() {
that._asyncSemaphore.p();
});
},
_loadDefaultCardList: function cm_loadDefaultCardList() {
var that = this;
return this._getPipedPromise('_loadDefaultCardList',
function(resolve, reject) {
var defaultCardListFile = 'shared/resources/default-cards.json';
that._asyncSemaphore.v();
that._loadFile({
url: defaultCardListFile,
responseType: 'json'
}).then(function onFulfill(config) {
that._cardList =
config.card_list.map(function(cardEntry) {
var card = that._deserializeCardEntry(cardEntry);
if (card instanceof Folder && card.isDeserializing()) {
card.loadCardsInFolder({
from: 'config',
cardEntry: cardEntry,
deserializer: that._deserializeCardEntry.bind(that)
});
}
return card;
});
// write cardList into data store for the first time
that.writeCardlistInCardStore().then(resolve, reject);
}).then(function() {
that._asyncSemaphore.p();
}).catch(function onReject(error) {
var reason ='request ' + defaultCardListFile +
' got reject ' + error;
that._asyncSemaphore.p();
reject(reason);
});
});
},
_onCardStoreChange: function cm_onCardStoreChange(evt) {
var that = this;
if (evt.operation === 'updated') {
that._asyncSemaphore.v();
// When we receives 'cardlist-changed' in readonly mode, it means
// Smart-Home app has change cardList. We'd better re-fetch cardList
// as a whole.
if (this._mode === 'readonly') {
this._cardList = [];
}
this._reloadCardList().then(function() {
that._asyncSemaphore.p();
that.fire('cardlist-changed');
});
}
},
_onFolderChange: function cm_onFolderChange(folder) {
if (folder.isDetached()) {
this.writeCardlistInCardStore();
} else {
this.writeFolderInCardStore(folder);
if (folder.isEmpty()) {
this.writeCardlistInCardStore();
}
}
},
_initCardStoreIfNeeded: function cm_initCardStore() {
if (!this._cardStore) {
this._cardStore = new CardStore(
this.TABLE_NAME, this._mode, this._manifestURLOfCardStore);
this._cardStore.on('change', this._onCardStoreChange.bind(this));
}
},
// XXX: DO NOT call this directly except for _reloadCardList.
// Because it is not protected by AsyncSemaphore. We should change it as
// returning a `Promise`
_loadCardListFromCardStore:
function cm_loadCardListFromCardStore(cardListFromCardStore) {
var that = this;
cardListFromCardStore.forEach(function(cardEntry) {
var found = that.findCardFromCardList({cardEntry: cardEntry});
if (!found) {
var card = that._deserializeCardEntry(cardEntry);
// The cards inside of folder are not saved nested in
// datastore 'cardList'. But we explicit save them under key
// of folderId. So we need to retrieve them by their folderId
// and put them back to folders where they belong.
if (card instanceof Folder && card.isDeserializing()) {
card.loadCardsInFolder({
from: 'datastore',
datastore: that._cardStore,
deserializer: that._deserializeCardEntry.bind(that)
});
}
that._cardList.push(card);
}
});
},
_reloadCardList: function cm_loadCardList() {
var that = this;
return this._getPipedPromise('_reloadCardList',
function(resolve, reject) {
that._asyncSemaphore.v();
that._initCardStoreIfNeeded();
resolve(that._cardStore.getData('cardList'));
}).then(function(cardListFromCardStore) {
if (cardListFromCardStore) {
// XXX: Change _loadCardListFromCardStore as returning a `Promise`
that._loadCardListFromCardStore(cardListFromCardStore);
} else {
// no cardList in datastore, load default from config file
return Promise.resolve(that._loadDefaultCardList());
}
}).then(function() {
that._asyncSemaphore.p();
}).catch(function(reason) {
console.warn('Unable to reload cardList due to ' + reason);
that._asyncSemaphore.p();
});
},
_loadFile: function cm_loadIcon(request) {
// _loadFile could accept reentrance, so it should return
// new Promise on each invokation
return new Promise(function(resolve, reject) {
var url = request.url;
var responseType = request.responseType || 'text';
if (typeof url === 'string') {
try {
var xhr = new XMLHttpRequest({mozAnon: true, mozSystem: true});
xhr.open('GET', url, true);
xhr.responseType = responseType;
xhr.onload = function onload(evt) {
if (xhr.status !== 0 && xhr.status !== 200) {
reject(xhr.statusText);
} else {
resolve(xhr.response);
}
};
xhr.ontimeout = xhr.onerror = function onErrorOrTimeout() {
reject();
};
xhr.send();
} catch (e) {
reject(e.message);
}
} else {
reject('invalid request');
}
});
},
_bestMatchingIcon:
function cm_bestMatchingIcon(app, manifest, preferredSize) {
var max = 0;
var closestSize = 0;
preferredSize = preferredSize || Number.MAX_VALUE;
for (var size in manifest.icons) {
size = parseInt(size, 10);
if (size > max) {
max = size;
}
if (!closestSize && size >= preferredSize) {
closestSize = size;
}
}
if (!closestSize) {
closestSize = max;
}
var url = manifest.icons[closestSize];
if (!url) {
return;
}
if (url.indexOf('data:') === 0 ||
url.indexOf('app://') === 0 ||
url.indexOf('http://') === 0 ||
url.indexOf('https://') === 0) {
return url;
}
if (url.charAt(0) != '/') {
console.warn('`' + manifest.name + '` app icon is invalid. ' +
'Manifest `icons` attribute should contain URLs -or- ' +
'absolute paths from the origin field.');
return '';
}
if (app.origin.slice(-1) === '/') {
return app.origin.slice(0, -1) + url;
}
return [app.origin + url, closestSize];
},
_onAppInstall: function cm_onAppInstall(evt) {
var app = evt.application;
var manifest = app.manifest || app.updateManifest;
if (!app.launch || !manifest || !manifest.icons ||
this._isHiddenApp(manifest.role)) {
return;
}
var message =
this.installedApps[app.manifestURL] ? 'update' : 'install';
this.installedApps[app.manifestURL] = app;
this.fire(message, this.getAppEntries(app.manifestURL));
},
_onAppUninstall: function cm_onAppUninstall(evt) {
var app = evt.application;
// XXX: Delete uninstalled app cards if any. The deletion should be
// performed only once, so we let only the owner of data store to do it.
// Notice the application object will be destroyed after uninstall event
// is done, and we won't be able to read any data inside card.nativeApp
// anymore (thus cause failure to FindCardFromCardList and etc.).
// So we let CardManager do it consciously to prevent zombie app cards.
// A possible way to improve it is to decouple AppMgmt and its app objects
// completely with CardManager and leave them inside applications.js.
if (this._cardStore.isOwner) {
var card = this.findCardFromCardList({
manifestURL: app.manifestURL
});
card && this.removeCard(card);
}
if (this.installedApps[app.manifestURL]) {
this.fire('uninstall', this.getAppEntries(app.manifestURL));
delete this.installedApps[app.manifestURL];
}
},
insertNewFolder: function cm_insertFolder(name, index) {
var newFolder = new Folder({
name: name,
state: Folder.STATES.DETACHED
});
this._asyncSemaphore.wait(function() {
if (typeof index !== 'number') {
index = this._cardList.length;
}
this._cardList.splice(index, 0, newFolder);
// Notice that we are not saving card list yet
// Because newFolder is empty, it is meaningless to save it
// But we need to hook `folder-changed` event handler in case
// we need to save it when its content changed
newFolder.on('folder-changed', this._onFolderChange.bind(this));
this.fire('card-inserted', newFolder, index);
}, this);
return newFolder;
},
insertCard: function cm_insertCard(options) {
var that = this;
this._asyncSemaphore.wait(function() {
var newCard;
var position;
if (options.cardEntry) {
newCard = this._deserializeCardEntry(options.cardEntry);
} else {
newCard = options.card;
}
// prevent same card from being inserted twice
if (newCard && newCard.nativeApp) {
var sameCardExisting = that.findCardFromCardList({
manifestURL: newCard.nativeApp.manifestURL,
launchURL: newCard.launchURL
});
if (sameCardExisting) {
return;
}
}
if (typeof options.index === 'number') {
position = options.index;
} else if (!newCard.group) {
position = this._cardList.length;
} else {
// If the given card belongs to a deck (has type), we assume the deck
// spans a group with all its bookmarks following deck icon itself,
// and the given card should be put at the end of the group.
position = -1;
for(var idx = 0; idx < this._cardList.length; idx++) {
var card = this._cardList[idx];
if(position === -1 && !(card instanceof Deck)) {
// Only Decks are admitted as the start of the group.
continue;
} else if (position !== -1 && card.group !== newCard.group) {
// We've exceeded the end of the group.
break;
} else if (card.group === newCard.group) {
// We're still inside the group.
position = idx;
}
}
position += 1;
// No corresponding deck found; insert at bottom.
if (position === 0) {
position = this._cardList.length;
}
}
this._cardList.splice(position, 0, newCard);
this.writeCardlistInCardStore().then(function() {
that.fire('card-inserted', newCard, position, options.overFolder);
});
}, this);
},
removeCard: function cm_removeCard(item) {
this._asyncSemaphore.wait(function() {
var that = this;
var index =
(typeof item === 'number') ? item : this._cardList.indexOf(item);
if (index >= 0) {
this._cardList.splice(index, 1);
this.writeCardlistInCardStore().then(function() {
that.fire('card-removed', [index]);
});
} else {
// the card is not in _cardList, then it could probably be in folder
this._cardList.forEach(function(card) {
if (card instanceof Folder) {
// don't bother to fire card-removed event because the folder
// itself will fire this event
card.removeCard(item);
}
});
}
}, this);
},
updateCard: function cm_updateCard(item, index) {
var that = this;
this._asyncSemaphore.wait(function() {
if (typeof index === 'undefined') {
index = this._cardList.findIndex(function(elem) {
return elem.cardId === item.cardId;
});
}
if (index >= 0) {
// Most of the time, 'item' is directly reference to card in _cardList
// and by the time we reach here, the card ('item') is already
// updated. So don't bother to update it again.
if (this._cardList[index] !== item) {
this._cardList[index] = item;
}
this.writeCardlistInCardStore().then(function() {
that.fire('card-updated', that._cardList[index], index);
});
}
}, this);
},
// XXX: Note that if you put card index as parameters for item1 and item2
// Please make sure they are "index of _cardList"
swapCard: function cm_switchCard(item1, item2) {
this._asyncSemaphore.wait(function() {
var that = this;
var idx1, idx2;
idx1 = (typeof item1 === 'number') ?
idx1 = item1 :
this._cardList.indexOf(item1);
idx2 = (typeof item2 === 'number') ?
idx2 = item2 :
this._cardList.indexOf(item2);
var tmp = this._cardList[idx1];
this._cardList[idx1] = this._cardList[idx2];
this._cardList[idx2] = tmp;
this.writeCardlistInCardStore().then(function() {
that.fire('card-swapped',
that._cardList[idx1], that._cardList[idx2], idx1, idx2);
});
}, this);
},
init: function cm_init(mode) {
var that = this;
var appMgmt = navigator.mozApps.mgmt;
this._asyncSemaphore = new AsyncSemaphore();
// protect from async access to cardList
this._asyncSemaphore.v();
this._mode = mode || 'readwrite';
// If we are running in readonly mode, we need to tell card store what
// manifestURL of the datastore we are going to use, because we are not
// using card store of current app.
if (this._mode === 'readonly') {
this._manifestURLOfCardStore =
'app://smart-home.gaiamobile.org/manifest.webapp';
}
return this._getPipedPromise('init', function(resolve, reject) {
var request = appMgmt.getAll();
request.onsuccess = function onsuccess(event) {
event.target.result.forEach(function eachApp(app) {
var manifest = app.manifest;
if (!app.launch || !manifest || !manifest.icons ||
that._isHiddenApp(manifest.role)) {
return;
}
that.installedApps[app.manifestURL] = app;
});
resolve(that._reloadCardList());
};
request.onerror = function() {
reject();
that._asyncSemaphore.p();
};
appMgmt.addEventListener('install', that);
appMgmt.addEventListener('uninstall', that);
}).then(function() {
that._asyncSemaphore.p();
});
},
uninit: function cm_uninit() {
var appMgmt = navigator.mozApps.mgmt;
appMgmt.removeEventListener('install', this);
appMgmt.removeEventListener('uninstall', this);
this._cardList = [];
this._cardStore.off('change');
this._cardStore = undefined;
this.installedApps = {};
},
getAppEntries: function cm_getAppEntries(manifestURL) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return [];
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
var entryPoints = manifest.entry_points;
var entries = [];
var removable = this.installedApps[manifestURL].removable;
if (!entryPoints || manifest.type !== 'certified') {
entries.push({
manifestURL: manifestURL,
entryPoint: '',
name: manifest.name,
removable: removable,
type: 'Application'
});
} else {
for (var entryPoint in entryPoints) {
if (entryPoints[entryPoint].icons) {
entries.push({
manifestURL: manifestURL,
entryPoint: entryPoint,
name: entryPoints[entryPoint].name,
removable: removable,
type: 'Application'
});
}
}
}
return entries;
},
getEntryManifest: function cm_getEntryManifest(manifestURL, entryPoint) {
if (!manifestURL || !this.installedApps[manifestURL]) {
return null;
}
var manifest = this.installedApps[manifestURL].manifest ||
this.installedApps[manifestURL].updateManifest;
if (entryPoint) {
var entry_manifest = manifest.entry_points[entryPoint];
return entry_manifest || null;
}
return manifest;
},
getIconBlob: function cm_getIconBlob(options) {
var manifestURL = options.manifestURL;
var entryPoint = options.entryPoint;
var preferredSize = options.preferredSize;
var that = this;
return new Promise(function(resolve, reject) {
var entry_manifest = that.getEntryManifest(manifestURL, entryPoint);
if (!entry_manifest) {
reject('No manifest');
}
var iconData = that._bestMatchingIcon(
that.installedApps[manifestURL], entry_manifest, preferredSize);
if (!iconData) {
reject('No url');
return;
}
that._loadFile({
url: iconData[0],
responseType: 'blob'
}).then(function onFulfill(blob) {
resolve([blob, iconData[1]]);
}, function onReject(error) {
reject('Error on loading blob of ' + manifestURL);
});
});
},
_getLocalizedName: function cm_getLocalizedName(manifestURL, lang) {
if (!manifestURL || !lang) {
return;
}
var manifest = this.getEntryManifest(manifestURL);
var locales = manifest.locales;
var localizedName = locales && locales[lang] &&
(locales[lang].short_name || locales[lang].name);
return localizedName || manifest.short_name || manifest.name;
},
// Resolve to one of the following forms:
// 1. {raw: 'localized name'}
// 2. {id: 'l10nid'}
// 3. undefined
resolveCardName: function cm_resolveCardName(card, lang) {
var name;
if (!card || !lang) {
return name;
}
// Priority is:
// 1. user given name
// 2. l10nId if any
// 3. localized application/deck name if it is an application/deck
if (card.name && card.name.raw) {
name = {
raw: card.name.raw
};
} else if (card.name && card.name.id) {
name = card.name;
} else if (card.nativeApp &&
(card instanceof Application || card instanceof Deck)) {
name = {
raw: this._getLocalizedName(card.nativeApp.manifestURL, lang)
};
}
return name;
},
getCardList: function cm_getCardList() {
var that = this;
return this._getPipedPromise('getCardList', function(resolve, reject) {
that._asyncSemaphore.wait(function() {
resolve(that._reloadCardList());
}, that);
}).then(function() {
return Promise.resolve(that._cardList);
});
},
getFilteredCardList: function cm_getFilteredCardList(filterName) {
return this.getCardList().then(function(allCards) {
var filteredCards = [];
if (filterName === 'all') {
filteredCards = allCards;
} else if (filterName) {
allCards.forEach(function(card) {
if (card.group === filterName) {
filteredCards.push(card);
}
});
}
return Promise.resolve(filteredCards);
});
},
// TODO: need to be protected by semaphore
// TODO: some comparison are based on card type. We may move these part to
// card class themselves.
// There are three types of query:
// 1. query by cardId
// 2. query by manifestURL and optionally launchURL
// 3. query by bookmark url
// 4. query by cardEntry (i.e. serialized card)
findCardFromCardList: function cm_findCardFromCardList(query) {
var found;
this._cardList.some(function(card) {
if (card instanceof Folder) {
// XXX: findCard() shares almost the same logic as
// findCardFromCardList. We should consolidate them. See
// http://bugzil.la/1156726
found = card.findCard(query);
if (found) {
return true;
}
}
if (card.cardId === query.cardId) {
found = card;
return true;
} else if (query.manifestURL && card.nativeApp &&
card.nativeApp.manifestURL === query.manifestURL) {
// if we specify launchURL in query, then we must compare
// launchURL first
if (query.launchURL) {
if (card.launchURL === query.launchURL) {
found = card;
return true;
}
} else {
found = card;
return true;
}
} else if (query.url) {
if (query.url === card.url) {
found = card;
return true;
}
} else if (query.cardEntry) {
// XXX: this could be bad at performance because we serialize card
// in every loop. We might need improvement on this query.
if (JSON.stringify(card.serialize()) ===
JSON.stringify(query.cardEntry)) {
found = card;
return true;
}
}
});
return found;
},
findContainingFolder: function cm_findContainingFolder(query) {
var folder;
this._cardList.some(function(card) {
if (card instanceof Folder) {
if (card.findCard(query)) {
folder = card;
}
}
});
return folder;
},
isPinned: function cm_isPinned(options) {
var that = this;
return this._getPipedPromise('isPinned', function(resolve, reject) {
that._asyncSemaphore.wait(function() {
resolve(that._reloadCardList());
}, that);
}).then(function() {
return Promise.resolve(!!that.findCardFromCardList(options));
});
},
handleEvent: function cr_handleEvent(evt) {
switch(evt.type) {
case 'install':
this._onAppInstall(evt);
break;
case 'uninstall':
this._onAppUninstall(evt);
break;
}
}
});
SharedUtils.addMixin(CardManager, PipedPromise);
exports.CardManager = CardManager;
}(window));

View file

@ -0,0 +1,167 @@
/* global evt, PipedPromise, SharedUtils */
(function(exports) {
'use strict';
var CardStore = function(name, mode, manifestURL) {
var that = this;
this._mode = mode || 'readwrite';
this._name = name;
if (manifestURL) {
this._manifestURL = manifestURL;
this._getStore();
} else {
this._isOwner = true;
navigator.mozApps.getSelf().onsuccess = function(evt) {
var app = evt.target.result;
that._manifestURL = app.manifestURL;
that._getStore();
};
}
};
CardStore.prototype = evt({
_dataStore: undefined,
_appRevisionId: undefined,
_manifestURL: undefined,
// Only two modes available: readonly and readwrite (default)
// 'readwrite' mode is for Smart-Home app only
_mode: 'readwrite',
isStarted: function cs_isStarted() {
return !!this._manifestURL && !!this._dataStore;
},
canWrite: function cs_canWrite() {
return this._mode === 'readwrite';
},
_onChange: function(evt) {
this.fire('change', evt);
},
_getStore: function cs_getStore() {
var that = this;
return this._getPipedPromise('_getStore', function(resolve, reject) {
if (that.isStarted()) {
resolve(that._dataStore);
return;
}
navigator.getDataStores(that._name).then(
function(stores) {
stores.forEach(function(store) {
if (store.owner === that._manifestURL) {
that._dataStore = store;
that._dataStore.addEventListener('change',
that._onChange.bind(that));
}
});
if (that._dataStore) {
resolve(that._dataStore);
} else {
reject();
}
});
});
},
getData: function cs_getData(id) {
var that = this;
return new Promise(function(resolve, reject) {
that._getStore().then(function onFulfill(store) {
if (store) {
store.get(id).then(resolve);
} else {
reject('no store available');
}
}, function onReject(reason) {
reject(reason);
});
});
},
saveData: function cs_saveData(id, data) {
var that = this;
return new Promise(function(resolve, reject) {
if (that.canWrite()) {
that._getStore().then(function onFulfill(store) {
if (store) {
store.put(data, id).then(resolve, function(error) {
reject(error);
});
} else {
reject('no store available');
}
}, function onReject(reason) {
reject(reason);
});
} else {
// resolve directly without actually writing anything,
// because we are in readonly mode
resolve();
}
});
},
iterateData: function cs_listData(cb) {
return new Promise((resolve, reject) => {
this._getStore().then(store => {
return store.sync();
// For a brand-new cursor, when calling next() continuously, the task
// sequence looks like:
// 1st call: operation 'clear'
// 2st call: operation 'add' with 1st entry
// 3rd call: operation 'add' with 2nd
// ...
// n+1st call: operation 'add' with nth data entry
// n+2nd call: operation 'done'
}).then(function iterateTask(cursor) {
cursor.next().then(task => {
if (task.operation == 'add') {
cb(task.data, task.id);
iterateTask(cursor);
} else if (task.operation == 'clear') {
iterateTask(cursor);
} else if (task.operation == 'done') {
cursor.close();
resolve();
} else {
iterateTask(cursor);
}
});
});
});
},
addData: function cs_addData(data, id) {
return this._getStore().then(store => {
return store.add(data, id);
});
},
removeData: function cs_removeData(id) {
return this._getStore().then(store => {
return store.remove(id);
});
},
getLength: function cs_getLength() {
return this._getStore().then(store => {
return store.getLength();
});
},
get isOwner() {
return this._isOwner;
}
});
SharedUtils.addMixin(CardStore, PipedPromise);
exports.CardStore = CardStore;
}(window));

View file

@ -0,0 +1,46 @@
/* global Card */
(function(exports) {
'use strict';
function AppBookmark(options) {
this.url = options.url;
this.name = options.name;
this.group = options.group;
this.thumbnail = options.thumbnail;
Card.prototype.constructor.call(this);
}
AppBookmark.deserialize = function ab_deserialize(cardEntry) {
var cardInstance;
if (cardEntry && cardEntry.type === 'AppBookmark') {
cardInstance = new AppBookmark({
name: cardEntry.name,
url: cardEntry.url,
group: cardEntry.group,
thumbnail: cardEntry.thumbnail
});
}
return cardInstance;
};
AppBookmark.prototype = Object.create(Card.prototype);
AppBookmark.prototype.constructor = AppBookmark;
AppBookmark.prototype.launch = function ab_launch(args) {
window.open(this.url, '_blank', 'remote=true,applike=true');
};
AppBookmark.prototype.serialize = function ab_serialize() {
return {
type: 'AppBookmark',
name: this.name,
url: this.url,
group: this.group,
thumbnail: this.thumbnail
};
};
exports.AppBookmark = AppBookmark;
}(window));

View file

@ -0,0 +1,86 @@
/* global Card */
(function(exports) {
'use strict';
var Application = function Application(options) {
this.nativeApp = options.nativeApp;
this.name = options.name;
this.cachedIconBlob = undefined;
this.thumbnail = options.thumbnail;
this.launchURL = options.launchURL;
this.group = options.group;
Card.prototype.constructor.call(this);
};
Application.deserialize = function app_deserialize(cardEntry, installedApps) {
var cardInstance;
if (cardEntry && installedApps && cardEntry.type === 'Application') {
cardInstance = new Application({
nativeApp: installedApps[cardEntry.manifestURL],
name: cardEntry.name,
thumbnail: cardEntry.thumbnail,
launchURL: cardEntry.launchURL,
group: cardEntry.group
});
}
return cardInstance;
};
Application.prototype = Object.create(Card.prototype);
Application.prototype.constructor = Application;
// expose getter of property of nativeApp
var exposedPropertyNames = ['manifest', 'updateManifest'];
exposedPropertyNames.forEach(function(propertyName) {
Object.defineProperty(Application.prototype, propertyName, {
get: function() {
return this.nativeApp && this.nativeApp[propertyName];
}
});
});
Application.prototype.serialize = function app_serialize() {
return {
manifestURL: this.nativeApp.manifestURL,
name: this.name,
type: 'Application',
thumbnail: this.thumbnail,
launchURL: this.launchURL,
group: this.group
};
};
Application.prototype.launch = function app_launch(args) {
if (this.nativeApp && this.nativeApp.launch && !this.launchURL) {
this.nativeApp.launch(args);
} else {
if (!Application._iacPort) {
console.error('no iacPort found, we cannot launch Application');
return;
}
Application._iacPort.postMessage({
'manifestURL': this.nativeApp.manifestURL,
'timestamp': (new Date()).getTime(),
'url': this.launchURL
});
}
};
window.addEventListener('load', function _retrieveIACPort() {
window.removeEventListener('load', _retrieveIACPort);
navigator.mozApps.getSelf().onsuccess = function(evt) {
var app = evt.target.result;
if (app) {
app.connect('customlaunchpath').then(function onAccepted(ports) {
Application._iacPort = ports[0];
});
}
};
});
exports.Application = Application;
}(window));

View file

@ -0,0 +1,40 @@
/* global evt */
(function(exports) {
'use strict';
var _counter = 0;
// Card is the base class for Application, Deck, and Folder
var Card = function Card() {
this.generateCardId();
};
Card.deserialize = function c_deserialize(cardEntry) {
// just to prevent undefined error
};
Card.prototype = evt({
get cardId() {
return this._id;
},
generateCardId: function c_generateCardId() {
// XXX: use constructor.name + name + incremental counter
// as cardId for now. Notice that cardId is only meaningful for
// Smart-Home app. Because only Smart-Home app has 'write' privilege.
var name = (this.nativeApp && this.nativeApp.manifest) ?
this.nativeApp.manifest.name : 'card';
this._id = this.constructor.name + '-' + name + '-' + (_counter);
_counter += 1;
return this._id;
},
serialize: function c_serialize() {
// just to prevent undefined error
},
constructor: Card
});
exports.Card = Card;
}(window));

View file

@ -0,0 +1,61 @@
/* global Card */
(function(exports) {
'use strict';
var Deck = function Deck(options) {
this.nativeApp = options.nativeApp;
this.name = options.name;
this.deckClass = options.deckClass;
this.group = options.group;
Card.prototype.constructor.call(this);
};
Deck.deserialize = function deck_deserialize(cardEntry, installedApps) {
var cardInstance;
if (cardEntry && installedApps && cardEntry.type === 'Deck') {
cardInstance = new Deck({
name: cardEntry.name,
nativeApp: cardEntry.manifestURL &&
installedApps[cardEntry.manifestURL],
deckClass: cardEntry.deckClass,
group: cardEntry.group
});
}
return cardInstance;
};
Deck.prototype = Object.create(Card.prototype);
Deck.prototype.constructor = Deck;
// expose getter of property of nativeApp
var exposedPropertyNames = ['manifest', 'updateManifest'];
exposedPropertyNames.forEach(function(propertyName) {
Object.defineProperty(Deck.prototype, propertyName, {
get: function() {
return this.nativeApp && this.nativeApp[propertyName];
}
});
});
Deck.prototype.launch = function deck_launch(args) {
if (this.nativeApp && this.nativeApp.launch) {
this.nativeApp.launch(args);
}
};
Deck.prototype.serialize = function deck_serialize() {
// A deck doesn't need background color because it is always full-sized
// icon. If not, it is an issue from visual's image.
return {
name: this.name,
deckClass: this.deckClass,
manifestURL: this.nativeApp && this.nativeApp.manifestURL,
type: 'Deck',
group: this.group
};
};
exports.Deck = Deck;
}(window));

View file

@ -0,0 +1,231 @@
/* global Card, uuid */
(function(exports) {
'use strict';
var Folder = function Folder(options) {
this._cardsInFolder = options._cardsInFolder || [];
this.name = options.name;
// folderId is used in cardStore as key
this.folderId = options.folderId || uuid.v4();
this._state = options.state || Folder.STATES.NORMAL;
Card.prototype.constructor.call(this);
};
Folder.STATES = Object.freeze({
// when folder is in DESERIALIZING state, it means we are still in the
// process of loading its content from datastore
'DESERIALIZING': 'DESERIALIZING',
'NORMAL': 'NORMAL',
// DIRTY state means the folder is out of sync with data store
'DIRTY': 'DIRTY',
// DETACHED state means the folder itself is not saved (detached)
// in card list
'DETACHED': 'DETACHED'
});
Folder.deserialize = function folder_deserialize(cardEntry) {
var cardInstance;
if (cardEntry && cardEntry.type === 'Folder') {
cardInstance = new Folder({
name: cardEntry.name,
folderId: cardEntry.folderId,
// The content of folder is saved in datastore under key of folderId
// thus we are not complete deserialize it yet, mark its state
// as 'DESERIALIZING'. Caller needs to put content of the folder
// back to its structure. Please refer to CardManager#_reloadCardList().
state: Folder.STATES.DESERIALIZING
});
}
return cardInstance;
};
Folder.prototype = Object.create(Card.prototype);
Folder.prototype.constructor = Folder;
Object.defineProperty(Folder.prototype, 'state', {
get: function() {
return this._state;
},
set: function(state) {
this._state = state;
this.fire('state-changed', this._state);
}
});
Folder.prototype.getCardList = function folder_getCardList() {
return this._cardsInFolder;
};
Folder.prototype.getFilteredCardList =
function folder_getFilteredCardList(filterName) {
if (typeof this._cardsInFolder !== 'object') {
return;
}
if (filterName === 'all') {
return this._cardsInFolder;
} else if (filterName) {
return this._cardsInFolder.filter(
elem => (elem.group === filterName));
}
};
Folder.prototype.isEmpty = function folder_isEmpty() {
return this._cardsInFolder.length === 0;
};
Folder.prototype.isNotEmpty = function folder_isNotEmpty() {
return this._cardsInFolder.length > 0;
};
Folder.prototype.isDeserializing = function folder_isDeserializing() {
return this._state === Folder.STATES.DESERIALIZING;
};
Folder.prototype.isDetached = function folder_isDetached() {
return this._state === Folder.STATES.DETACHED;
};
Folder.prototype.loadCardsInFolder = function folder_loadCardsInFolder(opts) {
var that = this;
if (opts.from === 'config') {
// load content of folder from config file (e.g. in 'cardEntry' form)
this._cardsInFolder =
opts.cardEntry._cardsInFolder.map(opts.deserializer);
this.state = Folder.STATES.NORMAL;
} else if (opts.from === 'datastore') {
// load content of folder from data store
opts.datastore.getData(this.folderId).then(function(innerCardList) {
innerCardList.forEach(function(innerCardEntry) {
that._cardsInFolder.push(opts.deserializer(innerCardEntry));
});
that.state = Folder.STATES.NORMAL;
});
}
};
// get index of card in folder
Folder.prototype._indexOfCard = function folder_indexOfCard(query) {
return this._cardsInFolder.indexOf(this.findCard(query));
};
// XXX: this method shares almost the same logic as
// findCardFromCardList in CardManager. We should consolidate them. See
// http://bugzil.la/1156726
// There are three types of query:
// 1. query by cardId
// 2. query by manifestURL and optionally launchURL
// 3. query by bookmark url
// 4. query by cardEntry (i.e. serialized card)
Folder.prototype.findCard = function folder_findCard(query) {
var found;
this._cardsInFolder.some(function(card, index) {
if (card.cardId === query.cardId) {
found = card;
return true;
} else if (query.manifestURL && card.nativeApp &&
card.nativeApp.manifestURL === query.manifestURL) {
// if we specify launchURL in query, then we must compare
// launchURL first
if (query.launchURL) {
if (card.launchURL === query.launchURL) {
found = card;
return true;
}
} else {
found = card;
return true;
}
} else if (query.url) {
if (query.url === card.url) {
found = card;
}
return true;
} else if (query.cardEntry) {
// XXX: this could be bad at performance because we serialize card
// in every loop. We might need improvement on this query.
if (JSON.stringify(card.serialize()) ===
JSON.stringify(query.cardEntry)) {
found = card;
return true;
}
}
});
return found;
};
Folder.prototype._isInFolder = function folder_isInFolder(card) {
if (card) {
return this._indexOfCard(card) > -1;
} else {
return false;
}
};
Folder.prototype._setDirty = function folder_setDirty() {
if (this._state !== Folder.STATES.DETACHED) {
this._state = Folder.STATES.DIRTY;
this.fire('state-changed', this._state);
}
this.fire('folder-changed', this);
};
Folder.prototype.addCard = function folder_addCard(card, index) {
// We don't support folder in folder
if (!this._isInFolder(card) && !(card instanceof Folder)) {
if (typeof index !== 'number') {
index = this._cardsInFolder.length;
}
this._cardsInFolder.splice(index, 0, card);
this._setDirty();
this.fire('card-inserted', card, index);
}
};
Folder.prototype.removeCard = function folder_removeCard(card) {
var index = this._indexOfCard(card);
if (index > -1) {
this._cardsInFolder.splice(index, 1);
this._setDirty();
this.fire('card-removed', [index]);
}
};
Folder.prototype.updateCard = function folder_updateCard(card, index) {
// The card instance is directly reference to card in '_cardsInFolder'
// So don't bother to update it again. But we DID need 'folder-changed'
// event to notify CardManager to write changes to data store.
this._setDirty();
this.fire('card-updated', card, index);
};
Folder.prototype.swapCard = function folder_swapCard(card1, card2) {
var index1 = (typeof card1 === 'number') ?
index1 = card1 : this._indexOfCard(card1);
var index2 = (typeof card2 === 'number') ?
index2 = card2 : this._indexOfCard(card2);
var temp = this._cardsInFolder[index1];
this._cardsInFolder[index1] = this._cardsInFolder[index2];
this._cardsInFolder[index2] = temp;
this._setDirty();
this.fire('card-swapped',
this._cardsInFolder[index1], this._cardsInFolder[index2],
index1, index2);
};
Folder.prototype.launch = function folder_launch() {
// fire launch event to inform exterior module
this.fire('launch', this);
};
Folder.prototype.serialize = function folder_serialize() {
return {
name: this.name,
folderId: this.folderId,
type: 'Folder'
};
};
exports.Folder = Folder;
}(window));

View file

@ -0,0 +1,244 @@
/* global evt */
'use strict';
(function(exports) {
function ChannelManager(tunerId) {
this.isReady = false;
this.currentTuners = {};
this.playingState = {
tunerId: tunerId || null,
sourceType: null,
channelNumber: null
};
}
var proto = Object.create(new evt());
// Current hash
Object.defineProperty(proto, 'currentHash', {
get: function() {
var state = this.playingState;
var currentHash = '#';
currentHash += state.tunerId;
currentHash += (',' + state.sourceType);
currentHash += (',' + state.channelNumber);
return currentHash;
}
});
proto.getTuner = function cm_getTuner(tunerId) {
tunerId = tunerId || this.playingState.tunerId;
return this.currentTuners[tunerId];
};
proto.getSource = function cm_getSource(sourceType) {
sourceType = sourceType || this.playingState.sourceType;
var tuner = this.getTuner();
if (!tuner || !tuner.sources) {
return null;
}
return tuner.sources[sourceType];
};
proto.getChannel = function cm_getChannel(channelNumber) {
channelNumber = channelNumber || this.playingState.channelNumber;
var source = this.getSource();
if (!source || !source.channelIndexHash) {
return null;
}
var index = source.channelIndexHash[channelNumber];
return source.channels[index];
};
/**
* Fetch tuner, source and channel settings from URL hash.
*/
proto.fetchSettingFromHash = function cm_fetchSettingFromHash(hash) {
if(hash) {
hash = hash.substring(1).split(',');
this.playingState = {
tunerId: hash[0],
sourceType: hash[1],
channelNumber: hash[2]
};
}
};
/**
* Retrieve all the currently available TV tuners.
*/
proto.scanTuners = function cm_scanTuners() {
navigator.tv.getTuners().then(function onsuccess(tuners) {
this.currentTuners = {};
tuners.forEach(function initTuner(tuner) {
this.currentTuners[tuner.id] = {
tuner: tuner,
sources: {}
};
}.bind(this));
if (!this.playingState.tunerId) {
this.playingState.tunerId = tuners[0].id;
}
if (!this.getTuner()) {
this.fire('error');
return;
}
this.scanSources();
}.bind(this));
};
/**
* Retrieve all the currently available TV sources from current TV tuner.
*/
proto.scanSources = function cm_scanSources() {
var tunerItem = this.getTuner();
tunerItem.tuner.getSources().then(function getSources(sources) {
tunerItem.sources = {};
if (sources.length === 0) {
console.error('Error, no source found!');
return;
}
sources.forEach(function initSource(source) {
tunerItem.sources[source.type] = {
source: source,
channels: [],
channelIndexHash: {}
};
}.bind(this));
if (!this.playingState.sourceType) {
if (tunerItem.tuner.currentSource) {
this.playingState.sourceType = tunerItem.tuner.currentSource.type;
} else {
this.playingState.sourceType = sources[0].type;
}
}
if (!this.getSource()) {
this.fire('error');
return;
}
this.scanChannels();
}.bind(this));
};
/**
* Retrieve all the currently available TV channels from current TV source.
* We have to scan the channels before calling getChannels.
*/
proto.scanChannels = function cm_scanChannels() {
var sourceItem = this.getSource();
sourceItem.source.getChannels().then(function onsuccess(channels) {
sourceItem.channels = [];
sourceItem.channelIndexHash = {};
if (channels.length === 0) {
console.error('Error, no channel found!');
return;
}
var i;
for (i = 0; i < channels.length; i++) {
sourceItem.channelIndexHash[channels[i].number] = i;
sourceItem.channels[i] = {
channel: channels[i],
programs: [],
};
}
if (!this.playingState.channelNumber) {
if (sourceItem.source.currentChannel) {
this.playingState.channelNumber =
sourceItem.source.currentChannel.number;
} else {
this.playingState.channelNumber = channels[0].number;
}
}
if (!this.getChannel()) {
this.fire('error');
return;
}
this.isReady = true;
this.fire('scanned');
}.bind(this), function onerror(error) {
console.error(error);
});
};
proto.setPlayingSource = function cm_setPlayingSource(callback) {
var tunerItem = this.getTuner();
if (tunerItem.tuner.currentSource === this.getSource().source) {
this.setPlayingChannel(callback);
return;
}
tunerItem.tuner.setCurrentSource(this.playingState.sourceType)
.then(function() {
this.setPlayingChannel(callback);
}.bind(this), function() {
console.error('Source not found');
});
};
proto.setPlayingChannel = function cm_setPlayingChannel(callback) {
if (this.getSource().source.isScanning) {
if (callback) {
callback();
}
console.error('Source is still scanning.');
return;
}
this.getSource().source.setCurrentChannel(this.playingState.channelNumber)
.then(function() {
if (callback) {
callback();
}
}.bind(this), function() {
console.error('Channel not found');
});
};
proto.switchChannel = function cm_switchChannel(direction) {
if (!this.getChannel() || !this.isReady) {
return false;
}
var channelList = this.getSource().channels;
var channelIndex = this.getSource()
.channelIndexHash[this.playingState.channelNumber];
if (channelIndex!== 0 && !channelIndex) {
return false;
}
switch(direction) {
case ChannelManager.KEY_UP:
channelIndex++;
break;
case ChannelManager.KEY_DOWN:
channelIndex--;
break;
default:
return;
}
channelIndex = (channelIndex < 0) ? channelList.length - 1 : channelIndex;
channelIndex = (channelIndex > channelList.length - 1) ? 0 : channelIndex;
this.playingState.channelNumber = channelList[channelIndex].channel.number;
return true;
};
ChannelManager.KEY_UP = 'up';
ChannelManager.KEY_DOWN = 'down';
ChannelManager.prototype = proto;
exports.ChannelManager = ChannelManager;
})(window);

View file

@ -0,0 +1,87 @@
/**
Copyright 2012, Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
(function(exports) {
/**
* Creates an object used for refreshing the clock UI element. Handles all
* related timer manipulation (start/stop/cancel).
* @class Clock
*/
function Clock() {
/**
* One-shot timer used to refresh the clock at a minute's turn
* @memberOf Clock
*/
this.timeoutID = null;
/**
* Timer used to refresh the clock every minute
* @memberOf Clock
*/
this.timerID = null;
/**
* Start the timer used to refresh the clock, will call the specified
* callback at every timer tick to refresh the UI. The callback used to
* refresh the UI will also be called immediately to ensure the UI is
* consistent.
*
* @param {Function} refresh Function used to refresh the UI at every timer
* tick, should accept a date object as its only argument.
* @memberOf Clock
*/
this.start = function cl_start(refresh) {
var date = new Date();
var self = this;
refresh(date);
if (this.timeoutID == null) {
this.timeoutID = window.setTimeout(function cl_setClockInterval() {
refresh(new Date());
if (self.timerID == null) {
self.timerID = window.setInterval(function cl_clockInterval() {
refresh(new Date());
}, 60000);
}
}, (60 - date.getSeconds()) * 1000);
}
};
/**
* Stops the timer used to refresh the clock
* @memberOf Clock
*/
this.stop = function cl_stop() {
if (this.timeoutID != null) {
window.clearTimeout(this.timeoutID);
this.timeoutID = null;
}
if (this.timerID != null) {
window.clearInterval(this.timerID);
this.timerID = null;
}
};
}
/** @exports Clock */
exports.Clock = Clock;
})(window);

View file

@ -0,0 +1,58 @@
'use strict';
/* global evt */
(function(exports) {
/**
* This class monitors incoming messages from [IAC](http://mzl.la/1TKR6zw) and
* fires event corresponding with incoming messages. ConnectionManager fires
* only `unpin` event for now.
*
* @class ConnectionManager
* @fires ConnectionManager#unpin
*/
var ConnectionManager = function() {};
ConnectionManager.prototype = evt({
_channels: undefined,
init: function cm_init(channels) {
var that = this;
this._channels = channels;
this._channels.forEach(function(channel) {
window.addEventListener(channel, that);
});
},
uninit: function cm_uninit() {
var that = this;
this._channels.forEach(function(channel) {
window.removeEventListener(channel, that);
});
},
// all messages should contain `type` and `data`, like this:
// {
// type: 'unpin',
// data: {
// name: 'Music',
// manifestURL: 'app://music.gaiamobile.org/manifest.webapp',
// launchURL: 'app://music.gaiamobile.org/'
// }
// }
handleEvent: function cm_handleEvent(evt) {
var message = evt.detail;
if (message && message.type) {
/**
* @event ConnectionManager#unpin
* @type {Object}
* @property {String} name - name of unpinned app
* @property {String} manifestURL - manifestURL of unpinned app
* @property {String} launchURL - launchURL of unpinned app
*/
this.fire(message.type, message.data);
}
}
});
exports.ConnectionManager = ConnectionManager;
}(window));

View file

@ -0,0 +1,248 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* global SimpleKeyNavigation, KeyEvent */
/* exports FTEWizard */
'use strict';
(function(exports) {
const MAX_TRANSITIONEND_LATENCY = 4000;
function FTEWizard(name) {
this._name = name;
this._running = false;
this._launched = !!localStorage.getItem(name + '.fteskip');
}
Object.defineProperty(FTEWizard.prototype, 'running', {
get: function() {
return this._running;
}
});
Object.defineProperty(FTEWizard.prototype, 'launched', {
get: function() {
return this._launched;
}
});
FTEWizard.prototype.init = function fw_init(options) {
if (this._launched) {
if (options.container) {
options.container.style.display = 'none';
}
options.onfinish && options.onfinish();
return;
}
this._container = options.container;
this._buttonsClass = options.buttonsClass || 'fte-button';
this._finishButtonClass = options.finishButtonClass || 'fte-finish';
this._skipButtonClass = options.skipButtonClass || 'fte-skip';
this._pageClass = options.pageClass || 'fte-page';
// For options.pageHiddenClass, the suggested styles needed are:
// opacity: 0;
// pointer-events: none;
// transition: opacity <any duration> linear <any duration>;
// (The delay duration should be the same as transition duration.)
// And the page transition should be:
// transition: opacity <any duration> linear;
this._pageHiddenClass = options.pageHiddenClass || 'hidden';
this._launchEveryTime = options.launchEveryTime || false;
this.propagateKeyEvent = options.propagateKeyEvent || false;
this._simpleKeyNavigation = new SimpleKeyNavigation();
this._onfinish = options.onfinish;
this._onskip = options.onskip;
this._pages =
Array.from(this._container.getElementsByClassName(this._pageClass));
this._nextButton =
this._container.getElementsByClassName(this._nextButtonClass)[0];
this._prevButton =
this._container.getElementsByClassName(this._prevButtonClass)[0];
this._finishButton =
this._container.getElementsByClassName(this._finishButtonClass)[0];
this._skipButton =
this._container.getElementsByClassName(this._skipButtonClass)[0];
this._currentPage = 0;
// The element list will be updated in this._updateNavigation.
this._simpleKeyNavigation.start([],
SimpleKeyNavigation.DIRECTION.HORIZONTAL);
this._container.addEventListener('keydown', this);
this._container.addEventListener('keyup', this);
this._container.addEventListener('click', this);
this._pages.forEach((page, idx) => this._hide(idx));
this._show(this._currentPage);
this._updateNavigation();
this._running = true;
};
FTEWizard.prototype.uninit = function fw_uninit() {
if (this._launched) {
return;
}
this._container.removeEventListener('keydown', this);
this._container.removeEventListener('keyup', this);
this._container.removeEventListener('click', this);
this._simpleKeyNavigation.stop();
this._simpleKeyNavigation = null;
this._running = false;
};
FTEWizard.prototype.goNext = function fw_next() {
if (this._currentPage >= this._pages.length - 1) {
// If the page number exceeds available pages, we just end the wizard.
this.finish();
return;
}
this._hide(this._currentPage);
attachTransitionEnd(
this._pages[++this._currentPage], this._updateNavigation.bind(this)
);
this._show(this._currentPage);
};
FTEWizard.prototype.goPrev = function fw_prev() {
if (this._currentPage <= 0) {
return;
}
this._hide(this._currentPage);
attachTransitionEnd(
this._pages[--this._currentPage], this._updateNavigation.bind(this)
);
this._show(this._currentPage);
};
FTEWizard.prototype.finish = function fw_finish() {
this._container.classList.add('finish');
attachTransitionEnd(this._container, () => {
this._hide(this._currentPage);
this._container.style.display = 'none';
this.uninit();
this._launched = true;
if (!this._launchEveryTime) {
localStorage.setItem(this._name + '.fteskip', true);
}
this._onfinish && this._onfinish();
});
};
/**
* Skip applies the same CSS class as finish to close the FTE and
* triggers the onskip callback.
*/
FTEWizard.prototype.skip = function fw_skip() {
this._container.classList.add('finish');
attachTransitionEnd(this._container, () => {
this._hide(this._currentPage);
this._container.style.display = 'none';
this.uninit();
this._launched = true;
localStorage.setItem(this._name + '.fteskip', true);
this._onskip && this._onskip();
});
};
FTEWizard.prototype.focus = function fw_focus() {
this._simpleKeyNavigation.focus();
};
FTEWizard.prototype._updateNavigation = function fw_updateNavigation() {
var list = Array.from(this._pages[this._currentPage].
getElementsByClassName(this._buttonsClass));
this._simpleKeyNavigation.updateList(list);
// Default focus on the finish button
if (list.indexOf(this._finishButton) !== -1) {
this._simpleKeyNavigation.focusOn(this._finishButton);
}
};
FTEWizard.prototype._hide = function fw_hide(pageIdx) {
this._pages[pageIdx].classList.add(this._pageHiddenClass);
};
FTEWizard.prototype._show = function fw_hide(pageIdx) {
this._pages[pageIdx].classList.remove(this._pageHiddenClass);
};
FTEWizard.prototype.handleEvent = function fw_handleEvent(evt) {
switch(evt.type) {
case 'keyup':
if (evt.keyCode === KeyEvent.DOM_VK_RETURN) {
switch (evt.target.dataset.behavior) {
case 'next':
this.goNext();
break;
case 'prev':
this.goPrev();
break;
case 'finish':
this.finish();
break;
case 'skip':
this.skip();
break;
}
}
// By default we stop propagating keyevent such that the main logic of
// app is not triggered while FTE is working.
!this.propagateKeyEvent && evt.stopPropagation();
break;
case 'keydown':
case 'keypress':
// also stop propagating keydown and keypress since arrow keys are
// triggered by keydown in smart-screen apps.
!this.propagateKeyEvent && evt.stopPropagation();
break;
case 'click':
// Do forced focus again on mouse click to prevent focus lost problem
// when running on devices like PC and phone.
this.focus();
}
};
function attachTransitionEnd(elem, cb, options) {
var latency = (options && options.maxLatency) ||
MAX_TRANSITIONEND_LATENCY;
elem.addEventListener('transitionend', destruct);
var timer = setTimeout(destruct, latency);
function destruct(evt) {
if(evt && evt.target != elem) {
return;
}
elem.removeEventListener('transitionend', destruct);
clearTimeout(timer);
cb(evt);
}
}
exports.FTEWizard = FTEWizard;
}(window));

View file

@ -0,0 +1,54 @@
/* global GestureDetector, KeyEvent */
'use strict';
(function(exports) {
function initGesture() {
function fireKeyEvent(keyCode, key) {
var eventObj = document.createEvent('Events');
eventObj.initEvent('keydown', true, true);
eventObj.key = key;
eventObj.keyCode = keyCode;
eventObj.which = keyCode;
document.activeElement.dispatchEvent(eventObj);
eventObj = document.createEvent('Events');
eventObj.initEvent('keypress', true, true);
eventObj.key = key;
eventObj.keyCode = keyCode;
eventObj.which = keyCode;
document.activeElement.dispatchEvent(eventObj);
eventObj = document.createEvent('Events');
eventObj.initEvent('keyup', true, true);
eventObj.key = key;
eventObj.keyCode = keyCode;
eventObj.which = keyCode;
document.activeElement.dispatchEvent(eventObj);
}
new GestureDetector(document.body).startDetecting();
document.addEventListener('swipe', function(evt) {
var direction = evt.detail.direction;
var keyDefine = {
'up': [KeyEvent.DOM_VK_UP, 'Up'],
'right': [KeyEvent.DOM_VK_RIGHT, 'Right'],
'down': [KeyEvent.DOM_VK_DOWN, 'Down'],
'left': [KeyEvent.DOM_VK_LEFT, 'Left']
};
fireKeyEvent(keyDefine[direction][0], keyDefine[direction][1]);
});
document.addEventListener('dbltap', function(evt) {
fireKeyEvent(KeyEvent.DOM_VK_RETURN, 'Enter');
});
document.addEventListener('transform', function(evt) {
fireKeyEvent(KeyEvent.DOM_VK_ESCAPE, 'Esc');
});
}
/* exports.initFakeAppEvent = initFakeAppEvent; */
exports.initGesture = initGesture;
})(window);
window.initGesture();

View file

@ -0,0 +1,101 @@
/* global KeyEvent, evt */
(function(exports) {
'use strict';
// KeyNavigationAdapter files event with '-keyup' as postfix. All behaviors
// which no need to have multple events while holding the key should use
// keyup.
// If we choose to listen to mozbrowser key events, before- and after- prefix
// will be attached on filed events.
function KeyNavigationAdapter() {
}
KeyNavigationAdapter.prototype = evt({
KEY_EVENTS: Object.freeze([
'keydown',
'keyup'
]),
MOZ_BROWSER_KEY_EVENTS: [
'mozbrowserbeforekeydown',
'mozbrowserbeforekeyup',
'mozbrowserafterkeydown',
'mozbrowserafterkeyup'
],
init: function kna_init(targetElement, options) {
this._targetElement = targetElement || window;
options = options || {};
// Pick required event listeners and add them
this._evtNames = [].concat(this.KEY_EVENTS);
if (options.useMozBrowserKeyEvents) {
this._evtNames = this._evtNames.concat(this.MOZ_BROWSER_KEY_EVENTS);
}
this._evtNames.forEach(
name => this._targetElement.addEventListener(name, this));
},
uninit: function kna_uninit() {
this._evtNames.foreach(
name => this._targetElement.removeEventListener(name, this));
},
handleEvent: function kna_handleEvent(evt) {
if(this._evtNames.indexOf(evt.type) !== -1) {
this.handleKeyEvent(this.convertKeyToString(evt.keyCode), evt.type);
}
},
handleKeyEvent: function kna_handleKeyEvent(key, eventType) {
// XXX : It's better to use KeyEvent.Key and use "ArrowUp", "ArrowDown",
// "ArrowLeft", "ArrowRight" for switching after Gecko synced with W3C
// KeyboardEvent.Key standard. Here we still use KeyCode and customized
// string of "up", "down", "left", "right" for the moment.
var evtPostfix = 'keyup' === eventType ? '-keyup' : '';
var prefixMatch = /mozbrowser(before|after)/.exec(eventType);
var evtPrefix = prefixMatch ? prefixMatch[1] + '-' : '';
switch (key) {
case 'up':
case 'down':
case 'left':
case 'right':
this.fire(evtPrefix + 'move' + evtPostfix, key);
break;
case 'enter':
this.fire(evtPrefix + 'enter' + evtPostfix);
break;
case 'back':
this.fire(evtPrefix + 'back' + evtPostfix);
break;
case 'esc':
this.fire(evtPrefix + 'esc' + evtPostfix);
break;
}
},
convertKeyToString: function kna_convertKeyToString(keyCode) {
switch (keyCode) {
case KeyEvent.DOM_VK_UP:
return 'up';
case KeyEvent.DOM_VK_RIGHT:
return 'right';
case KeyEvent.DOM_VK_DOWN:
return 'down';
case KeyEvent.DOM_VK_LEFT:
return 'left';
case KeyEvent.DOM_VK_RETURN:
return 'enter';
case KeyEvent.DOM_VK_ESCAPE:
return 'esc';
case KeyEvent.DOM_VK_BACK_SPACE:
return 'back';
default:// we don't consume other keys.
return null;
}
}
});
exports.KeyNavigationAdapter = KeyNavigationAdapter;
}(window));

View file

@ -0,0 +1,44 @@
/* global evt, CardManager */
'use strict';
(function(exports) {
function PinCard(options) {
options = options || {};
this.pinnedChannels = {};
this.origin = options.origin || '';
this.manifestURL = options.manifestURL || '';
this.cardManager = new CardManager();
this.cardManager.init('readonly')
.then(this.updatePinnedChannels.bind(this));
// Update pinnedChannels when card-list changed
this.cardManager.on(
'cardlist-changed', this.updatePinnedChannels.bind(this));
}
var proto = Object.create(new evt());
/**
* Retrieve latest TV card list from card manager and update pin button
* information.
*/
proto.updatePinnedChannels = function pc_updatePinnedChannels() {
this.pinnedChannels = {};
this.cardManager.getCardList().then(function(cardList) {
cardList.forEach(function(card) {
var url = card.launchURL;
if (url && url.match(new RegExp(this.origin))) {
var channelHash = url.substring(url.indexOf('#'));
this.pinnedChannels[channelHash] = true;
return true;
}
return false;
}.bind(this));
this.fire('update-pin-button');
}.bind(this));
};
PinCard.prototype = proto;
exports.PinCard = PinCard;
})(window);

View file

@ -0,0 +1,66 @@
(function(exports) {
'use strict';
/**
* The PipedPromise is a 'trait' or 'mixin' for other module to mix it in.
* Under certain situation, when we implement method that returns a Promise
* to callee, we don't want the method to be reentrant and instantiate another
* promise. We would like the request to be 'piped' in the same promise.
* This is the case where PipedPromise comes to our resque.
*
* @example
* SomeModule.prototype = {
* ...
* willReturnPromise: function willReturnPromise() {
* return this._getPipedPromise('willReturnPromise',
* function(resolve, reject) {
* ...
* });
* },
* ...
* };
* addMixin(SomeModule, PipedPromise);
*
*/
var PipedPromise = {
_removePipedPromise: function pp_removePipedPromise(key) {
if (!this._piped_promises) {
this._piped_promises = {};
}
this._piped_promises[key] = undefined;
},
/**
* Get Promise object of specific key. It will generate a new Promise
* if Promise of the key is not existed yet.
*
* @param {String} key - key of the Promise
* @param {Function} executor - the executor once the Promise resolved
* or rejected
* @returns {Promise} - A Promise
*
*/
_getPipedPromise: function pp_getOrCreatePromise(key, executor) {
if (!this._piped_promises) {
this._piped_promises = {};
}
var that = this;
var promise = this._piped_promises[key];
if (!promise) {
promise = new Promise(executor);
Promise.all([promise]).then(function onFulfill() {
that._removePipedPromise(key);
}, function onReject() {
that._removePipedPromise(key);
});
that._piped_promises[key] = promise;
}
return promise;
}
};
exports.PipedPromise = PipedPromise;
}(window));

View file

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

View file

@ -0,0 +1,118 @@
'use strict';
(function(exports) {
function SelectionBorder(options) {
if (!options) {
throw new Error('SelectionBorder requires an options object.');
}
this.multiple = options.multiple;
this.forground = options.forground;
this.container = options.container;
this.selectedItems = [];
this.borders = [];
}
SelectionBorder.MAX_SPARE_BORDERS = 5;
SelectionBorder.prototype.select = function sb_select(dom, rect) {
if (!dom) {
return;
}
if (!this.multiple && this.selectedItems.length > 0) {
this.deselectAll();
}
var border = this.requestBorder();
var pos = rect ? rect : dom.getBoundingClientRect();
var radius = getComputedStyle(dom).borderBottomLeftRadius;
border.style.left = pos.left + 'px';
border.style.top = pos.top + 'px';
border.style.width = pos.width + 'px';
border.style.height = pos.height + 'px';
border.style.borderRadius = radius;
this.selectedItems.push({ dom: dom, border: border });
};
SelectionBorder.prototype.selectRect = function sb_selectRect(rect) {
if (!rect) {
return;
}
if (!this.multiple && this.selectedItems.length > 0) {
this.deselectAll();
}
var border = this.requestBorder();
border.style.left = rect.x + 'px';
border.style.top = rect.y + 'px';
border.style.width = rect.w + 'px';
border.style.height = rect.h + 'px';
this.selectedItems.push({ rect: rect, border: border });
};
SelectionBorder.prototype.deselect = function sb_deselect(dom) {
for (var i = 0; i < this.selectedItems.length; i++) {
if (this.selectedItems[i].dom === dom) {
this.selectedItems[i].border.hidden = true;
this.borders.push(this.selectedItems[i].border);
this.selectedItems.splice(i, 1);
break;
}
}
};
SelectionBorder.prototype.deselectRect = function sb_deselectRect(rect) {
for (var i = 0; i < this.selectedItems.length; i++) {
if (this.selectedItems[i].rect.x === rect.x &&
this.selectedItems[i].rect.y === rect.y &&
this.selectedItems[i].rect.w === rect.w &&
this.selectedItems[i].rect.h === rect.h) {
this.releaseBorder(this.selectedItems[i].border);
this.selectedItems.splice(i, 1);
break;
}
}
};
SelectionBorder.prototype.deselectAll = function sb_deselectAll() {
for (var i = 0; i < this.selectedItems.length; i++) {
this.releaseBorder(this.selectedItems[i].border);
}
this.selectedItems = [];
};
SelectionBorder.prototype.releaseBorder = function sb_releaseBorder(border) {
border.hidden = true;
if (this.borders.length < SelectionBorder.MAX_SPARE_BORDERS) {
this.borders.push(border);
} else {
this.container.removeChild(border);
}
};
SelectionBorder.prototype.requestBorder = function sb_requestBorder() {
var border = this.borders.pop();
if (!border) {
border = document.createElement('div');
border.classList.add('selection-border');
if (this.forground) {
this.container.appendChild(border);
} else {
// we assume the container is the offset parent of dom.
// We don't have this kind of assumption when we move it to system app.
this.container.insertBefore(border, this.container.firstChild);
}
} else {
border.hidden = false;
}
return border;
};
exports.SelectionBorder = SelectionBorder;
})(window);

View file

@ -0,0 +1,154 @@
/**
* SettingsCache is a singleton that caches mozSettings values for fast
* access.
*
* @module SettingsCache
*/
(function() {
'use strict';
var _settings = window.navigator.mozSettings;
// Cache of all current settings values. There's some large stuff
// in here, but not much useful can be done with the settings app
// without these, so we keep this around most of the time.
var _settingsCache = null;
// True when a request has already been made to fill the settings
// cache. When this is true, no further get("*") requests should be
// made; instead, pending callbacks should be added to
// _pendingSettingsCallbacks.
var _settingsCacheRequestSent = null;
// There can be race conditions in which we need settings values,
// but haven't filled the cache yet. This array tracks those
// listeners.
var _pendingSettingsCallbacks = [];
var _callbacks = [];
var _keyCallbacks = {};
var _onSettingsChange = function sc_onSettingsChange(event) {
var key = event.settingName;
var value = event.settingValue;
// Always update the cache if it's present, even if the DOM
// isn't loaded yet.
if (_settingsCache) {
_settingsCache[key] = value;
}
_callbacks.forEach(function(callback) {
callback(event);
});
if (_keyCallbacks[key]) {
_keyCallbacks[key].forEach(function(callback) {
callback(value);
});
}
};
if (_settings) {
_settings.onsettingchange = _onSettingsChange;
}
/**
* Event reporting that a setting value is changed.
*
* @event module:SettingsCache#settingsChange
* @property {MozSettingsEvent} event
*/
var SettingsCache = {
init: function sc_init() {
// Make a request for settings to warm the cache, since we need it
// very soon in startup after the DOM is available.
this.getSettings();
},
// the reset function is for unit tests
reset: function sc_reset() {
_settings = window.navigator.mozSettings;
if (_settings) {
_settings.onsettingchange = _onSettingsChange;
}
_settingsCache = null;
_settingsCacheRequestSent = null;
_pendingSettingsCallbacks = [];
_callbacks = [];
},
get cache() {
return _settingsCache;
},
/**
* Where callback is a function to be called with a request object for a
* successful fetch of settings values, when those values are ready.
*
* @alias module:SettingsCache#getSettings
* @param {Function} callback
*/
getSettings: function sc_getSettings(callback) {
if (!_settings) {
return;
}
if (_settingsCache && callback) {
// Fast-path that we hope to always hit: our settings cache is
// already available, so invoke the callback now.
callback(_settingsCache);
return;
}
if (!_settingsCacheRequestSent && !_settingsCache) {
_settingsCacheRequestSent = true;
var lock = _settings.createLock();
var request = lock.get('*');
request.onsuccess = function(e) {
var result = request.result;
var cachedResult = {};
for (var attr in result) {
cachedResult[attr] = result[attr];
}
_settingsCache = cachedResult;
var cbk;
while ((cbk = _pendingSettingsCallbacks.pop())) {
cbk(result);
}
};
}
if (callback) {
_pendingSettingsCallbacks.push(callback);
}
},
observe: function sc_observe(name, defaultValue, callback) {
if (!callback) {
return;
}
this.getSettings(function(cache) {
if (callback) {
callback((typeof cache[name]) === 'undefined' ?
defaultValue : cache[name]);
}
});
if (!_keyCallbacks[name]) {
_keyCallbacks[name] = [];
}
_keyCallbacks[name].push(function(v) {
callback((typeof v) === 'undefined' ? defaultValue : v);
});
},
get: function sc_get(name, callback) {
this.getSettings(function(cache) {
callback(cache[name]);
});
}
};
SettingsCache.init();
window.SettingsCache = SettingsCache;
})();

View file

@ -0,0 +1,136 @@
/*
* global asyncStorage
*/
(function(exports) {
'use strict';
exports.SharedUtils = {
nodeListToArray: function su_nodeListToArray(obj) {
return [].map.call(obj, function(element) {
return element;
});
},
addMixin: function su_addMixin(obj, mixin) {
for (var prop in mixin) {
if (mixin.hasOwnProperty(prop)) {
if (!obj.prototype.hasOwnProperty(prop)) {
obj.prototype[prop] = mixin[prop];
}
}
}
},
// Because the scoped css cannot be override by outer css, we have to create
// a cloned function without scope from shared/component_utils.js
injectComponentStyle: function su_injectComponentStyle(self, baseUrl) {
var style = document.createElement('style');
var url = baseUrl + 'style.css';
style.innerHTML = '@import url(' + url + ');';
self.appendChild(style);
self.style.visibility = 'hidden';
// Wait for the stylesheet to load before injecting
// it into the shadow-dom. This is to work around
// bug 1003294, let's review once landed.
style.addEventListener('load', function() {
// Put a clone of the stylesheet into the shadow-dom.
// We have to use two <style> nodes, to work around
// the lack of `:host` (bug 992245) and `:content`
// (bug 992249) selectors. Once we have those we
// can do all our styling from a single style-sheet
// within the shadow-dom.
if (self.shadowRoot) {
self.shadowRoot.appendChild(style.cloneNode(true));
}
self.style.visibility = '';
});
},
/**
* Read color code from the specified image blob. Please note that the blob
* should be downloaded from System XHR or within the same domain.
* Otherwise, this function returns SecurityError
*
* @param {Blob} blob the image blob.
* @param {float} x read color from position x (0~1). If the value is 0.5,
it reads the center.
* @param {float} y read color from position y (0~1). If the value is 0.5,
it reads the center.
* @param {Function} callback the callback function whose signature is:
* void callback(color in rgba array, error);
* @memberof SharedUtils
**/
readColorCode: function su_readColorCode(blob, x, y, callback) {
if (!callback) {
// Callback is not optional. We don't have use case without callback.
return;
}
var offscreenUrl = URL.createObjectURL(blob);
var offscreenImg = new Image();
offscreenImg.onload = function() {
URL.revokeObjectURL(offscreenUrl);
var canvas = document.createElement('canvas');
// We only need one pixel.
canvas.width = 1;
canvas.height = 1;
var ctx = canvas.getContext('2d');
try {
// Let's draw image x, y with 1x1 at canvas position(0, 0).
ctx.drawImage(offscreenImg, offscreenImg.naturalWidth * x,
offscreenImg.naturalHeight * y, 1, 1, 0, 0, 1, 1);
var data = ctx.getImageData(0, 0, 1, 1).data;
// Note the data is in Uint8ClampedArray. We need to convert it to
// array.
callback([data[0], data[1], data[2], data[3]]);
} catch(ex) {
// drawImage may throw decoding error
// getImageData may throw security error.
callback(null, ex);
}
};
offscreenImg.onerror = function() {
URL.revokeObjectURL(offscreenUrl);
console.error('read color code from ' + blob);
callback(null, new Error('read color code from ' + blob));
};
// Let's load the image
offscreenImg.src = offscreenUrl;
},
/**
* Localize html element from payload, support l10n-id, l10n-args, or
* plain string format. See http://mzl.la/1yoNtZ1 for l10n refernce.
* @param {HtmlElement} element target html element to be localized
* @param {Object} payload l10n payload
* @memberof SharedUtils
*/
localizeElement: function su_localizeElement(element, payload) {
// payload could be:
// 1. string -> l10nId
// 2. object -> {id: l10nId, args: l10nArgs}
// 3. object -> {raw: string}
// It could be HTML fragment but currently we don't have it yet.
if (typeof payload === 'string') {
element.setAttribute('data-l10n-id', payload);
return;
}
if (typeof payload === 'object') {
if (payload.id) {
navigator.mozL10n.setAttributes(element, payload.id, payload.args);
return;
} else if (payload.raw) {
element.removeAttribute('data-l10n-id');
element.textContent = payload.raw;
return;
}
}
}
};
}(window));

View file

@ -0,0 +1,170 @@
/* global evt, KeyEvent */
(function(exports) {
'use strict';
/**
* SimpleKeyNavigation handles left/right or top/bottom keys to move the
* focus. It is a very simple version of key navigation. It only moves the
* focus among an array.
*
* Please note that this module listens key event and changes focus while we
* start it. If we don't need it, we should stop it.
**/
function SimpleKeyNavigation() {
this._paused = true;
}
var proto = SimpleKeyNavigation.prototype = new evt();
SimpleKeyNavigation.DIRECTION = Object.freeze({
'HORIZONTAL': 'horizontal',
'VERTICAL': 'vertical'
});
proto.start = function skn_start(list, direction, options) {
this._paused = false;
this.direction = direction;
this.updateList(list);
this.isChild = options ? !!options.isChild : false;
this.target = (options && options.target) || window;
if (!this.isChild) {
this.target.addEventListener('keydown', this);
}
};
proto.stop = function skn_stop() {
this.target.removeEventListener('keydown', this);
};
proto.pause = function skn_pause() {
if (this._paused) {
return;
}
this._paused = true;
this._List.forEach((elem) => { // For nested case
if (elem instanceof SimpleKeyNavigation) {
elem.pause();
}
});
};
proto.resume = function skn_resume() {
if (!this._paused) {
return;
}
this._paused = false;
this._List.forEach((elem) => { // For nested case
if (elem instanceof SimpleKeyNavigation) {
elem.resume();
}
});
};
proto.updateList = function skn_updateList(list) {
this._List = list;
this._focusedIndex = -1;
if (list.length > 0) {
this._focusedIndex = 0;
this.focus();
}
};
proto.getFocusedElement = function skn_getFocusedElement() {
var elem = this._List[this._focusedIndex];
if (!elem) {
elem = null;
} else if (elem instanceof SimpleKeyNavigation) {
elem = elem.getFocusedElement();
}
return elem;
};
proto.focus = function skn_focus() {
var elem = this._List[this._focusedIndex];
if (elem.focus && (typeof elem.focus) === 'function') {
elem.focus();
}
// Fon nested case, we need to propagate child navigator event to parent.
if (elem instanceof SimpleKeyNavigation) {
elem = elem._List[elem._focusedIndex];
}
this.fire('focusChanged', elem);
};
proto.blur = function skn_blur() {
var elem = this._List[this._focusedIndex];
elem.blur && (typeof elem.blur === 'function') && elem.blur();
// Fon nested case, we need to propagate child navigator event to parent.
if (elem instanceof SimpleKeyNavigation) {
elem = elem._List[elem._focusedIndex];
}
this.fire('focusBlurred', elem);
};
proto.focusOn = function skn_focusOn(elem) {
var index = this._List.indexOf(elem);
if (index >= 0) {
this._focusedIndex = index;
this._List[this._focusedIndex].focus();
this.fire('focusChanged', this._List[this._focusedIndex]);
}
};
proto.movePrevious = function skn_movePrevious() {
if (this._focusedIndex < 1) {
return;
}
this._focusedIndex--;
this.focus();
};
proto.moveNext = function skn_moveNext() {
if (this._focusedIndex > this._List.length - 2) {
return;
}
this._focusedIndex++;
this.focus();
};
proto.handleKeyMove = function skn_handleKeyMove(e, pre, next) {
if (this._paused) {
return;
}
if (e.keyCode === pre) {
this.movePrevious();
} else if (e.keyCode === next) {
this.moveNext();
}
};
proto.propagateKeyMove = function skn_propagateKeyMove(e, pre, next) {
if (this._paused) {
return;
}
if (this._List[this._focusedIndex] instanceof SimpleKeyNavigation &&
(e.keyCode === pre || e.keyCode === next)) {
this._List[this._focusedIndex].handleEvent(e);
}
};
proto.handleEvent = function skn_handleEvent(e) {
if (this._paused) {
return;
}
if (this.direction === SimpleKeyNavigation.DIRECTION.HORIZONTAL) {
this.handleKeyMove(e, KeyEvent.DOM_VK_LEFT, KeyEvent.DOM_VK_RIGHT);
this.propagateKeyMove(e, KeyEvent.DOM_VK_UP, KeyEvent.DOM_VK_DOWN);
} else {
this.handleKeyMove(e, KeyEvent.DOM_VK_UP, KeyEvent.DOM_VK_DOWN);
this.propagateKeyMove(e, KeyEvent.DOM_VK_LEFT, KeyEvent.DOM_VK_RIGHT);
}
};
exports.SimpleKeyNavigation = SimpleKeyNavigation;
})(window);

View file

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