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

4
gaia/tv_apps/smart-home/js/bootstrap.js vendored Executable file
View file

@ -0,0 +1,4 @@
'use strict';
/* global Home */
window.home = new Home();
window.home.init();

View file

@ -0,0 +1,82 @@
/* global evt */
(function(exports) {
'use strict';
/**
* This class controls UI and user interaction of filter, with the help from
* [MenuGroup](http://bit.ly/1JbbnZ8).
*
* @class CardFilter
* @requires {@link http://bit.ly/1JbbnZ8|MenuGroup}
* @fires CardFilter#opened
* @fires CardFilter#filterchanged
*/
function CardFilter() {}
var proto = CardFilter.prototype = new evt();
Object.defineProperty(proto, 'filter', {
get: function cf_getFilter() {
return this._selectedFilter;
},
set: function cf_setFilter(icon) {
if (this.menuGroup && this._buttons[icon]) {
if (this._buttons[this._selectedFilter]) {
this._buttons[this._selectedFilter].classList.remove('toggled');
}
this._selectedFilter = icon;
this.menuGroup.changeIcon(icon);
this._buttons[icon].classList.add('toggled');
/**
* This event fires whenever filter changes.
* @event CardFilter#filterchanged
*/
this.emit('filterchanged', icon);
}
}
});
proto.start = function cf_start(menuGroup) {
this.menuGroup = menuGroup;
this.menuGroup.addEventListener('opened', function() {
/**
* This event fires whenever [MenuGroup](http://bit.ly/1JbbnZ8) is opened.
* @event CardFilter#opened
*/
this.fire('opened');
}.bind(this));
var buttons = this.menuGroup.querySelectorAll(
'smart-button[data-icon-type]');
this._buttons = {};
for (var i = 0; i < buttons.length; i++) {
this._buttons[buttons[i].dataset.iconType] = buttons[i];
buttons[i].addEventListener('click', this);
}
};
proto.stop = function cf_stop() {
var that = this;
var buttonKeys = Object.keys(this._buttons);
buttonKeys.forEach(function(key) {
that._buttons[key].removeEventListener('click', that);
});
};
proto.handleEvent = function cf_handleEvent(evt) {
if (!evt.target.dataset.iconType) {
return;
}
this.filter = evt.target.dataset.iconType;
};
proto.hide = function cf_hide() {
this.menuGroup.classList.add('hidden');
};
proto.show = function cf_show() {
this.menuGroup.classList.remove('hidden');
};
exports.CardFilter = CardFilter;
})(window);

View file

@ -0,0 +1,551 @@
/* global evt, Folder, SmartModalDialog, Utils */
'use strict';
(function(exports) {
const EDIT_MODE_SCALE = 0.57;
const CARD_TRANSFORM_LATENCY = 500;
/**
* Edit is the controller class when smart-home is in edit mode.
*
* @class Edit
*/
function Edit() {}
Edit.prototype = evt({
mainSection: document.getElementById('main-section'),
doneButton: document.getElementById('done-button'),
searchButton: document.getElementById('search-button'),
addNewFolderButton: document.getElementById('add-new-folder-button'),
editButton: document.getElementById('edit-button'),
settingsButton: document.getElementById('settings-button'),
regularNavElements: undefined,
editNavElements: undefined,
currentNode: undefined,
init: function(spatialNavigator, cardManager,
cardScrollable, folderScrollable) {
var that = this;
this.modalDialog = new SmartModalDialog();
this.spatialNavigator = spatialNavigator;
this.cardManager = cardManager;
this.cardScrollable = cardScrollable;
this.folderScrollable = folderScrollable;
this.regularNavElements =
[this.searchButton, this.settingsButton, this.editButton];
this.editNavElements = [this.doneButton, this.addNewFolderButton];
this.cardManager.on('card-swapped', this.onCardSwapped.bind(this));
this.cardManager.getCardList().then(function(cardList) {
cardList.forEach(function(card) {
if (card instanceof Folder) {
card.on('card-swapped', that.onCardSwapped.bind(that));
}
});
});
this.cardScrollable.on('listTransformEnd',
this.handleListTransformEnd.bind(this));
this.cardScrollable.on('nodeTransformEnd',
this.onCardMoveAnimationEnd.bind(this));
this.cardScrollable.on('hovering-node-removed',
this.onHoveringNodeRemoved.bind(this));
this.cardScrollable.on('node-inserted-over-folder',
this.onNodeInsertedOverFolder.bind(this));
this.spatialNavigator.on('focus', this.handleFocus.bind(this));
this.spatialNavigator.on('unfocus', this.handleUnfocus.bind(this));
// A workaround for checking if the folder list is navigable
this.isFolderReady = false;
// The hovering card
this._hoveringCard = null;
},
/**
* State of home app looks like this:
* (normal state)<-->Edit mode<-->Arrange mode
* (Only adjacant states are switchable)
*/
toggleEditMode: function() {
if (this.mainSection.dataset.mode === 'edit') {
this.mainSection.dataset.mode = '';
this.spatialNavigator.multiAdd(this.regularNavElements);
this.spatialNavigator.multiRemove(this.editNavElements);
this.cardScrollable.setScale();
this.folderScrollable.setScale();
this.cardScrollable.listElem.classList.add('exiting-edit-mode');
this.cardManager.writeCardlistInCardStore({cleanEmptyFolder: true})
.then(function() {
// Since writeCardlistInCardStore triggers card-removed event that
// causes re-focus on other elements in card list, we need to wait
// until those actions to be done before focusing editButton.
this.spatialNavigator.focus(this.editButton);
this.currentNode.classList.remove('focused');
}.bind(this));
this._concealPanel(this.currentScrollable, this.currentNode);
this.fire('exit-edit-mode');
} else {
this.mainSection.dataset.mode = 'edit';
this.spatialNavigator.multiRemove(this.regularNavElements);
this.spatialNavigator.multiAdd(this.editNavElements);
this._setCurrentScrollable(this.cardScrollable);
this.currentNode =
this.cardScrollable.getNodeFromItem(this.cardScrollable.currentItem);
this._revealPanel(this.currentScrollable, this.currentNode);
this.spatialNavigator.focus(this.cardScrollable);
this.cardScrollable.setScale(EDIT_MODE_SCALE);
this.folderScrollable.setScale(EDIT_MODE_SCALE);
this.fire('enter-edit-mode');
}
},
toggleArrangeMode: function() {
if (this.mainSection.dataset.mode === 'edit') {
this.mainSection.dataset.mode = 'arrange';
this._concealPanel(this.currentScrollable, this.currentNode);
this.currentScrollable.focus(this.currentScrollable.currentItem);
this._setHintArrow();
if (this.currentScrollable === this.folderScrollable) {
this.cardScrollable.setColspanOnFocus(1);
}
this.fire('arrange');
} else if (this.mainSection.dataset.mode == 'arrange') {
this.mainSection.dataset.mode = 'edit';
this.currentNode = this.currentScrollable.getNodeFromItem(
this.currentScrollable.currentItem);
this._revealPanel(this.currentScrollable, this.currentNode);
this._clearHintArrow();
this.cardScrollable.setColspanOnFocus(0);
this.folderScrollable.realignToReferenceElement();
}
},
_moveQueue: {
queue: [],
isEmpty: function() {
return (this.queue.length === 0);
},
push: function(obj) {
this.queue.push(obj);
},
shift: function() {
return this.queue.shift();
},
getLength: function() {
return this.queue.length;
},
clearQueue: function() {
this.queue.length = 0;
}
},
_moveTimer: undefined,
_produceMove: function(key) {
this._moveQueue.push(key);
if (!this._moveTimer) {
this._consumeMove();
}
},
_consumeMove: function() {
var focus = this.spatialNavigator.getFocusedElement();
if (!this._moveQueue.isEmpty() && focus.CLASS_NAME === 'XScrollable') {
var key = this._moveQueue.shift();
var targetItem = focus.getTargetItem(key);
switch (key) {
case 'left':
case 'right':
if (!focus.isHovering && targetItem &&
focus.currentItem.getAttribute('app-type') !== 'deck' &&
focus.currentItem.getAttribute('app-type') !== 'folder' &&
targetItem.getAttribute('app-type') === 'folder') {
// hover on folder
this.hoverCard(focus, focus.currentItem, targetItem);
this._moveTimer =
window.setTimeout(this.onCardMoveAnimationEnd.bind(this),
CARD_TRANSFORM_LATENCY, targetItem);
} else if (focus.isHovering && targetItem !== focus.hoveredItem) {
this.unhoverCard(focus, true);
this._moveTimer =
window.setTimeout(this.onCardMoveAnimationEnd.bind(this),
CARD_TRANSFORM_LATENCY, targetItem);
} else if (targetItem) {
if (focus.isHovering) {
this.unhoverCard(focus, false);
}
var focusedCard = this.cardManager.findCardFromCardList(
{cardId: focus.currentItem.dataset.cardId});
var nonFocusedCard = this.cardManager.findCardFromCardList(
{cardId: targetItem.dataset.cardId});
if (this.currentScrollable === this.folderScrollable) {
var containingFolder = this.cardManager.findContainingFolder(
{cardId: focus.currentItem.dataset.cardId});
containingFolder.swapCard(focusedCard, nonFocusedCard);
} else {
this.cardManager.swapCard(focusedCard, nonFocusedCard);
}
this._moveTimer =
window.setTimeout(this.onCardMoveAnimationEnd.bind(this),
CARD_TRANSFORM_LATENCY, targetItem);
} else {
// if queue is not empty but we are failed to get targetItem
// that means we reach the boundary of cards. Thus we should
// consume next action in queue
this._consumeMove();
}
break;
case 'down':
if (focus.isHovering && this.isFolderReady) {
this.moveToFolder();
this._moveTimer =
window.setTimeout(this.onCardMoveAnimationEnd.bind(this),
CARD_TRANSFORM_LATENCY);
}
this._moveQueue.clearQueue();
break;
case 'up':
if (this.currentScrollable === this.folderScrollable) {
this.moveToCardList(focus);
this._moveTimer =
window.setTimeout(this.onCardMoveAnimationEnd.bind(this),
CARD_TRANSFORM_LATENCY);
}
break;
}
}
},
onCardMoveAnimationEnd: function(elem) {
if (this._moveTimer) {
window.clearTimeout(this._moveTimer);
this._moveTimer = undefined;
}
if (elem &&
!elem.classList.contains('focused') &&
!this._moveQueue.isEmpty()) {
this._consumeMove();
}
},
onCardSwapped: function(card1, card2, idx1, idx2) {
this.currentScrollable.swap(idx1, idx2);
this._setHintArrow();
},
onMove: function(key) {
if (this.modalDialog.isOpened) {
return true;
}
if (this.mode !== 'arrange') {
return false;
}
this._produceMove(key);
return true;
},
_setHintArrow: function(scrollable) {
var index = parseInt(this.currentNode.dataset.idx, 10);
if (scrollable && scrollable.isHovering) {
this.currentNode.classList.toggle('left_arrow', true);
this.currentNode.classList.toggle('right_arrow', true);
this.currentNode.classList.toggle('down_arrow', true);
this.currentNode.classList.remove('up_arrow');
} else {
this.currentNode.classList.toggle('left_arrow', index > 0);
this.currentNode.classList.toggle('right_arrow',
index < this.currentScrollable.length - 1);
this.currentNode.classList.remove('down_arrow');
this.currentNode.classList.toggle('up_arrow',
this.currentScrollable === this.folderScrollable);
}
},
_setCurrentScrollable: function(scrollable) {
this.currentScrollable = scrollable;
document.getElementById('card-viewer').classList.toggle(
'current-scrollable', scrollable === this.cardScrollable);
document.getElementById('folder-viewer').classList.toggle(
'current-scrollable', scrollable === this.folderScrollable);
},
_clearHintArrow: function() {
this.currentNode.classList.remove('left_arrow');
this.currentNode.classList.remove('right_arrow');
},
addNewFolder: function() {
var folder = this.cardManager.insertNewFolder({id: 'new-folder'},
this.cardScrollable.currentIndex);
folder.on('card-swapped', this.onCardSwapped.bind(this));
this.spatialNavigator.focus(this.cardScrollable);
},
moveToFolder: function() {
// Remove the card from the main list
this.cardManager.removeCard(this._hoveringCard);
},
/**
* Runs after moveToFolder call, when the hovering card is successfully
* removed by cardManager and the node is removed from cardScrollable.
*/
onHoveringNodeRemoved: function(cardItem, folderItem) {
var card = this._hoveringCard;
var folder = this.cardManager.findCardFromCardList(
{cardId: folderItem.dataset.cardId});
// Add the card into the folder
folder.addCard(card, this.folderScrollable.isReversed ? null : 0);
this._hoveringCard = null;
this._setCurrentScrollable(this.folderScrollable);
this.folderScrollable.realignToReferenceElement();
this.currentNode = this.folderScrollable.getNodeFromItem(
this.folderScrollable.currentItem);
if (!this.spatialNavigator.focus(this.folderScrollable)) {
// When a card is added to a new empty folder,
// add the folderScrollable to this.spatialNavigator.
this.spatialNavigator.add(this.folderScrollable);
this.spatialNavigator.focus(this.folderScrollable);
}
this._setHintArrow();
},
moveToCardList: function(focus) {
var folder = this.cardManager.findContainingFolder(
{cardId: focus.currentItem.dataset.cardId});
var card = this.cardManager.findCardFromCardList(
{cardId: focus.currentItem.dataset.cardId});
var folderItem = this.cardScrollable.currentItem;
var idx = this.cardScrollable.getNodeFromItem(folderItem).dataset.idx;
this._hoveringCard = card;
folder.removeCard(card);
// insert card
this.cardManager.insertCard({
card: card,
index: parseInt(idx, 10),
overFolder: true
});
},
onNodeInsertedOverFolder: function() {
this.spatialNavigator.focus(this.cardScrollable);
this._setCurrentScrollable(this.cardScrollable);
this.currentNode = this.cardScrollable.getNodeFromItem(
this.cardScrollable.currentItem);
this._setHintArrow(this.currentScrollable);
this.folderScrollable.realignToReferenceElement();
},
onEnter: function() {
if (this.mode !== 'edit' && this.mode !== 'arrange') {
return false;
}
if (this.modalDialog.isOpened) {
return true;
}
var focus = this.spatialNavigator.getFocusedElement();
if (focus === this.doneButton) {
this.toggleEditMode();
} else if (focus === this.addNewFolderButton) {
this.addNewFolder();
} else if (focus.CLASS_NAME === 'XScrollable') {
var currentItem = focus.currentItem;
if (currentItem.classList.contains('rename-btn')) {
this.renameCard(focus, focus.getNodeFromItem(currentItem));
} else if (currentItem.classList.contains('delete-btn')) {
this.openDeleteCardDialog(focus, focus.getNodeFromItem(currentItem));
} else if (currentItem.classList.contains('hover')) {
// When a card is hovering over a folder, do nothing here.
} else {
// Current focus is on a card
this.toggleArrangeMode();
}
}
return true;
},
renameCard: function(scrollable, nodeElem) {
var card = this.cardManager.findCardFromCardList({
cardId: scrollable.getItemFromNode(nodeElem).dataset.cardId
});
var _ = navigator.mozL10n.get;
var lang = document.documentElement.lang;
var oldName = this.cardManager.resolveCardName(card, lang);
oldName = oldName.raw ? oldName.raw : _(oldName.id);
var result = prompt(_('enter-new-name'), oldName);
if (!result) {
return;
}
card.name = {raw: result};
this.cardManager.updateCard(card);
},
openDeleteCardDialog: function(scrollable, nodeElem) {
Utils.holdFocusForAnimation();
this.modalDialog.open({
message: {
textL10nId: 'delete-card-alert'
},
buttonSettings: [
{
textL10nId: 'cancel',
defaultFocus: true,
onClick: this.spatialNavigator.focus.bind(this.spatialNavigator)
},
{
textL10nId: 'delete',
class: 'danger',
onClick: this.deleteCard.bind(this, scrollable, nodeElem)
}
],
onCancel: this.spatialNavigator.focus.bind(this.spatialNavigator)
});
},
deleteCard: function(scrollable, nodeElem) {
var itemElem = scrollable.getItemFromNode(nodeElem);
this._concealPanel(scrollable, nodeElem);
nodeElem.classList.add('delete');
if (scrollable === this.folderScrollable) {
var folder = this.cardManager.findContainingFolder(
{cardId: itemElem.dataset.cardId});
var card = this.cardManager.findCardFromCardList(
{cardId: itemElem.dataset.cardId});
}
var deletedItem = scrollable.getItemFromNode(nodeElem);
deletedItem.addEventListener('transitionend', function(evt) {
if (evt.propertyName === 'transform') {
scrollable.spatialNavigator
.focus(scrollable.getItemFromNode(nodeElem));
if (scrollable === this.cardScrollable) {
this.cardManager.removeCard(parseInt(nodeElem.dataset.idx, 10));
} else {
folder.removeCard(card);
if (folder.isEmpty()) {
this.spatialNavigator.focus(this.cardScrollable);
}
}
}
}.bind(this));
},
hoverCard: function(scrollable, focusedItem, targetItem) {
var card = this.cardManager.findCardFromCardList(
{cardId: focusedItem.dataset.cardId});
this._hoveringCard = card;
scrollable.hover(focusedItem, targetItem);
// set up/bottom arrow
this._setHintArrow(scrollable);
},
unhoverCard: function(scrollable, shouldResetCardPositions) {
scrollable.unhover(shouldResetCardPositions);
this._hoveringCard = null;
// clear up/bottom arrow
if (shouldResetCardPositions) {
this._setHintArrow();
}
},
_getPanel: function(nodeElem) {
var cardPanel = nodeElem.getElementsByClassName('card-panel')[0];
return {
panel: cardPanel,
renameBtn: cardPanel.getElementsByClassName('rename-btn')[0],
deleteBtn: cardPanel.getElementsByClassName('delete-btn')[0]
};
},
_revealPanel: function(scrollable, nodeElem) {
var panel = this._getPanel(nodeElem);
if(scrollable.getItemFromNode(nodeElem).getAttribute('app-type') ===
'deck') {
// Decks can't be renamed or deleted.
return;
}
scrollable.spatialNavigator.add(panel.renameBtn);
scrollable.spatialNavigator.add(panel.deleteBtn);
},
_concealPanel: function(scrollable, nodeElem) {
var panel = this._getPanel(nodeElem);
scrollable.spatialNavigator.remove(panel.renameBtn);
scrollable.spatialNavigator.remove(panel.deleteBtn);
},
handleFocus: function(elem) {
if (this.mode === 'edit' && elem.CLASS_NAME === 'XScrollable') {
this.handleCardFocus(
elem, elem.currentItem, elem.getNodeFromItem(elem.currentItem));
}
},
handleUnfocus: function(elem) {
if (this.mode === 'edit' && elem === this.currentScrollable) {
this._concealPanel(this.currentScrollable, this.currentNode);
// Make sure we leave focus on item itself rather than any panel buttons
this.currentScrollable.silentFocus(
this.currentScrollable.getItemFromNode(this.currentNode));
}
},
handleCardFocus: function(scrollable, itemElem, nodeElem) {
if (this.mode !== 'edit' ||
// When focus goes outside the scrollable and the last selected item
// is a panel button, we still focus back to the card corresponding to
// that button to keep track of the last focused card. But in this
// case we don't need any further action on UI view.
this.spatialNavigator.getFocusedElement() !== scrollable) {
return;
}
// If users are moving from card to panel button, they're under the same
// node element. We need to "unfocus" the node only when user really move
// to another card. So here we call handleCardUnfocus manually.
if (this.currentNode != nodeElem) {
this._concealPanel(this.currentScrollable, this.currentNode);
this.currentNode = nodeElem;
this._setCurrentScrollable(scrollable);
}
this._revealPanel(scrollable, nodeElem);
itemElem.focus();
nodeElem.classList.add('focused');
},
handleListTransformEnd: function() {
if (this.mode === '') {
this.cardScrollable.listElem.classList.remove('exiting-edit-mode');
}
},
get mode() {
return this.mainSection.dataset.mode;
}
});
exports.Edit = Edit;
}(window));

View file

@ -0,0 +1,310 @@
/* global CardFilter, Folder */
(function(exports) {
'use strict';
/**
* FilterManager is the controller class of filter. It relies on
* {@link CardFilter} to do UI works of filter itself. However it also needs
* to co-operate with {@link http://bit.ly/1JcrKZO|SmartBubbles},
* {@link Edit}, and {@link Home} classes to do the actual *filtering* task
* in smart-home.
*
* The working flow when user change filter is like:
*
* 1. FilterManager receives `filterchanged` from {@link CardFilter}
* 2. Play bubbling down animation, see
* {@link FilterManager#onFilterChanged|onFilterChanged()}.
* 3. When animation ends, change card icon in smart-home, with help from
* {@link Home} and XScrollable. See
* {@link FilterManager#onAnimationEnd|onAnimationEnd()}.
* 4. Play bubbling up animation, see
* {@link FilterManager#_performBubbleUp|_performBubbleUp()}.
*
* @class FilterManager
* @requires {@link Home}
* @requires XScrollable
* @requires {@link Edit}
* @requires {@link CardFilter}
* @requires {@link http://bit.ly/1JcrKZO|SmartBubbles}
*/
var FilterManager = function() {};
/**
* @namespace FilterManager.FILTERS
*/
FilterManager.FILTERS = Object.freeze({
/**
* @readonly
* @memberof FilterManager.FILTERS
* @property {String} name - value is `all`
* @property {String} iconName - value is `filter`
*/
'ALL': {
name: 'all',
iconName: 'filter'
},
/**
* @readonly
* @memberof FilterManager.FILTERS
* @property {String} name - value is `tv`
* @property {String} iconName - value is `tv`
*/
'TV': {
name: 'tv',
iconName: 'tv'
},
/**
* @readonly
* @memberof FilterManager.FILTERS
* @property {String} name - value is `device`
* @property {String} iconName - value is `device`
*/
'DEVICE': {
name: 'device',
iconName: 'device'
},
/**
* @readonly
* @memberof FilterManager.FILTERS
* @property {String} name - value is `application`
* @property {String} iconName - value is `application`
*/
'APPLICATION': {
name: 'application',
iconName: 'application'
},
/**
* @readonly
* @memberof FilterManager.FILTERS
* @property {String} name - value is `website`
* @property {String} iconName - value is `website`
*/
'WEBSITE': {
name: 'website',
iconName: 'web'
}
});
FilterManager.prototype = {
/**
* Initialize FilterManager
*
* @public
* @method FilterManager#init
* @param {Object} options
* @param {Object} options.cardListElem - `HTMLElement` of `card-list`
* @param {Object} options.cardScrollable - instance of XScrollable
* @param {Object} options.home - instance of {@link Home}
* @param {Object} options.cardManager - instance of CardManager
*/
init: function(options) {
this._cardListElem = options.cardListElem;
this._cardScrollable = options.cardScrollable;
this._home = options.home;
this._edit = this._home.edit;
this._cardManager = options.cardManager;
this._cardFilter = new CardFilter();
this._cardFilter.start(document.getElementById('filter-tab-group'));
this._cardFilter.filter = FilterManager.FILTERS.ALL.iconName;
this._filterChangedHandler = this.onFilterChanged.bind(this);
this._cardFilter.on('filterchanged', this._filterChangedHandler);
this._cardFilter.on('opened', function() {
this._home.cleanFolderScrollable(true);
}.bind(this));
this._hideFilter = this.hide.bind(this);
this._showFilter = this.show.bind(this);
this._edit.on('enter-edit-mode', this._hideFilter);
this._edit.on('exit-edit-mode', this._showFilter);
this._smartBubblesElem = document.getElementById('bubbles');
this._animationEndHandler = this.onAnimationEnd.bind(this);
this._smartBubblesElem.addEventListener('all-items-bubbled',
this._animationEndHandler);
},
/**
* Do the opposite of initalization
*
* @public
* @method FilterManager#uninit
*/
uninit: function() {
this._cardFilter.off('filterchanged', this._filterChangedHandler);
this._edit.off('enter-edit-mode', this._hideFilter);
this._edit.off('exit-edit-mode', this._showFilter);
this._smartBubblesElem.removeEventListener('all-items-bubbled',
this._animationEndHandler);
},
_filteredCardList: undefined,
_isFirstFrame: false,
_isFilterChanging: false,
_isBubbleSinking: function fm_isBubbleSinking() {
return (
this._cardListElem.getAttribute('smart-bubbles-direction') === 'down');
},
/**
* Perform bubbling up animation. We must hide the first frame of animation
* because there are sliding animation on XScrollable. We don't want user to
* see this according to UX spec.
*
* @private
* @method FilterManager#_performBubbleUp
*/
_performBubbleUp: function fm_performBubbleUp() {
if (this._isFirstFrame) {
this._isFirstFrame = false;
this._cardListElem.style.transition = 'none';
this._cardScrollable.resetScroll();
window.requestAnimationFrame(this._performBubbleUp.bind(this));
} else {
this._cardListElem.style.opacity = 1;
this._cardListElem.style.transition = '';
this._cardListElem.removeAttribute('smart-bubbles-direction');
this._smartBubblesElem.play(
document.querySelectorAll('#card-list > .card > .app-button'));
}
},
/**
* Get constant object of {@link FilterManager.FILTERS} of the `iconName`
*
* @public
* @method FilterManager#getFilterByIconName
* @param {String} iconName -
*/
getFilterByIconName: function fm_getFilterByIconName(iconName) {
var keys = Object.keys(FilterManager.FILTERS);
var filter;
keys.some(function(key) {
var cursor = FilterManager.FILTERS[key];
if (iconName === cursor.iconName) {
filter = cursor;
return true;
}
});
return filter;
},
/**
* EventHandler of `all-items-bubbled` event.
* [SmartBubbles](http://bit.ly/1JcrKZO) fires `all-items-bubbled` when it
* is done with bubbling down animation. We start to alter the card content
* of XScrollable only after bubbling down animation ends. And after we are
* done with altering content, we'll have to perform bubbling up animation
* by calling {@link FilterManager#_performBubbleUp}.
*
* @public
* @method FilterManager#onAnimationEnd
*/
onAnimationEnd: function fm_onAnimationEnd() {
if (!this._isFilterChanging) {
return;
}
var that = this;
var filter = this.getFilterByIconName(this._cardFilter.filter);
var gotCardLists = function(results) {
// results[0] is the main card list which has been filtered.
// results[1] is the main card list.
var filteredList = results[0];
var cardList = results[1];
// Get filtered card list for each folder.
cardList.filter(elem => elem instanceof Folder).forEach(elem => {
filteredList = filteredList.concat(
elem.getFilteredCardList(filter.name));
});
filteredList.forEach(function(card) {
that._cardScrollable.addNode(that._home.createCardNode(card));
});
that._filteredCardList =
(filter.name !== 'all') ? filteredList : undefined;
that._cardListElem.style.opacity = 0;
window.requestAnimationFrame(that._performBubbleUp.bind(that));
};
if (this._isBubbleSinking()) {
this._cardScrollable.clean();
Promise.all([
this._cardManager.getFilteredCardList(filter.name),
this._cardManager.getCardList()
]).then(gotCardLists);
} else {
this._isFilterChanging = false;
}
},
/**
* EventHandler of `filterchanged` event. {@link CardFilter} fires
* `filterchanged` event whenever user select a different filter. This is
* becasue we need to play bubbling down animation whenever filter is
* changed.
*
* @public
* @method FilterManager#onFilterChanged
*/
onFilterChanged: function fm_onFilterChange(iconName) {
this._isFirstFrame = true;
if (this._isFilterChanging) {
this._smartBubblesElem.stopImmediately();
}
this._isFilterChanging = true;
this._cardListElem.setAttribute('smart-bubbles-direction', 'down');
// Notice that methods like document.querySelectorAll or
// document.getElementsByClassName returns elements based on the
// sequence of time that element added to DOM structure. But we should
// play animation in the left-to-right order. Card scrollable keeps the
// order of all items, we just use them directly to play animation.
// See http://bugzil.la/1169538
this._smartBubblesElem.play(this._cardScrollable.allItems);
},
/**
* Hide filter. This should be called when we are in edit mode.
*
* @public
* @method FilterManager#hide
*/
hide: function fm_hide() {
this._cardFilter.hide();
},
/**
* Hide filter. This should be called when we are exit from edit mode.
*
* @public
* @method FilterManager#show
*/
show: function fm_show() {
this._cardFilter.show();
},
/**
* Reset filter to {@link FilterManager.FILTERS.ALL}. We should always reset
* filter when smart-home is back to foreground.
*
* @public
* @method FilterManager#resetFilter
*/
resetFilter: function fm_resetFilter() {
if (this._cardFilter.filter !== FilterManager.FILTERS.ALL.iconName) {
this._cardFilter.filter = FilterManager.FILTERS.ALL.iconName;
}
}
};
exports.FilterManager = FilterManager;
}(window));

View file

@ -0,0 +1,831 @@
'use strict';
/* global Application, FilterManager, CardManager, Clock, Deck, Edit, Folder,
KeyNavigationAdapter, MessageHandler, MozActivity, SearchBar,
SharedUtils, SpatialNavigator, URL, XScrollable, Animations,
Utils, FTEWizard, AppBookmark */
/* jshint nonew: false */
(function(exports) {
const FULLSIZED_ICON = 336 * (window.devicePixelRatio || 1);
const DEFAULT_ICON = 'url("/style/images/appic_developer.png")';
const DEFAULT_BGCOLOR = 'rgba(0, 0, 0, 0.5)';
const DEFAULT_BGCOLOR_ARRAY = [0, 0, 0, 0.5];
const CARDLIST_LEFT_MARGIN = 8.4;
/**
* Main class of smart-home
* @class Home
*/
function Home() {}
Home.prototype = {
navigableIds:
['search-button', 'search-input', 'edit-button', 'settings-button',
'filter-tab-group'],
topElementIds: ['search-button', 'search-input', 'edit-button',
'settings-button'],
bottomElementIds: ['filter-tab-group', 'filter-all-button',
'filter-tv-button', 'filter-device-button', 'filter-app-button',
'filter-website-button'],
isNavigable: true,
navigableClasses: ['filter-tab', 'command-button'],
navigableScrollable: [],
cardScrollable: undefined,
folderScrollable: undefined,
_focus: undefined,
_focusScrollable: undefined,
_folderCard: undefined,
filterElementIds: ['filter-all-button', 'filter-tv-button',
'filter-device-button', 'filter-app-button', 'filter-website-button'],
filterManager: undefined,
cardListElem: document.getElementById('card-list'),
folderListElem: document.getElementById('folder-list'),
cardManager: undefined,
editButton: document.getElementById('edit-button'),
settingsButton: document.getElementById('settings-button'),
searchButton: document.getElementById('search-button'),
timeElem: document.getElementById('time'),
fteElem: document.getElementById('fte'),
init: function() {
var that = this;
this.initClock();
this.cardManager = new CardManager();
this.cardManager.init();
this.searchBar = new SearchBar();
this.searchBar.init(document.getElementById('search-bar'));
this.searchBar.on('shown', this.onSearchBarShown.bind(this));
this.searchBar.on('hidden', this.onSearchBarHidden.bind(this));
this.cardManager.getCardList().then(function(cardList) {
that.messageHandler = new MessageHandler();
that.messageHandler.init(that);
that._createCardList(cardList);
that.cardScrollable = new XScrollable({
frameElem: 'card-list-frame',
listElem: 'card-list',
itemClassName: 'app-button',
leftMargin: CARDLIST_LEFT_MARGIN});
that.folderScrollable = new XScrollable({
frameElem: 'folder-list-frame',
listElem: 'folder-list',
itemClassName: 'app-button',
leftMargin: CARDLIST_LEFT_MARGIN,
scale: 0.68,
referenceElement: that.cardScrollable});
that.navigableScrollable = [that.cardScrollable, that.folderScrollable];
var collection = that._getNavigateElements();
that.spatialNavigator = new SpatialNavigator(collection);
that.spatialNavigator.straightOnly = true;
that.keyNavigatorAdapter = new KeyNavigationAdapter();
that.keyNavigatorAdapter.init();
that.keyNavigatorAdapter.on('move', that.onMove.bind(that));
// All behaviors which no need to have multple events while holding
// the key should use keyup.
that.keyNavigatorAdapter.on('enter-keyup', that.onEnter.bind(that));
that.cardListElem.addEventListener('transitionend',
that.determineFolderExpand.bind(that));
that.cardManager.on('card-inserted',
that.onCardInserted.bind(that, that.cardScrollable));
that.cardManager.on('card-removed',
that.onCardRemoved.bind(that, that.cardScrollable));
that.cardManager.on('card-updated', that.onCardUpdated.bind(that));
that.spatialNavigator.on('focus', that.handleFocus.bind(that));
that.spatialNavigator.on('unfocus', that.handleUnfocus.bind(that));
var handleCardFocusBound = that.handleCardFocus.bind(that);
var handleCardUnfocusBound = that.handleCardUnfocus.bind(that);
var handleCardUnhoverBound = that.handleCardUnhover.bind(that);
that.navigableScrollable.forEach(function(scrollable) {
scrollable.on('focus', handleCardFocusBound);
scrollable.on('unfocus', handleCardUnfocusBound);
scrollable.on('unhover', handleCardUnhoverBound);
if (scrollable.isEmpty()) {
that.spatialNavigator.remove(scrollable);
}
});
that.edit = new Edit();
that.edit.init(that.spatialNavigator, that.cardManager,
that.cardScrollable, that.folderScrollable);
that.edit.on('arrange', that.onArrangeMode.bind(that));
that.filterManager = new FilterManager();
that.filterManager.init({
cardListElem: that.cardListElem,
cardScrollable: that.cardScrollable,
home: that,
cardManager: that.cardManager,
});
// In some case, we can do action at keydown which is translated as
// onEnter in home.js. But in button click case, we need to listen
// keyup. So, instead keydown/keyup, we just use click event to handle
// it. The click event is translated at smart-button when use press
// enter on smart-button.
that.searchButton.addEventListener('click', function() {
that.searchBar.show();
// hide the searchButton because searchBar has an element whose
// appearance is the same as it.
that.searchButton.classList.add('hidden');
}.bind(that));
// handle animation
that.endBubble = null;
document.addEventListener(
'visibilitychange', that.onVisibilityChange.bind(that));
// if this init function is executed after the document is set to
// visible, the visibilitychange event may not be triggered.
if (document.visibilityState === 'visible') {
that.onVisibilityChange();
}
cardList.forEach(function(card) {
if (card instanceof Folder) {
card.on('card-inserted',
that.onCardInserted.bind(that, that.folderScrollable));
card.on('card-removed',
that.onCardRemoved.bind(that, that.folderScrollable));
}
});
that._fteWizard = new FTEWizard('homeFTE');
that._fteWizard.init({
container: that.fteElem,
onfinish: () => {
that.spatialNavigator.focus(that.cardScrollable);
that.isNavigable = true;
}
});
});
},
onVisibilityChange: function() {
if (document.visibilityState === 'visible') {
Utils.holdFocusForAnimation();
this.cardListElem.classList.remove('hidden');
var that = this;
Promise.all([new Promise(function(resolve) {
that.skipBubble = Animations.doBubbleAnimation(
that.cardListElem, '.app-button', 100, resolve);
}), new Promise(function(resolve) {
if (that._folderCard) {
that.skipFolderBubble = Animations.doBubbleAnimation(
that.folderListElem, '.app-button', 100, resolve);
} else {
resolve();
}
})]).then(function() {
// Catch focus back unless there is a pin activity since callback of
// pinning would catch the focus for us.
if (that._fteWizard.running) {
that._fteWizard.focus();
} else if (!that.messageHandler.resumeActivity()) {
that.spatialNavigator.focus();
}
that.isNavigable = true;
that.skipBubble = null;
that.skipFolderBubble = null;
});
} else {
this.cardListElem.classList.add('hidden');
this.messageHandler.stopActivity();
this.isNavigable = false;
// An user may close home app when bubbling or sliding animations are
// still playing, and then open home app again right away. In this case,
// the user will see the last unfinished animations. In order to solve
// this, we have to force disable all the animations and trigger their
// callbacks when home app is in hidden state.
if (this.skipBubble) {
this.skipBubble();
}
if (this.skipFolderBubble) {
this.skipFolderBubble();
}
if (this.cardScrollable.isSliding) {
this.cardScrollable.endSlide();
}
this.filterManager.resetFilter();
}
},
initClock: function() {
var that = this;
navigator.mozL10n.ready(function() {
that.clock = new Clock();
that.clock.start(that.updateClock.bind(that));
// Listen to 'moztimechange'
window.addEventListener('moztimechange',
that.restartClock.bind(that));
// Listen to 'timeformatchange'
window.addEventListener('timeformatchange',
that.restartClock.bind(that));
});
},
_localizeCardName: function(elem, card) {
if (!elem || !card) {
return;
}
// We should use user given name first, otherwise we use localized
// application/deck name.
var lang = document.documentElement.lang;
var name = this.cardManager.resolveCardName(card, lang);
if (name && name.raw) {
elem.removeAttribute('data-l10n-id');
elem.textContent = name.raw;
} else if (name && name.id) {
SharedUtils.localizeElement(elem, name);
}
},
onCardInserted: function(scrollable, card, idx, overFolder) {
if (card instanceof Folder) {
card.on('card-inserted',
this.onCardInserted.bind(this, this.folderScrollable));
card.on('card-removed',
this.onCardRemoved.bind(this, this.folderScrollable));
}
var newCardElem = this.createCardNode(card);
var newCardButtonElem = newCardElem.firstElementChild;
// Initial transition for new card
newCardButtonElem.classList.add('new-card');
newCardButtonElem.classList.add('new-card-transition');
newCardButtonElem.addEventListener('transitionend', function onPinned() {
newCardButtonElem.classList.remove('new-card-transition');
newCardButtonElem.classList.remove('last-card');
newCardButtonElem.removeEventListener('transitionend', onPinned);
});
// insert new card into cardScrollable
this.isNavigable = false;
scrollable.on('slideEnd', function() {
newCardButtonElem.classList.remove('new-card');
if (scrollable.nodes.indexOf(newCardElem) ===
scrollable.nodes.length - 1) {
newCardButtonElem.classList.add('last-card');
}
this.isNavigable = true;
}.bind(this));
if (!overFolder) {
scrollable.insertNodeBefore(newCardElem, idx);
} else {
scrollable.insertNodeOver(newCardElem, idx);
}
},
onCardUpdated: function(card, idx) {
var that = this;
var cardButton = this.cardScrollable.getItemFromNode(
this.cardScrollable.getNode(idx));
var spans =
SharedUtils.nodeListToArray(cardButton.getElementsByTagName('span'));
spans.forEach(function(span) {
if (span.classList.contains('name')) {
that._localizeCardName(span, card);
}
});
},
onCardRemoved: function(scrollable, indices) {
indices.forEach(function(idx) {
var elm = scrollable.getNode(idx);
if (elm && elm.dataset.revokableURL) {
URL.revokeObjectURL(elm.dataset.revokableURL);
}
}, this);
scrollable.removeNodes(indices);
},
_setCardIcon: function (cardButton, card, blob, bgColor) {
try {
var bgUrl = URL.createObjectURL(blob);
if (bgColor) {
cardButton.style.backgroundColor = bgColor;
cardButton.classList.add('fitted');
card.backgroundType = 'fitted';
} else {
cardButton.classList.add('fullsized');
card.backgroundType = 'fullsized';
}
cardButton.dataset.revokableURL = bgUrl;
cardButton.style.backgroundImage = 'url("' + bgUrl + '")';
} catch (e) {
// If the blob is broken, we may get an exception while creating object
// URL.
cardButton.style.backgroundImage = DEFAULT_ICON;
cardButton.style.backgroundColor = DEFAULT_BGCOLOR;
}
},
_createWave: function(cardButton, card) {
// deck's icon using gaia font
var deckIcon = document.createElement('span');
deckIcon.className = 'icon';
deckIcon.dataset.icon = card.deckClass;
// front wave of a deck
var waveFront = document.createElement('div');
waveFront.className = 'deck-wave';
waveFront.classList.add('wave-front');
waveFront.classList.add(card.deckClass + '-wave-front');
waveFront.classList.add('wave-paused');
// back wave of a deck
var waveBack = document.createElement('div');
waveBack.className = 'deck-wave';
waveBack.classList.add('wave-back');
waveBack.classList.add(card.deckClass + '-wave-back');
cardButton.appendChild(waveBack);
cardButton.appendChild(deckIcon);
cardButton.appendChild(waveFront);
cardButton.classList.add('deck-' + card.deckClass);
},
_fillCardIcon: function(cardButton, card) {
var manifestURL = card.nativeApp && card.nativeApp.manifestURL;
var that = this;
// We have thumbnail which is created by pin
if (card.thumbnail) {
this._setCardIcon(cardButton, card, card.thumbnail,
card.backgroundColor);
// TODO add backgroundColor??? How to do it???
} else if (!card.cachedIconBlob) {
// We don't have cachedIconBlob, just get icon from app
this.cardManager.getIconBlob({
manifestURL: manifestURL,
entryPoint: card.entryPoint,
// XXX: preferredSize should be determined by
// real offsetWidth of cardThumbnailElem instead of hard-coded value
preferredSize: FULLSIZED_ICON
}).then(function(iconData) {
var blob = iconData[0];
var size = iconData[1];
if (size >= FULLSIZED_ICON) {
that._setCardIcon(cardButton, card, blob, null);
} else {
that._getIconColor(blob, function(color, err) {
if (err) {
that._setCardIcon(cardButton, card, blob, DEFAULT_BGCOLOR);
} else {
that._setCardIcon(cardButton, card, blob, 'rgba(' + color[0] +
', ' + color[1] + ', ' + color[2] + ', ' + color[3] + ')');
}
});
}
card.cachedIconBlob = blob;
});
} else if (card.cachedIconBlob) {
// We already have cacedIconBlob which is created by previous step.
this._setCardIcon(cardButton, card, card.cachedIconBlob,
card.backgroundColor);
}
},
_getIconColor: function(blob, callback) {
var dy = 0;
function checkColor(color, err) {
if (err) {
callback(null, err);
} else if (color[3] < 255 && dy < 0.5) {
dy += 0.25;
SharedUtils.readColorCode(blob, 0.5, dy, checkColor);
} else {
callback(color[3] < 255 ? DEFAULT_BGCOLOR_ARRAY : color, err);
}
}
SharedUtils.readColorCode(blob, 0.5, 0, checkColor);
},
onArrangeMode: function() {
if (this._focusScrollable !== this.folderScrollable) {
this.cleanFolderScrollable();
}
},
createCardNode: function(card) {
// card element would be created like this:
// <div class="card">
// <smart-button>/* Card button */</smart-button>
// <section class="card-panel">
// <smart-button>/* Rename button */</smart-button>
// <smart-button>/* Delete button */</smart-button>
// </section>
// </div>
// and return DOM element
var cardNode = document.createElement('div');
cardNode.classList.add('card');
var cardButton = document.createElement('smart-button');
cardButton.setAttribute('type', 'app-button');
cardButton.className = 'app-button';
cardButton.dataset.cardId = card.cardId;
var cardPanel = document.createElement('section');
cardPanel.className = 'card-panel';
var renameButton = document.createElement('smart-button');
renameButton.dataset.icon = 'rename';
renameButton.classList.add('rename-btn');
var deleteButton = document.createElement('smart-button');
deleteButton.dataset.icon = 'delete';
deleteButton.classList.add('delete-btn');
cardPanel.appendChild(renameButton);
cardPanel.appendChild(deleteButton);
cardNode.appendChild(cardButton);
cardNode.appendChild(cardPanel);
// XXX: will support Folder and other type of Card in the future
// for now, we only create card element for Application and Deck
if (card instanceof Application) {
if (card.group === 'tv') {
cardButton.classList.add('tv-channel');
cardButton.dataset.icon = 'tv';
cardButton.setAttribute('app-type', 'tv');
} else {
cardButton.setAttribute('app-type', 'app');
this._fillCardIcon(cardButton, card);
}
} else if (card instanceof Deck) {
cardButton.setAttribute('app-type', 'deck');
if (card.group === 'website') {
this._fillCardIcon(cardButton, card);
} else {
this._createWave(cardButton, card);
}
} else if (card instanceof AppBookmark) {
cardButton.setAttribute('app-type', 'appbookmark');
this._fillCardIcon(cardButton, card);
} else if (card instanceof Folder) {
cardButton.setAttribute('app-type', 'folder');
cardButton.dataset.icon = 'folder';
}
// For smart-button, we put card name in pseudo-element :after. However,
// we need to localize card name and l10n library do not support
// localizing element with children elements.
// Instead of using :after, we create a 'span' element under smart-button
// and put card name in it.
var nameSpan = document.createElement('span');
nameSpan.classList.add('name');
this._localizeCardName(nameSpan, card);
cardButton.appendChild(nameSpan);
return cardNode;
},
_createCardList: function(cardList) {
cardList.forEach(function(card) {
this.cardListElem.appendChild(this.createCardNode(card));
}.bind(this));
},
onMove: function(key) {
if (!this.isNavigable || this.edit.onMove(key)) {
return;
}
var focus = this.spatialNavigator.getFocusedElement();
if (!(focus.CLASS_NAME == 'XScrollable' && focus.move(key))) {
this.spatialNavigator.move(key);
}
},
onEnter: function() {
if (!this.isNavigable || this.edit.onEnter()) {
return;
}
var focusElem = this.focusElem;
if (focusElem === this.settingsButton) {
this.openSettings();
} else if (focusElem === this.editButton) {
this.cleanFolderScrollable();
this.edit.toggleEditMode();
// XXX: Reset card filter when entering edit mode
this.filterManager.resetFilter();
} else if (focusElem &&
this.filterElementIds.indexOf(focusElem.id) > -1) {
this.cleanFolderScrollable();
} else {
// Current focus is on a card
var cardId = focusElem.dataset.cardId;
var card;
if (this.focusScrollable === this.folderScrollable) {
card = this._folderCard.findCard({cardId: cardId});
} else {
card = this.cardManager.findCardFromCardList({cardId: cardId});
}
if (card) {
card.launch();
}
}
},
onSearchBarShown: function() {
var hideSearchBar = function() {
document.removeEventListener('visibilitychange', hideSearchBar);
this.searchBar.hide();
}.bind(this);
document.addEventListener('visibilitychange', hideSearchBar);
var activity = new MozActivity({
name: 'search',
data: { keyword: '' }
});
activity.onerror = hideSearchBar;
},
onSearchBarHidden: function() {
this.searchButton.classList.remove('hidden');
},
_getNavigateElements: function() {
var elements = [];
this.navigableIds.forEach(function(id) {
var elem = document.getElementById(id);
if (elem) {
elements.push(elem);
}
});
this.navigableClasses.forEach(function(className) {
var elems = document.getElementsByClassName(className);
if (elems.length) {
// Change HTMLCollection to array before concatenating
elements = elements.concat(Array.prototype.slice.call(elems));
}
});
elements = elements.concat(this.navigableScrollable);
return elements;
},
handleFocus: function(elem) {
if (elem.CLASS_NAME == 'XScrollable') {
this._focusScrollable = elem;
elem.focus();
this.checkFocusedGroup();
} else if (elem.nodeName) {
if (this._focus) {
this._focus.blur();
}
switch(elem.nodeName.toLowerCase()) {
case 'menu-group':
this.handleFocusMenuGroup(elem);
break;
default:
elem.focus();
this._focus = elem;
this._focusScrollable = undefined;
this.checkFocusedGroup(elem);
break;
}
} else {
this._focusScrollable = undefined;
}
},
handleUnfocus: function(elem, nodeElem) {
if(elem.CLASS_NAME == 'XScrollable') {
this.handleCardUnfocus(
elem, elem.currentItem, elem.getNodeFromItem(elem.currentItem));
}
},
checkFocusedGroup: function(elem) {
if (!this._focusedGroup) {
return;
}
// close the focused group when we move focus out of this group.
if (!elem || !this._focusedGroup.contains(elem)) {
this._focusedGroup.close();
this._focusedGroup = null;
}
},
handleFocusMenuGroup: function(menuGroup) {
var self = this;
menuGroup.once('opened', function() {
self.spatialNavigator.remove(menuGroup);
var childElement = menuGroup.firstElementChild;
var firstFocusable = null;
while(childElement) {
switch(childElement.nodeName.toLowerCase()) {
case 'style':
case 'script':
break;
default:
firstFocusable = firstFocusable || childElement;
self.spatialNavigator.add(childElement);
}
childElement = childElement.nextElementSibling;
}
if (firstFocusable) {
self.spatialNavigator.focus(firstFocusable);
}
});
menuGroup.once('will-close', function() {
// Clear all opened event listener because we won't have it if opened is
// not fired and the group is trying to close it self.
menuGroup.off('opened');
self.spatialNavigator.add(menuGroup);
var childElement = menuGroup.firstElementChild;
while(childElement) {
switch(childElement.nodeName.toLowerCase()) {
case 'style':
case 'script':
break;
default:
self.spatialNavigator.remove(childElement);
}
childElement = childElement.nextElementSibling;
}
});
this.checkFocusedGroup(menuGroup);
this._focusedGroup = menuGroup;
menuGroup.open();
},
handleCardFocus: function(scrollable, itemElem, nodeElem) {
this._focus = itemElem;
if (this.edit.mode === 'edit') {
this.edit.handleCardFocus(scrollable, itemElem, nodeElem);
}
itemElem.focus();
nodeElem.classList.add('focused');
if(scrollable === this.cardScrollable && this._folderCard &&
itemElem.dataset.cardId !== this._folderCard.cardId &&
!this.cardScrollable.isHovering) {
this.cleanFolderScrollable();
}
},
cleanFolderScrollable: function(doNotChangeFocus) {
if (this._focusScrollable === this.folderScrollable &&
!doNotChangeFocus) {
this.spatialNavigator.focus(this.cardScrollable);
}
this.spatialNavigator.remove(this.folderScrollable);
this.folderScrollable.clean();
this._folderCard = undefined;
this.edit.isFolderReady = false;
this.cardScrollable.setColspanOnFocus(0);
},
handleCardUnfocus: function(scrollable, itemElem, nodeElem) {
// Fix null error when the last card in a folder is removed.
if (nodeElem) {
nodeElem.classList.remove('focused');
}
},
handleCardUnhover: function(scrollable, itemElem, nodeElem) {
this.cleanFolderScrollable();
},
determineFolderExpand: function(evt) {
// Folder expansion is performed on only when user moves cursor onto a
// folder or hover a folder in edit mode and it finished its focus
// transition.
if (this.focusScrollable === this.cardScrollable &&
evt.originalTarget.classList.contains('app-button') &&
(!this._folderCard ||
this._folderCard.cardId !== evt.originalTarget.dataset.cardId) &&
(evt.originalTarget.classList.contains('focused') &&
// Listen to 'outline-width' rather than 'transform' here since it
// also applies to edit mode when user moves from panel button back
// to card.
evt.propertyName === 'outline-width' &&
document.getElementById('main-section').dataset.mode !== 'arrange' ||
// Folder needs to be expanded when hovered as well.
evt.originalTarget.classList.contains('hovered'))) {
this.buildFolderList(evt.originalTarget);
}
},
buildFolderList: function(target) {
var cardId = target.dataset.cardId;
var card = this.cardManager.findCardFromCardList({cardId: cardId});
if (!(card instanceof Folder)) {
return;
}
this._folderCard = card;
var folderList = this._folderCard.getCardList();
if (folderList.length === 0) {
this.edit.isFolderReady = true;
// Needs to disable the folder-list animation right away to
// prevent it from affecting the new-card into folder animation.
this.folderListElem.style.transition = 'none';
return;
}
// Build folder list
folderList.forEach(function(card) {
this.folderScrollable.addNode(this.createCardNode(card));
}, this);
var isFirstFrame = true;
var initFolderAnimation = function() {
if (isFirstFrame) {
isFirstFrame = false;
// At first frame, we call setReferenceElement to move folder list
// right under folder card. Transition should be replaced by 'none'
// since we don't need to show this process as animation to user.
this.folderListElem.style.transition = 'none';
this.folderScrollable.realignToReferenceElement();
this.skipFolderBubble = Animations.doBubbleAnimation(
this.folderListElem, '.app-button', 100, function() {
this.spatialNavigator.add(this.folderScrollable);
this.edit.isFolderReady = true;
this.skipFolderBubble = undefined;
}.bind(this));
window.requestAnimationFrame(initFolderAnimation);
} else {
// 2nd frame, recover original transition.
this.folderListElem.style.transition = '';
}
}.bind(this);
window.requestAnimationFrame(initFolderAnimation);
},
openSettings: function() {
/* jshint nonew: false */
new MozActivity({
name: 'configure',
data: {}
});
},
updateClock: function() {
var now = new Date();
var _ = navigator.mozL10n.get;
var use12Hour = window.navigator.mozHour12;
var f = new navigator.mozL10n.DateTimeFormat();
var timeFormat = use12Hour ? _('shortTimeFormat12') :
_('shortTimeFormat24');
// remove AM/PM and we use our owned style to show it.
timeFormat = timeFormat.replace('%p', '').trim();
var formatted = f.localeFormat(now, timeFormat);
this.timeElem.innerHTML = formatted;
this.timeElem.dataset.ampm = use12Hour ? f.localeFormat(now, '%p') : '';
},
restartClock: function() {
navigator.mozL10n.ready((function() {
// restart clcok
this.clock.stop();
this.clock.start(this.updateClock.bind(this));
}).bind(this));
},
get focusElem() {
return this._focus;
},
get focusScrollable() {
return this._focusScrollable;
}
};
exports.Home = Home;
}(window));

View file

@ -0,0 +1,134 @@
'use strict';
/* global ConnectionManager */
(function(exports) {
/**
* MessageHandler handles all incoming messages which includes both
* [IAC](http://mzl.la/1TKR6zw) and [MozActivity](http://mzl.la/1Pu8ecc).
* This class relies on {@link ConnectionManager} to monitor incoming `unpin`
* message on [IAC](http://mzl.la/1TKR6zw).
*
* @class MessageHandler
* @requires {@link ConnectionManager}
* @requires {@link Home}
*/
var MessageHandler = function() {
this.isStop = false;
this.activityQueue = [];
};
MessageHandler.prototype = {
_connectionManager: undefined,
_home: undefined,
/**
* Initialize MessageHandler
* @public
* @method MessageHandler#init
*/
init: function cm_init(home) {
this._home = home;
navigator.mozSetMessageHandler(
'activity', this.handleActivity.bind(this));
this._connectionManager = new ConnectionManager();
this._connectionManager.init(['iac-appdeck-channel']);
this._connectionManager.on('unpin', this.unpin.bind(this));
},
/**
* Pin card of `cardEntry`
* @public
* @method MessageHandler#pin
* @param {Object} cardEntry - cardEntry representing the card
* @param {String} cardEntry.type - valid values are 'Application', 'Deck',
* 'AppBookmark', and 'Folder'
* @param {String} cardEntry.group - valud values are 'tv', 'application',
* 'device', and 'dashboard'
* @param {String} cardEntry.manifestURL - manifestURL, if the card
* represents an app or a deck
* @param {String} cardEntry.launchURL - optional
* @param {String} cardEntry.url - URL, if the card represents a bookmark
* @param {String} cardEntry.name.raw - optional display name
* @param {Blob} cardEntry.thumnail - see [blob](http://mzl.la/1BRI7IV)
*/
pin: function mh_pin(cardEntry) {
this._home.cardManager.insertCard({
cardEntry: cardEntry
});
},
/**
* Unpin card
* @public
* @method MessageHandler#unpin
* @param {Object} data
* @param {String} data.manifestURL - manifestURL if unpinning an app
* @param {String} data.launchURL - optional launchURL if unpinning an
* app
* @param {String} data.url - link URL when unpinning an bookmark
*/
unpin: function mh_unpin(data) {
var home = this._home;
// XXX: this is extra step, we should remove this step once we were
// done fixing and refactoring cardManager.removeCard.
// The reason we need this step here is because cardManager.removeCard
// only accept card instance as sole parameter.
var card = home.cardManager.findCardFromCardList(data);
home.cardManager.removeCard(card);
},
handleActivity: function mh_handleActivity(activity) {
if(this.isStop) {
this.activityQueue.push(activity);
return;
}
this._digestActivity(activity);
},
/**
* Stop from processing activity in activityQueue. The reason we need this
* is that we don't need to do pinning when smart-home is not visible.
*
* @public
* @method MessageHandler#stopActivity
*/
stopActivity: function mh_stopActivity() {
this.isStop = true;
},
_digestActivity: function(activity) {
var name = activity.source.name;
switch(name) {
case 'pin':
this.pin(activity.source.data);
break;
}
},
/**
* Resume processing activity in activityQueue. We could call this method to
* continue processing pinning when smart-home is visible.
*
* @public
* @method MessageHandler#resumeActivity
*/
resumeActivity: function mh_resumeActivity() {
this.isStop = false;
if (this.activityQueue.length === 0) {
return false;
}
var activity;
while(this.activityQueue.length > 0) {
activity = this.activityQueue.pop();
this._digestActivity(activity);
}
return true;
}
};
exports.MessageHandler = MessageHandler;
})(window);

View file

@ -0,0 +1,69 @@
/* global evt */
(function(exports) {
'use strict';
function SearchBar() {}
SearchBar.STATES = Object.freeze({
'HIDDEN': 'hidden',
'SHOWING': 'showing',
'SHOWN': 'shown'
});
var proto = SearchBar.prototype = new evt();
Object.defineProperty(proto, 'state', {
get: function sb_getState() {
return this._state;
},
set: function sb_setState(state) {
this._state = state;
this.fire(state);
}
});
proto.init = function sb_init(ui) {
this.searchUI = ui;
this.searchUI.addEventListener('transitionend', this);
this.voiceButton = this.searchUI.querySelector('.search-mic-button');
this.voiceButton.addEventListener('transitionend', this);
this.state = this.searchUI.classList.contains('hidden') ?
SearchBar.STATES.HIDDEN : SearchBar.STATES.SHOWN;
};
proto.uninit = function sb_uninit() {
this.searchUI.removeEventListener('transitionend', this);
this.voiceButton.removeEventListener('transitionend', this);
};
proto.show = function sb_show() {
this.searchUI.classList.remove('hidden');
this.state = SearchBar.STATES.SHOWING;
};
proto.hide = function sb_hide() {
this.searchUI.classList.add('hidden');
this.searchUI.classList.remove('opened');
this.state = SearchBar.STATES.HIDDEN;
};
proto.handleEvent = function sb_handleEvent(evt) {
// We have two steps transition:
// 1. background-color transition on searchUI
// 2. opacity on voiceButton
if (evt.target === this.searchUI &&
evt.propertyName === 'background-color' &&
this.state === SearchBar.STATES.SHOWING) {
this.searchUI.classList.add('opened');
} else if (evt.target === this.voiceButton &&
evt.propertyName === 'opacity' &&
this.state === SearchBar.STATES.SHOWING) {
this.state = SearchBar.STATES.SHOWN;
}
};
exports.SearchBar = SearchBar;
})(window);

View file

@ -0,0 +1,13 @@
'use strict';
(function(exports) {
exports.Utils = {
holdFocusForAnimation: function utils_holdFocusForAnimation() {
// we have to move focus to another element to prevent user press enter
// to trigger unexpected behavior. We cannot use blur() to do it because
// it makes smart-home without focus. In this case, other apps can
// request focus from smart-home which makes focus lost, bug 1161940.
document.getElementById('main-section').focus();
}
};
})(window);