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.
65
gaia/shared/elements/gaia_grid/examples/example.js
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
|
||||
var grid = document.getElementById('grid');
|
||||
|
||||
var icon = 'mozilla.png';
|
||||
var whitePixel = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///' +
|
||||
'wAAACwAAAAAAQABAAACAkQBADs=';
|
||||
|
||||
var items = [
|
||||
new GaiaGrid.Bookmark({
|
||||
id: 1,
|
||||
name: 'Mozilla',
|
||||
icon: icon,
|
||||
url: 'http://mozilla.org'
|
||||
}),
|
||||
new GaiaGrid.Bookmark({
|
||||
id: 2,
|
||||
name: 'Two',
|
||||
icon: icon,
|
||||
url: 'http://2'
|
||||
}),
|
||||
new GaiaGrid.Bookmark({
|
||||
id: 3,
|
||||
name: 'Three',
|
||||
icon: icon,
|
||||
url: 'http://3'
|
||||
}),
|
||||
new GaiaGrid.Bookmark({
|
||||
id: 4,
|
||||
name: 'Four',
|
||||
icon: icon,
|
||||
url: 'http://4'
|
||||
}),
|
||||
new GaiaGrid.Mozapp({
|
||||
id: 5,
|
||||
icon: icon,
|
||||
manifest: {
|
||||
name: 'A Webapp',
|
||||
icons: {
|
||||
64: whitePixel
|
||||
}
|
||||
}
|
||||
}),
|
||||
new GaiaGrid.Divider(),
|
||||
new GaiaGrid.Mozapp({
|
||||
id: 6,
|
||||
manifestURL: 'app://xfoobar',
|
||||
manifest: {
|
||||
name: 'Another Webapp',
|
||||
icons: {
|
||||
64: whitePixel
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
items.forEach(grid.add.bind(grid));
|
||||
|
||||
grid.render();
|
||||
|
||||
document.getElementById('clear').addEventListener('click', function() {
|
||||
grid.clear();
|
||||
});
|
||||
|
||||
51
gaia/shared/elements/gaia_grid/examples/index.html
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>GaiaGrid Demo</title>
|
||||
<meta name="description" content="Demo app for gaia-grid" />
|
||||
<style type="text/css">
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
background-color: #48036f;
|
||||
font-family: Verdana, sans-serif;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
gaia-grid {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script>window.GaiaConfirmBaseurl = '../';</script>
|
||||
<script>window.GaiaButtonsBaseurl = '../../gaia_buttons/';</script>
|
||||
<script src="../../../js/component_utils.js"></script>
|
||||
<script src="../../gaia_buttons/script.js"></script>
|
||||
<script src="../../gaia_confirm/script.js"></script>
|
||||
<script>window.GaiaGridBaseurl = '../';</script>
|
||||
<script src="../../../js/url_helper.js"></script>
|
||||
<script src="../../../js/l10n.js"></script>
|
||||
<script src="../script.js"></script>
|
||||
<script src="../js/grid_icon_renderer.js"></script>
|
||||
<script src="../js/dialog.js"></script>
|
||||
<script src="../js/grid_view.js"></script>
|
||||
<script src="../js/grid_icon_renderer.js"></script>
|
||||
<script src="../js/items/grid_item.js"></script>
|
||||
<script src="../js/items/placeholder.js"></script>
|
||||
<script src="../js/items/bookmark.js"></script>
|
||||
<script src="../js/items/divider.js"></script>
|
||||
<script src="../js/items/mozapp.js"></script>
|
||||
<script src="../js/grid_layout.js"></script>
|
||||
<script src="../js/grid_dragdrop.js"></script>
|
||||
<script src="../js/grid_zoom.js"></script>
|
||||
</head>
|
||||
<body role="application">
|
||||
<button id="clear">Clear Grid</button>
|
||||
<gaia-grid id="grid" dragdrop></gaia-grid>
|
||||
<script defer src="example.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
gaia/shared/elements/gaia_grid/examples/mozilla.png
Executable file
|
After Width: | Height: | Size: 77 KiB |
BIN
gaia/shared/elements/gaia_grid/images/add.png
Executable file
|
After Width: | Height: | Size: 312 B |
BIN
gaia/shared/elements/gaia_grid/images/add@1.5x.png
Executable file
|
After Width: | Height: | Size: 400 B |
BIN
gaia/shared/elements/gaia_grid/images/add@2.25x.png
Executable file
|
After Width: | Height: | Size: 573 B |
BIN
gaia/shared/elements/gaia_grid/images/add@2x.png
Executable file
|
After Width: | Height: | Size: 558 B |
BIN
gaia/shared/elements/gaia_grid/images/add@3.375x.png
Executable file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_canceled.png
Executable file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_canceled@1.5x.png
Executable file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_canceled@2.25x.png
Executable file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_canceled@2x.png
Executable file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_canceled@3.375x.png
Executable file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_failed.png
Executable file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_failed@1.5x.png
Executable file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_failed@2.25x.png
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_failed@2x.png
Executable file
|
After Width: | Height: | Size: 3 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_failed@3.375x.png
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_spinner.png
Executable file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_spinner@1.5x.png
Executable file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_spinner@2.25x.png
Executable file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_spinner@2x.png
Executable file
|
After Width: | Height: | Size: 4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_spinner@3.375x.png
Executable file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_unrecoverable.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_unrecoverable@1.5x.png
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_unrecoverable@2.25x.png
Executable file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_unrecoverable@2x.png
Executable file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_install_unrecoverable@3.375x.png
Executable file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_installing.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_installing@1.5x.png
Executable file
|
After Width: | Height: | Size: 2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_installing@2.25x.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_installing@2x.png
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/app_installing@3.375x.png
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/collapse_arrow.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/collapse_arrow@1.5x.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/collapse_arrow@2.25x.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
gaia/shared/elements/gaia_grid/images/collapse_arrow@2x.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/collapse_arrow@3.375x.png
Executable file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/default_icon.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gaia/shared/elements/gaia_grid/images/default_icon@1.5x.png
Executable file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/default_icon@2.25x.png
Executable file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
gaia/shared/elements/gaia_grid/images/default_icon@2x.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
gaia/shared/elements/gaia_grid/images/default_icon@3.375x.png
Executable file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
gaia/shared/elements/gaia_grid/images/loading_spinner.png
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
gaia/shared/elements/gaia_grid/images/loading_spinner@1.5x.png
Executable file
|
After Width: | Height: | Size: 3 KiB |
BIN
gaia/shared/elements/gaia_grid/images/loading_spinner@2.25x.png
Executable file
|
After Width: | Height: | Size: 118 KiB |
BIN
gaia/shared/elements/gaia_grid/images/loading_spinner@2x.png
Executable file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
gaia/shared/elements/gaia_grid/images/loading_spinner@3.375x.png
Executable file
|
After Width: | Height: | Size: 122 KiB |
BIN
gaia/shared/elements/gaia_grid/images/move_gripper.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/move_gripper@1.5x.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
gaia/shared/elements/gaia_grid/images/move_gripper@2.25x.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/move_gripper@2x.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/move_gripper@3.375x.png
Executable file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
gaia/shared/elements/gaia_grid/images/remove.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
gaia/shared/elements/gaia_grid/images/remove@1.5x.png
Executable file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gaia/shared/elements/gaia_grid/images/remove@2.25x.png
Executable file
|
After Width: | Height: | Size: 2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/remove@2x.png
Executable file
|
After Width: | Height: | Size: 2 KiB |
BIN
gaia/shared/elements/gaia_grid/images/remove@3.375x.png
Executable file
|
After Width: | Height: | Size: 2.6 KiB |
844
gaia/shared/elements/gaia_grid/js/grid_dragdrop.js
Executable file
|
|
@ -0,0 +1,844 @@
|
|||
'use strict';
|
||||
/* global devicePixelRatio */
|
||||
/* global GaiaGrid */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const ACTIVE_SCALE = 1.4;
|
||||
|
||||
/* This is an alternative delay used to initiate icon movement when in edit
|
||||
* mode. The normal contextmenu delay is too long once we're in edit mode
|
||||
* and causes user confusion, but a short delay globally would cause too many
|
||||
* accidental triggers of edit mode.
|
||||
*/
|
||||
const EDIT_LONG_PRESS_DELAY = 200;
|
||||
|
||||
/* The movement threshold to use for the above synthetic long-press. */
|
||||
const EDIT_LONG_PRESS_THRESHOLD = 4 * devicePixelRatio;
|
||||
|
||||
/* This delay is the time passed once users stop the finger over an icon and
|
||||
* the rearrange is performed */
|
||||
const REARRANGE_DELAY = 30;
|
||||
|
||||
/* The page is scrolled via javascript if an icon is being moved, and is
|
||||
* within a length of a page edge configured by this value */
|
||||
const EDGE_PAGE_THRESHOLD = 50;
|
||||
|
||||
/* This is the delay before calling finish when handling touchend. */
|
||||
const TOUCH_END_FINISH_DELAY = 20;
|
||||
|
||||
/* The maximum frequency with which the grid will be rearranged in response
|
||||
* to the item position changing, in ms.
|
||||
*/
|
||||
const REARRANGE_FREQUENCY = 100;
|
||||
|
||||
/* The amount of cool-down time after a rearrange animation. To avoid
|
||||
* rearranging too frequently and causing jank and user confusion.
|
||||
*/
|
||||
const REARRANGE_COOLDOWN = 750;
|
||||
|
||||
const SCREEN_HEIGHT = window.innerHeight;
|
||||
|
||||
const scrollStep = Math.round(SCREEN_HEIGHT / EDGE_PAGE_THRESHOLD);
|
||||
|
||||
/* The scroll step will be 5 times bigger over the edge */
|
||||
const maxScrollStepFactor = 5;
|
||||
|
||||
function DragDrop(gridView) {
|
||||
this.gridView = gridView;
|
||||
this.container = gridView.element;
|
||||
this.scrollable = document.documentElement;
|
||||
this.container.addEventListener('touchstart', this);
|
||||
this.container.addEventListener('touchmove', this);
|
||||
this.container.addEventListener('touchend', this);
|
||||
window.addEventListener('touchcancel', this);
|
||||
this.container.addEventListener('click', this);
|
||||
this.container.addEventListener('contextmenu', this);
|
||||
}
|
||||
|
||||
DragDrop.prototype = {
|
||||
|
||||
/**
|
||||
* The current touchmove target.
|
||||
* @type {DomElement}
|
||||
*/
|
||||
target: null,
|
||||
|
||||
/**
|
||||
* The edit-mode long-press timeout
|
||||
*/
|
||||
longPressTimeout: null,
|
||||
|
||||
/**
|
||||
* The position of the first touchstart of the current event block
|
||||
*/
|
||||
touchStart: { x: 0, y: 0, screenX: 0, screenY: 0 },
|
||||
|
||||
/**
|
||||
* If we have moved an icon, this indicates that we need to save the state
|
||||
* @type {boolean}
|
||||
*/
|
||||
dirty: false,
|
||||
|
||||
/**
|
||||
* Whether or not we are in edit mode.
|
||||
* @type {boolean}
|
||||
*/
|
||||
inEditMode: false,
|
||||
|
||||
/**
|
||||
* The item being hovered over.
|
||||
* @type {GaiaGrid.GridItem}
|
||||
*/
|
||||
hoverItem: null,
|
||||
|
||||
/**
|
||||
* The group being hovered over.
|
||||
* @type {GaiaGrid.Group}
|
||||
*/
|
||||
hoverGroup: null,
|
||||
|
||||
/*
|
||||
* An element that's aligned to the top of the grid and should be
|
||||
* considered as obscuring the grid when in edit mode.
|
||||
*/
|
||||
editHeaderElement: null,
|
||||
|
||||
/**
|
||||
* The timeout used to rearrange the grid during dragging.
|
||||
*/
|
||||
rearrangeGridTimeout: null,
|
||||
|
||||
/**
|
||||
* The time of the last reposition call.
|
||||
*/
|
||||
lastRepositionTime: 0,
|
||||
|
||||
/**
|
||||
* Returns the maximum active scale value.
|
||||
*/
|
||||
get activeScale() {
|
||||
return ACTIVE_SCALE;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if we are currently dragging an icon.
|
||||
*/
|
||||
get inDragAction() {
|
||||
return this.target && this.target.classList.contains('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the delay before calling finish in response to a touch-end event.
|
||||
*/
|
||||
get touchEndFinishDelay() {
|
||||
return TOUCH_END_FINISH_DELAY;
|
||||
},
|
||||
|
||||
/**
|
||||
* Begins the drag/drop interaction.
|
||||
* Enlarges the icon.
|
||||
* Sets additional data to make the touchmove handler faster.
|
||||
*/
|
||||
begin: function(e) {
|
||||
// Stop icon launching while we are in active state
|
||||
this.gridView.stop();
|
||||
window.dispatchEvent(new CustomEvent('gaiagrid-dragdrop-begin'));
|
||||
|
||||
this.hoverItem = null;
|
||||
this.rearrangeDelay = null;
|
||||
this.doRearrange = null;
|
||||
this.enterEditMode();
|
||||
this.container.classList.add('dragging');
|
||||
this.icon.scale = ACTIVE_SCALE;
|
||||
this.icon.setActive(true);
|
||||
this.container.parentNode.style.overflow = 'hidden';
|
||||
|
||||
// Work around e.pageX/e.pageY being null (to make it easier to work with
|
||||
// injected events, or old versions of Marionette)
|
||||
this.initialPageX = (typeof e.pageX === 'undefined') ?
|
||||
this.icon.x : e.pageX;
|
||||
this.initialPageY = (typeof e.pageY === 'undefined') ?
|
||||
this.icon.y : e.pageY;
|
||||
this.currentTouch = {
|
||||
pageX: this.initialPageX,
|
||||
pageY: this.initialPageY
|
||||
};
|
||||
|
||||
var items = this.gridView.items;
|
||||
var lastElement = items[items.length - 1];
|
||||
this.maxScroll = lastElement.y + lastElement.pixelHeight +
|
||||
(this.icon.pixelHeight * ACTIVE_SCALE);
|
||||
|
||||
// If this is a group, or the sole icon in a group, make sure the
|
||||
// surrounding groups are marked as invalid-drop so you can't initiate a
|
||||
// move that would have no result.
|
||||
var itemIndex = this.icon.detail.index;
|
||||
if (this.icon.detail.type === 'divider') {
|
||||
this.icon.element.classList.add('invalid-drop');
|
||||
if (itemIndex > 0) {
|
||||
for (var i = itemIndex - 1; i >= 0; i--) {
|
||||
var item = items[i];
|
||||
if (item.detail.type === 'divider') {
|
||||
item.element.classList.add('invalid-drop');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var itemBefore = itemIndex ? items[itemIndex - 1] : null;
|
||||
var itemAfter = items[itemIndex + 1];
|
||||
|
||||
if ((itemAfter.detail.type === 'placeholder' ||
|
||||
itemAfter.detail.type === 'divider') &&
|
||||
(!itemBefore || itemBefore.detail.type === 'divider')) {
|
||||
if (itemBefore) {
|
||||
itemBefore.element.classList.add('invalid-drop');
|
||||
}
|
||||
var group, groupIndex = itemIndex;
|
||||
do {
|
||||
group = items[++groupIndex];
|
||||
} while (group.detail.type !== 'divider');
|
||||
group.element.classList.add('invalid-drop');
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw the icon at the new position and scale
|
||||
this.updateIconPosition();
|
||||
|
||||
// Rearrange grid to highlight the group underneath the icon
|
||||
this.rearrangeGrid();
|
||||
},
|
||||
|
||||
finish: function() {
|
||||
// Complete the repositioning timeout
|
||||
if (this.rearrangeGridTimeout !== null) {
|
||||
this.rearrangeGrid();
|
||||
}
|
||||
|
||||
// Remove the dragging property after the icon has transitioned into
|
||||
// place to avoid jank due to animations starting that are disabled
|
||||
// when dragging.
|
||||
this.icon.element.addEventListener('transitionend', this);
|
||||
this.currentTouch = null;
|
||||
|
||||
if (this.doRearrange !== null) {
|
||||
if (this.rearrangeDelay) {
|
||||
clearTimeout(this.rearrangeDelay);
|
||||
}
|
||||
this.doRearrange.call(this);
|
||||
} else {
|
||||
this.icon.requestAttention();
|
||||
}
|
||||
|
||||
// Hand back responsibility to Grid view to render the dragged item.
|
||||
this.icon.scale = 1;
|
||||
this.icon.setActive(false);
|
||||
this.icon.element.classList.remove('hovering');
|
||||
this.target = null;
|
||||
|
||||
this.gridView.render();
|
||||
|
||||
// Save icon state if we need to
|
||||
if (this.dirty) {
|
||||
window.dispatchEvent(new CustomEvent('gaiagrid-saveitems'));
|
||||
}
|
||||
this.dirty = false;
|
||||
|
||||
setTimeout(function nextTick() {
|
||||
this.gridView.start();
|
||||
window.dispatchEvent(new CustomEvent('gaiagrid-dragdrop-finish'));
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
finalize: function() {
|
||||
this.container.parentNode.style.overflow = '';
|
||||
this.container.classList.remove('dragging');
|
||||
this.container.classList.remove('hover-over-top');
|
||||
if (this.icon) {
|
||||
this.icon.element.removeEventListener('transitionend', this);
|
||||
this.icon = null;
|
||||
}
|
||||
if (this.hoverItem) {
|
||||
this.hoverItem.element.classList.remove('hovered');
|
||||
this.hoverItem = null;
|
||||
}
|
||||
if (this.hoverGroup) {
|
||||
this.hoverGroup.element.classList.remove('drop-target');
|
||||
this.hoverGroup = null;
|
||||
}
|
||||
for (var i = 0, iLen = this.gridView.items.length;
|
||||
i < iLen; i++) {
|
||||
var item = this.gridView.items[i];
|
||||
item.element.classList.remove('invalid-drop');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The closer to edge the faster (bigger step).
|
||||
** Distance 0px -> 5 times faster
|
||||
** Distance 25px -> 2.5 times faster
|
||||
** Distance 50px (EDGE_PAGE_THRESHOLD) -> 0 times
|
||||
*/
|
||||
getScrollStep: function(distanceToEdge) {
|
||||
var factor = maxScrollStepFactor;
|
||||
|
||||
if (distanceToEdge > 0) {
|
||||
factor *= ((EDGE_PAGE_THRESHOLD - distanceToEdge) /
|
||||
EDGE_PAGE_THRESHOLD);
|
||||
}
|
||||
|
||||
return Math.round(scrollStep * factor);
|
||||
},
|
||||
|
||||
/**
|
||||
* Positions the dragged icon, rearranges the grid and scrolls the page if
|
||||
* needed.
|
||||
* The page is scrolled via javascript if an icon is being moved,
|
||||
* and is within a percentage of a page edge.
|
||||
*/
|
||||
positionAndScrollIfNeeded: function() {
|
||||
var touch = this.currentTouch;
|
||||
if (!touch || !this.inDragAction) {
|
||||
this.isScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollStep;
|
||||
var docScroll = this.scrollable.scrollTop;
|
||||
var distanceFromTop = Math.abs(touch.pageY - docScroll);
|
||||
var distanceFromHeader = distanceFromTop -
|
||||
(this.editHeaderElement ? this.editHeaderElement.clientHeight : 0);
|
||||
this.isScrolling = true;
|
||||
|
||||
if (distanceFromTop > SCREEN_HEIGHT - EDGE_PAGE_THRESHOLD) {
|
||||
var maxY = this.maxScroll;
|
||||
scrollStep = this.getScrollStep(SCREEN_HEIGHT - distanceFromTop);
|
||||
// We cannot exceed the maximum scroll value
|
||||
if (touch.pageY >= maxY || maxY - touch.pageY < scrollStep) {
|
||||
this.isScrolling = false;
|
||||
}
|
||||
} else if (touch.pageY > 0 && distanceFromHeader < EDGE_PAGE_THRESHOLD) {
|
||||
// We cannot go below the minimum scroll value
|
||||
scrollStep = -this.getScrollStep(distanceFromHeader);
|
||||
scrollStep = Math.max(scrollStep, -this.scrollable.scrollTop);
|
||||
if (scrollStep >= 0) {
|
||||
this.isScrolling = false;
|
||||
}
|
||||
} else {
|
||||
this.isScrolling = false;
|
||||
}
|
||||
|
||||
if (!this.isScrolling) {
|
||||
this.updateIconPosition();
|
||||
this.deferredRearrangeGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateIconPosition();
|
||||
this.scrollable.scrollTop += scrollStep;
|
||||
touch.pageY += scrollStep;
|
||||
|
||||
exports.requestAnimationFrame(this.positionAndScrollIfNeeded.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlights the group of the given icon index, or the group if the index
|
||||
* belongs to a group.
|
||||
* @param {Integer} index The grid index of the icon or group.
|
||||
*/
|
||||
highlightGroup: function(index) {
|
||||
for (var i = index, iLen = this.gridView.items.length;
|
||||
i < iLen; i++) {
|
||||
var item = this.gridView.items[i];
|
||||
if (item.detail.type === 'divider') {
|
||||
if (this.hoverGroup != item) {
|
||||
if (this.hoverGroup) {
|
||||
this.hoverGroup.element.classList.remove('drop-target');
|
||||
}
|
||||
this.hoverGroup = item;
|
||||
item.element.classList.add('drop-target');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
inFirstGroup: function(index) {
|
||||
for (var i = index; i >= 0; i--) {
|
||||
if (this.gridView.items[i].detail.type === 'divider') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Positions an icon on the grid using the current touch coordinates.
|
||||
*/
|
||||
updateIconPosition: function() {
|
||||
var oldX = this.icon.x;
|
||||
var oldY = this.icon.y;
|
||||
var newX = this.currentTouch.pageX - (this.initialPageX - this.icon.x);
|
||||
var newY = this.currentTouch.pageY - (this.initialPageY - this.icon.y);
|
||||
|
||||
// Adjust new icon coordinates for the slightly inflated scale so that
|
||||
// it appears centered around the touch point. Dividers aren't scaled
|
||||
// during dragging.
|
||||
if (this.icon.detail.type !== 'divider') {
|
||||
newX = newX - ((this.icon.scale * this.gridView.layout.gridItemWidth) -
|
||||
this.gridView.layout.gridItemWidth) / 2;
|
||||
newY = newY - ((this.icon.scale * this.icon.pixelHeight) -
|
||||
this.icon.pixelHeight) / 2;
|
||||
}
|
||||
|
||||
this.icon.setCoordinates(newX, newY);
|
||||
this.icon.render();
|
||||
this.icon.setCoordinates(oldX, oldY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Will call rearrangeGrid after a short period, calculated based on how
|
||||
* recently the grid was last rearranged.
|
||||
*/
|
||||
deferredRearrangeGrid: function() {
|
||||
if (this.rearrangeGridTimeout !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = Math.max(
|
||||
REARRANGE_FREQUENCY, REARRANGE_COOLDOWN -
|
||||
Math.max(0, Date.now() - this.lastRepositionTime));
|
||||
this.rearrangeGridTimeout = setTimeout(() => {
|
||||
this.rearrangeGridTimeout = null;
|
||||
this.rearrangeGrid();
|
||||
}, delay);
|
||||
},
|
||||
|
||||
/**
|
||||
* Rearranges the grid icons and groups based on the current position of
|
||||
* the dragged icon.
|
||||
*/
|
||||
rearrangeGrid: function() {
|
||||
if (this.rearrangeGridTimeout !== null) {
|
||||
clearTimeout(this.rearrangeGridTimeout);
|
||||
this.rearrangeGridTimeout = null;
|
||||
}
|
||||
|
||||
if (this.isScrolling) {
|
||||
// We don't want the grid to shift while we're auto-scrolling
|
||||
return;
|
||||
}
|
||||
|
||||
var iconIsDivider = this.icon.detail.type === 'divider';
|
||||
var pageX = this.currentTouch.pageX - (this.initialPageX - this.icon.x);
|
||||
var pageY = this.currentTouch.pageY - (this.initialPageY - this.icon.y);
|
||||
|
||||
// Reposition in the icons array if necessary.
|
||||
// Find the icon with the closest X/Y position of the move,
|
||||
// and insert ours before it.
|
||||
var foundIndex = 0;
|
||||
var insertDividerAtTop = !this.gridView.config.features.disableSections;
|
||||
pageX += this.gridView.layout.gridItemWidth / 2;
|
||||
pageY += this.icon.pixelHeight / 2;
|
||||
if (pageY >= 0) {
|
||||
foundIndex =
|
||||
this.gridView.getNearestItemIndex(pageX, pageY, iconIsDivider);
|
||||
|
||||
// If we're dragging a group over the first icon, a divider will
|
||||
// be inserted at the top.
|
||||
if (!(iconIsDivider && foundIndex === 0)) {
|
||||
this.container.classList.remove('hover-over-top');
|
||||
insertDividerAtTop = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the rearrange callback and hover item if we aren't hovering over
|
||||
// anything.
|
||||
this.doRearrange = null;
|
||||
if (this.rearrangeDelay) {
|
||||
clearTimeout(this.rearrangeDelay);
|
||||
this.rearrangeDelay = null;
|
||||
}
|
||||
if (this.hoverItem) {
|
||||
this.hoverItem.element.classList.remove('hovered');
|
||||
this.hoverItem = null;
|
||||
}
|
||||
|
||||
// Add the 'hovering' class to the dragged icon.
|
||||
if (foundIndex !== null) {
|
||||
this.icon.element.classList.add('hovering');
|
||||
} else {
|
||||
this.icon.element.classList.remove('hovering');
|
||||
}
|
||||
|
||||
// Nothing to do if we find the dragged icon or no icon
|
||||
if (!insertDividerAtTop &&
|
||||
(foundIndex === null || foundIndex === this.icon.detail.index)) {
|
||||
if (!iconIsDivider) {
|
||||
this.highlightGroup(this.icon.detail.index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var foundItem = this.gridView.items[foundIndex];
|
||||
|
||||
// If we're a divider being dragged, only allow dropping on either the
|
||||
// first item or another divider.
|
||||
if (iconIsDivider &&
|
||||
foundIndex > 0 &&
|
||||
foundItem.detail.type !== 'divider') {
|
||||
// Remove the hovering class from a group if it's not hovering over
|
||||
// a valid drop position or itself.
|
||||
this.icon.element.classList.remove('hovering');
|
||||
return;
|
||||
}
|
||||
|
||||
// If the item isn't a group, trigger the
|
||||
// hovered state on the found item.
|
||||
if (!insertDividerAtTop && !iconIsDivider) {
|
||||
this.hoverItem = foundItem;
|
||||
this.hoverItem.element.classList.add('hovered');
|
||||
}
|
||||
|
||||
// Delay rearranging when we're dragging groups to when they're dropped
|
||||
var rearrangeAfterDelay = !iconIsDivider;
|
||||
|
||||
// Determine if we want to create a new divider. We do this when hovering
|
||||
// over a divider with an icon, or when dragging an icon to the very top
|
||||
// of the grid.
|
||||
var createDivider = insertDividerAtTop;
|
||||
if (!insertDividerAtTop && !iconIsDivider &&
|
||||
(foundItem.detail.type === 'divider')) {
|
||||
// Allow dropping into a collapsed group if the new position is in
|
||||
// the top 2/3 of the group.
|
||||
if (foundItem.detail.collapsed &&
|
||||
pageY <= foundItem.y + (foundItem.pixelHeight * 2/3)) {
|
||||
rearrangeAfterDelay = false;
|
||||
foundItem.element.classList.remove('hovered');
|
||||
|
||||
// When dragging an item forwards, it gets placed after the found
|
||||
// item, adjust to the item before the divider.
|
||||
if (this.icon.detail.index < foundIndex) {
|
||||
foundItem = this.gridView.items[foundIndex - 1];
|
||||
}
|
||||
} else {
|
||||
createDivider = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (createDivider) {
|
||||
// Remove the group background highlight if we're between groups
|
||||
if (this.hoverGroup) {
|
||||
this.hoverGroup.element.classList.remove('drop-target');
|
||||
this.hoverGroup = null;
|
||||
}
|
||||
|
||||
// Cancel rearrangement if it would have no effect.
|
||||
var redundantRearrange = false;
|
||||
if (insertDividerAtTop) {
|
||||
if (iconIsDivider) {
|
||||
redundantRearrange = this.inFirstGroup(this.icon.detail.index - 1);
|
||||
} else {
|
||||
redundantRearrange =
|
||||
(this.icon.detail.index === 0 &&
|
||||
this.gridView.items[1].detail.type === 'placeholder');
|
||||
}
|
||||
} else {
|
||||
redundantRearrange = this.gridView.items[foundIndex].element.
|
||||
classList.contains('invalid-drop');
|
||||
}
|
||||
|
||||
if (redundantRearrange) {
|
||||
if (this.hoverItem) {
|
||||
this.hoverItem.element.classList.remove('hovered');
|
||||
this.hoverItem = null;
|
||||
}
|
||||
return;
|
||||
} else if (insertDividerAtTop) {
|
||||
this.container.classList.add('hover-over-top');
|
||||
}
|
||||
|
||||
this.doRearrange =
|
||||
this.createNewDivider.bind(this,
|
||||
insertDividerAtTop ? null : foundItem);
|
||||
} else {
|
||||
if (!iconIsDivider) {
|
||||
// Change the display of the group the icon is hovering over to
|
||||
// indicate it can be dropped.
|
||||
this.highlightGroup(foundIndex);
|
||||
}
|
||||
|
||||
this.doRearrange = this.rearrange.bind(this, foundItem);
|
||||
|
||||
if (rearrangeAfterDelay) {
|
||||
this.rearrangeDelay =
|
||||
setTimeout(this.doRearrange.bind(this), REARRANGE_DELAY);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new divider in GridView.items and rearranges the currently
|
||||
* dragged icon into the newly created section.
|
||||
* @param {Object} tDivider The divider after which to place a new divider,
|
||||
* or null to insert a divider at the beginning on the grid.
|
||||
*/
|
||||
createNewDivider: function(tDivider) {
|
||||
var items = this.gridView.items;
|
||||
var tIndex = tDivider ? items.indexOf(tDivider) + 1 : 0;
|
||||
|
||||
// Create the new divider
|
||||
var newDivider = new GaiaGrid.Divider();
|
||||
items.splice(tIndex, 0, newDivider);
|
||||
|
||||
// Place the dragged item into the new empty section
|
||||
var sIndex = items.indexOf(this.icon);
|
||||
this.rearrange(sIndex >= tIndex ? newDivider : tDivider);
|
||||
|
||||
// Make sure the new divider/group is in view
|
||||
newDivider.requestAttention();
|
||||
},
|
||||
|
||||
/**
|
||||
* Rearranges items in GridView.items
|
||||
* @param {Object} tItem The item to position the dragged icon at.
|
||||
*/
|
||||
rearrange: function(tItem) {
|
||||
// Clear the rearrange callback
|
||||
this.doRearrange = null;
|
||||
this.rearrangeDelay = null;
|
||||
|
||||
// We get a reference to the position of this.icon within the items
|
||||
// array. Because placeholders are shifting around while we are dragging,
|
||||
// we can't trust the detail.index attribute. This will be fixed on every
|
||||
// render call though.
|
||||
var sIndex = this.gridView.items.indexOf(this.icon);
|
||||
var tIndex = this.gridView.items.indexOf(tItem);
|
||||
|
||||
// Check how many items we need to rearrange (if it's a group, there will
|
||||
// be multiple items.
|
||||
var eIndex = sIndex;
|
||||
if (this.icon.detail.type === 'divider') {
|
||||
for (; sIndex > 0 &&
|
||||
this.gridView.items[sIndex - 1].detail.type !== 'divider';
|
||||
sIndex--) {}
|
||||
|
||||
// Modify the tIndex to make sure we're inserting immediately after
|
||||
// another divider or at the start of the container.
|
||||
if (this.gridView.items[tIndex].detail.type === 'divider') {
|
||||
tIndex++;
|
||||
} else {
|
||||
tIndex = 0;
|
||||
}
|
||||
|
||||
if (sIndex === tIndex) {
|
||||
// Nothing to do.
|
||||
return;
|
||||
}
|
||||
} else if (sIndex < tIndex) {
|
||||
tIndex++;
|
||||
}
|
||||
|
||||
// We may have changed tIndex, so refresh our item pointer
|
||||
tItem = this.gridView.items[tIndex];
|
||||
|
||||
// Remove the items from the array
|
||||
var lastItemOffset = eIndex - sIndex;
|
||||
var toInsert = this.gridView.items.splice(sIndex, lastItemOffset + 1);
|
||||
|
||||
// Craft the parameter list to reinsert the items at the correct place
|
||||
toInsert.unshift(tItem ? this.gridView.items.indexOf(tItem) :
|
||||
this.gridView.items.length, 0);
|
||||
|
||||
// Reinsert items
|
||||
this.rearrangeDelay = null;
|
||||
this.dirty = true;
|
||||
this.gridView.items.splice.apply(this.gridView.items, toInsert);
|
||||
|
||||
var oldX = this.icon.x;
|
||||
var oldY = this.icon.y;
|
||||
this.gridView.render();
|
||||
|
||||
// In this case, we don't want to compensate for the icon moving, so
|
||||
// we need to correct our initial values to stop the icon from jumping
|
||||
// after rearranging.
|
||||
this.initialPageX -= oldX - this.icon.x;
|
||||
this.initialPageY -= oldY - this.icon.y;
|
||||
|
||||
this.lastRepositionTime = Date.now();
|
||||
},
|
||||
|
||||
enterEditMode: function() {
|
||||
this.inEditMode = true;
|
||||
this.container.classList.add('edit-mode');
|
||||
document.body.classList.add('edit-mode');
|
||||
this.gridView.element.dispatchEvent(
|
||||
new CustomEvent('editmode-start'));
|
||||
document.addEventListener('visibilitychange', this);
|
||||
this.gridView.render();
|
||||
},
|
||||
|
||||
exitEditMode: function() {
|
||||
// If we're in the middle of a drag, cancel it.
|
||||
if (this.icon) {
|
||||
this.finish();
|
||||
this.finalize();
|
||||
}
|
||||
|
||||
this.inEditMode = false;
|
||||
this.cancelLongPressTimeout();
|
||||
this.container.classList.remove('edit-mode');
|
||||
document.body.classList.remove('edit-mode');
|
||||
this.gridView.element.dispatchEvent(new CustomEvent('editmode-end'));
|
||||
document.removeEventListener('visibilitychange', this);
|
||||
this.gridView.render();
|
||||
},
|
||||
|
||||
cancelLongPressTimeout: function() {
|
||||
if (this.longPressTimeout !== null) {
|
||||
clearTimeout(this.longPressTimeout);
|
||||
this.longPressTimeout = null;
|
||||
}
|
||||
},
|
||||
|
||||
inLongPressThreshold: function(x, y) {
|
||||
return (
|
||||
Math.abs(x - this.touchStart.screenX) < EDIT_LONG_PRESS_THRESHOLD &&
|
||||
Math.abs(y - this.touchStart.screenY) < EDIT_LONG_PRESS_THRESHOLD);
|
||||
},
|
||||
|
||||
/**
|
||||
* General event handler.
|
||||
*/
|
||||
handleEvent: function(e) {
|
||||
switch(e.type) {
|
||||
case 'visibilitychange':
|
||||
if (document.hidden) {
|
||||
this.exitEditMode();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'touchstart':
|
||||
this.cancelLongPressTimeout();
|
||||
|
||||
if (e.touches.length > 1) {
|
||||
if (this.inDragAction) {
|
||||
this.finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inEditMode && !this.icon) {
|
||||
this.touchStart.x = e.touches[0].pageX;
|
||||
this.touchStart.y = e.touches[0].pageY;
|
||||
this.touchStart.screenX = e.touches[0].screenX;
|
||||
this.touchStart.screenY = e.touches[0].screenY;
|
||||
this.longPressTimeout = setTimeout(() => {
|
||||
this.longPressTimeout = null;
|
||||
this.handleEvent({
|
||||
type: 'contextmenu',
|
||||
target: e.target,
|
||||
pageX: this.touchStart.x,
|
||||
pageY: this.touchStart.y,
|
||||
preventDefault: function() {},
|
||||
stopImmediatePropagation: function() {}
|
||||
});
|
||||
}, EDIT_LONG_PRESS_DELAY);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contextmenu':
|
||||
this.cancelLongPressTimeout();
|
||||
|
||||
if (this.icon) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented) {
|
||||
// other handlers already handled this.
|
||||
// in the future, we should use the shadow root and dispatch a
|
||||
// "contextmenu" event from here instead.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.icon = this.gridView.findItemFromElement(e.target, true);
|
||||
|
||||
if (!this.icon || !this.icon.element || !this.icon.isDraggable() ||
|
||||
this.icon.detail.type === 'placeholder') {
|
||||
this.icon = null;
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
this.target = this.icon.element;
|
||||
this.begin(e);
|
||||
|
||||
break;
|
||||
|
||||
case 'touchmove':
|
||||
var touch = e.touches[0];
|
||||
|
||||
if (this.inDragAction) {
|
||||
this.currentTouch = {
|
||||
pageX: touch.pageX,
|
||||
pageY: touch.pageY
|
||||
};
|
||||
|
||||
if (!this.isScrolling) {
|
||||
this.positionAndScrollIfNeeded();
|
||||
}
|
||||
} else if (this.longPressTimeout !== null &&
|
||||
!this.inLongPressThreshold(touch.screenX,
|
||||
touch.screenY)) {
|
||||
this.cancelLongPressTimeout();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
case 'touchcancel':
|
||||
if (this.inDragAction) {
|
||||
this.finish();
|
||||
this.finalize();
|
||||
}
|
||||
|
||||
this.cancelLongPressTimeout();
|
||||
break;
|
||||
|
||||
case 'touchend':
|
||||
if (this.inDragAction) {
|
||||
// Ensure the app is not launched
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// As contextmenu event can be synthesized, it's possible for it
|
||||
// to happen in the same event-loop as touchend, meaning
|
||||
// finish would be called immediately after begin. This would
|
||||
// mean that the icon doesn't get a chance to transition and
|
||||
// the transitionend that we expect won't be called, so finalize
|
||||
// also won't be called, leaving us in an inconsistent state.
|
||||
// It would be so great if there was a transitionstart event :(
|
||||
setTimeout(() => { this.finish(); }, TOUCH_END_FINISH_DELAY);
|
||||
}
|
||||
|
||||
this.cancelLongPressTimeout();
|
||||
break;
|
||||
|
||||
case 'transitionend':
|
||||
this.finalize();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.GridDragDrop = DragDrop;
|
||||
|
||||
}(window));
|
||||
171
gaia/shared/elements/gaia_grid/js/grid_icon_renderer.js
Executable file
|
|
@ -0,0 +1,171 @@
|
|||
'use strict';
|
||||
/* global devicePixelRatio */
|
||||
/* global Promise */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const SHADOW_BLUR = 1;
|
||||
const SHADOW_OFFSET_Y = 1;
|
||||
const SHADOW_OFFSET_X = 1;
|
||||
const SHADOW_COLOR = 'rgba(0, 0, 0, 0.2)';
|
||||
const DEFAULT_BACKGROUND_COLOR = 'rgb(228, 234, 238)';
|
||||
const UNSCALED_CANVAS_PADDING = 2;
|
||||
const CANVAS_PADDING = UNSCALED_CANVAS_PADDING * devicePixelRatio;
|
||||
|
||||
function IconRenderer(icon) {
|
||||
this._icon = icon;
|
||||
}
|
||||
|
||||
IconRenderer.TYPE = {
|
||||
CLIP: 'clip',
|
||||
FAVICON: 'favicon',
|
||||
STANDARD: 'standard',
|
||||
};
|
||||
|
||||
IconRenderer.prototype = {
|
||||
|
||||
// Export necessary defines. Left + right padding
|
||||
unscaledCanvasPadding: UNSCALED_CANVAS_PADDING * 2,
|
||||
|
||||
/**
|
||||
* Returns the max size for an icon based on grid size which is based on
|
||||
* pixel ratio.
|
||||
*/
|
||||
get _maxSize() {
|
||||
return this._icon.grid.layout.gridMaxIconSize;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a properly sized canvas to match the maxSize + padding
|
||||
* for shadows.
|
||||
* @return {HTMLCanvasElement}
|
||||
*/
|
||||
_createCanvas: function() {
|
||||
// To get the smoother rounded circle for each icon, adding one pixel
|
||||
// padding in bottom to support different size of icon images
|
||||
const CANVAS_PADDING_BOTTOM = 1 * devicePixelRatio;
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = this._maxSize + (CANVAS_PADDING * 2);
|
||||
canvas.height = this._maxSize + CANVAS_PADDING + CANVAS_PADDING_BOTTOM;
|
||||
return canvas;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a properly sized canvas to match the maxSize + padding
|
||||
* for clip.
|
||||
* @return {HTMLCanvasElement}
|
||||
*/
|
||||
_createClipCanvas: function() {
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = this._maxSize + (CANVAS_PADDING * 2);
|
||||
canvas.height = this._maxSize + (CANVAS_PADDING * 2);
|
||||
return canvas;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets definitions for the shadow canvas.
|
||||
* @return {CanvasRenderingContext2D}
|
||||
*/
|
||||
_decorateShadowCanvas: function(canvas) {
|
||||
var ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
ctx.shadowColor = SHADOW_COLOR;
|
||||
ctx.shadowBlur = SHADOW_BLUR;
|
||||
ctx.shadowOffsetY = SHADOW_OFFSET_Y;
|
||||
ctx.shadowOffsetX = SHADOW_OFFSET_X;
|
||||
|
||||
return ctx;
|
||||
},
|
||||
|
||||
/**
|
||||
* Draws a clipped icon.
|
||||
* A clipped icon is generally a full-sized icon, with rounded corners
|
||||
* and a drop shadow.
|
||||
* @param {HTMLImageElement} img An image element to display from.
|
||||
* @return {Promise}
|
||||
*/
|
||||
clip: function(img) {
|
||||
return new Promise((resolve) => {
|
||||
var shadowCanvas = this._createCanvas();
|
||||
var shadowCtx = this._decorateShadowCanvas(shadowCanvas);
|
||||
|
||||
var clipCanvas = this._createClipCanvas();
|
||||
var clipCtx = clipCanvas.getContext('2d',
|
||||
{ willReadFrequently: true });
|
||||
|
||||
clipCtx.beginPath();
|
||||
clipCtx.arc(clipCanvas.width / 2, clipCanvas.height / 2,
|
||||
clipCanvas.height / 2,
|
||||
0, 2 * Math.PI);
|
||||
clipCtx.clip();
|
||||
clipCtx.drawImage(img, 0, 0,
|
||||
clipCanvas.width, clipCanvas.height);
|
||||
|
||||
shadowCtx.drawImage(clipCanvas, CANVAS_PADDING, CANVAS_PADDING,
|
||||
this._maxSize, this._maxSize);
|
||||
shadowCanvas.toBlob(resolve);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Draws a favicon for the grid.
|
||||
* Favicons are generally smaller, and drawn on top of a background.
|
||||
* @param {HTMLImageElement} img An image element to display from.
|
||||
* @return {Promise}
|
||||
*/
|
||||
favicon: function(img) {
|
||||
|
||||
// If we have a decent sized image, we want to clip instead.
|
||||
if (img.width > this._icon.grid.layout.gridIconSize / 2) {
|
||||
return this.clip(img);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
var shadowCanvas = this._createCanvas();
|
||||
var shadowCtx = this._decorateShadowCanvas(shadowCanvas);
|
||||
|
||||
var iconWidth;
|
||||
var iconHeight;
|
||||
|
||||
// Draws Shadow and default background circle
|
||||
shadowCtx.beginPath();
|
||||
shadowCtx.arc(shadowCanvas.width / 2,
|
||||
shadowCanvas.height / 2,
|
||||
shadowCanvas.height / 2 - CANVAS_PADDING,
|
||||
0, 2 * Math.PI, false);
|
||||
shadowCtx.fillStyle = DEFAULT_BACKGROUND_COLOR;
|
||||
shadowCtx.fill();
|
||||
|
||||
// Draws favicon with antinaliasing disabled
|
||||
iconWidth = iconHeight = this._maxSize * 0.55;
|
||||
// Disable smoothing on icon resize
|
||||
shadowCtx.shadowBlur = 0;
|
||||
shadowCtx.shadowOffsetY = 0;
|
||||
shadowCtx.mozImageSmoothingEnabled = false;
|
||||
shadowCtx.drawImage(img, (shadowCanvas.width - iconWidth) / 2,
|
||||
(shadowCanvas.height - iconHeight) / 2,
|
||||
iconWidth, iconHeight);
|
||||
shadowCanvas.toBlob(resolve);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Draws a standard icon for the grid.
|
||||
* Normal icons maintain their shape and are only given a shadow.
|
||||
* @param {HTMLImageElement} img An image element to display from.
|
||||
* @return {Promise}
|
||||
*/
|
||||
standard: function(img) {
|
||||
return new Promise((resolve) => {
|
||||
var shadowCanvas = this._createCanvas();
|
||||
var shadowCtx = this._decorateShadowCanvas(shadowCanvas);
|
||||
shadowCtx.drawImage(img, CANVAS_PADDING, CANVAS_PADDING,
|
||||
this._maxSize, this._maxSize);
|
||||
shadowCanvas.toBlob(resolve);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.GridIconRenderer = IconRenderer;
|
||||
|
||||
}(window));
|
||||
193
gaia/shared/elements/gaia_grid/js/grid_layout.js
Executable file
|
|
@ -0,0 +1,193 @@
|
|||
'use strict';
|
||||
/* global devicePixelRatio */
|
||||
/* global verticalPreferences */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const maxIconsPerRow = 4;
|
||||
|
||||
// 320 / 5 = 64px | 480 / 5 = 96px | 540 / 5 = 108px | ...
|
||||
const iconScaleFactorMaxIconsPerRow = 5;
|
||||
|
||||
const minIconsPerRow = 3;
|
||||
|
||||
// 320 / 3.8 = 84px | 480 / 3.8 = 126px | 540 / 3.8 = 142px | ...
|
||||
const iconScaleFactorMinIconsPerRow = 3.8;
|
||||
|
||||
const distanceBetweenIconsWithMinIconsPerRow = 36;
|
||||
|
||||
const distanceBetweenIconsWithMaxIconsPerRow = 38;
|
||||
|
||||
const eachTextRowHeight = 20;
|
||||
|
||||
var constraintSize;
|
||||
|
||||
function GridLayout(gridView) {
|
||||
this.gridView = gridView;
|
||||
|
||||
if (window.verticalPreferences) {
|
||||
verticalPreferences.get('grid.cols').then(value => {
|
||||
this.cols = value;
|
||||
this.onReady();
|
||||
}, this.onReady);
|
||||
verticalPreferences.addEventListener('updated', this);
|
||||
} else {
|
||||
this.onReady();
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout.prototype = {
|
||||
|
||||
minIconsPerRow: minIconsPerRow,
|
||||
|
||||
maxIconsPerRow: maxIconsPerRow,
|
||||
|
||||
_cols: minIconsPerRow,
|
||||
|
||||
_offsetY: 0,
|
||||
|
||||
_percent: 1,
|
||||
|
||||
/**
|
||||
* The visible height of each divider.
|
||||
* Calculated by the divider class and cached here.
|
||||
*/
|
||||
_dividerLineHeight: 0,
|
||||
|
||||
get percent() {
|
||||
return this._percent;
|
||||
},
|
||||
|
||||
get cols() {
|
||||
return this._cols;
|
||||
},
|
||||
|
||||
set cols(value) {
|
||||
if (!value || value === this._cols) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.verticalPreferences) {
|
||||
verticalPreferences.put('grid.cols', value);
|
||||
}
|
||||
|
||||
// Reset the y-offset because we will re-render everything anyway.
|
||||
this._offsetY = 0;
|
||||
|
||||
this._percent = value == minIconsPerRow ? 1 :
|
||||
minIconsPerRow / maxIconsPerRow;
|
||||
this._cols = value;
|
||||
},
|
||||
|
||||
/**
|
||||
* The width of the grid.
|
||||
*/
|
||||
get constraintSize() {
|
||||
if (!constraintSize) {
|
||||
this.calculateSize();
|
||||
}
|
||||
return constraintSize;
|
||||
},
|
||||
|
||||
/**
|
||||
* The height of each grid item.
|
||||
*/
|
||||
get gridItemHeight() {
|
||||
var height = this.gridIconSize +
|
||||
(this._cols === minIconsPerRow ?
|
||||
distanceBetweenIconsWithMinIconsPerRow :
|
||||
distanceBetweenIconsWithMaxIconsPerRow);
|
||||
|
||||
// Increase height for additional rows of text.
|
||||
var defaultTextRows = 2;
|
||||
var definedRows = parseInt(
|
||||
this.gridView.element.getAttribute('text-rows'), 10);
|
||||
if (definedRows > defaultTextRows) {
|
||||
height += (definedRows - defaultTextRows) * eachTextRowHeight;
|
||||
}
|
||||
|
||||
return height;
|
||||
},
|
||||
|
||||
/**
|
||||
* The width of each grid item.
|
||||
* This number changes based on current zoom level.
|
||||
*/
|
||||
get gridItemWidth() {
|
||||
return this.constraintSize / this._cols;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the maximum size in pixels for an icon image. It is the size when
|
||||
* the grid is displayed with the minimum number of columns per row.
|
||||
*/
|
||||
get gridMaxIconSize() {
|
||||
var baseSize = (this.constraintSize / iconScaleFactorMinIconsPerRow);
|
||||
return baseSize * devicePixelRatio;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the icon image size depending on grid configuration and screen
|
||||
* characteristics.
|
||||
*/
|
||||
get gridIconSize() {
|
||||
var numCols = this._cols;
|
||||
|
||||
var size = this.constraintSize / numCols;
|
||||
if (numCols === minIconsPerRow) {
|
||||
size = this.constraintSize / iconScaleFactorMinIconsPerRow;
|
||||
} else if (numCols === maxIconsPerRow) {
|
||||
size = this.constraintSize / iconScaleFactorMaxIconsPerRow;
|
||||
}
|
||||
return Math.floor(size);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the current offset of the Y-axis for the current zoom level.
|
||||
* This value is updated by calling zoom.stepYAxis. For example, each
|
||||
* group of three icons, or a divider, should increment this value.
|
||||
* The value is reset and recalculated when the zoom level changes.
|
||||
*/
|
||||
get offsetY() {
|
||||
return this._offsetY;
|
||||
},
|
||||
|
||||
set offsetY(value) {
|
||||
this._offsetY = value;
|
||||
},
|
||||
|
||||
/**
|
||||
* After we render a row we need to store the current position of the y-axis
|
||||
*/
|
||||
stepYAxis: function(value) {
|
||||
this._offsetY += value;
|
||||
},
|
||||
|
||||
/**
|
||||
* General event handler.
|
||||
*/
|
||||
handleEvent: function(e) {
|
||||
switch(e.type) {
|
||||
case 'updated':
|
||||
var prop = e.target;
|
||||
if (prop.name === 'grid.cols') {
|
||||
this.cols = parseInt(prop.value, 10);
|
||||
this.gridView.render();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
calculateSize: function() {
|
||||
constraintSize = Math.min(window.screen.width, window.screen.height);
|
||||
},
|
||||
|
||||
onReady: function() {
|
||||
window.dispatchEvent(new CustomEvent('gaiagrid-layout-ready'));
|
||||
}
|
||||
};
|
||||
|
||||
exports.GridLayout = GridLayout;
|
||||
|
||||
}(window));
|
||||
588
gaia/shared/elements/gaia_grid/js/grid_view.js
Executable file
|
|
@ -0,0 +1,588 @@
|
|||
'use strict';
|
||||
/* global GridDragDrop */
|
||||
/* global GaiaGrid */
|
||||
/* global GridLayout */
|
||||
/* global GridZoom */
|
||||
/* global LazyLoader */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
/* The time for which we'll disable launching other icons after tapping on
|
||||
* an app icon. This will also disable edit mode.
|
||||
*/
|
||||
const APP_LAUNCH_TIMEOUT = 3000;
|
||||
|
||||
/**
|
||||
* GridView is a generic class to render and display a grid of items.
|
||||
* @param {Object} config Configuration object containing:
|
||||
* - element: The shadow root of the grid
|
||||
*/
|
||||
function GridView(config) {
|
||||
this.config = config;
|
||||
this.clickIcon = this.clickIcon.bind(this);
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
|
||||
if (config.features.zoom) {
|
||||
this.zoom = new GridZoom(this);
|
||||
}
|
||||
|
||||
this.layout = new GridLayout(this);
|
||||
|
||||
// Set columns if we have a 'cols' attribute
|
||||
if (config.element.hasAttribute('cols')) {
|
||||
this.layout.cols = parseInt(config.element.getAttribute('cols'), 10);
|
||||
}
|
||||
|
||||
// Enable event listeners when instantiated.
|
||||
this.start();
|
||||
}
|
||||
|
||||
GridView.prototype = {
|
||||
/**
|
||||
* List of all application icons.
|
||||
* Maps an icon identifier to an icon object.
|
||||
*/
|
||||
icons: {},
|
||||
|
||||
/**
|
||||
* Lists of all displayed objects in the homescreen.
|
||||
* Includes app icons, dividers, and bookmarks.
|
||||
*/
|
||||
items: [],
|
||||
|
||||
/**
|
||||
* Returns a reference to the gaia-grid element.
|
||||
*/
|
||||
get element() {
|
||||
return this.config.element;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the number of columns.
|
||||
*/
|
||||
set cols(value) {
|
||||
this.layout.cols = value;
|
||||
},
|
||||
|
||||
/**
|
||||
* We are in the state of launching an app.
|
||||
*/
|
||||
_launchingApp: false,
|
||||
|
||||
/**
|
||||
* Adds an item into the items array.
|
||||
* If the item is an icon, add it to icons.
|
||||
* @param {Object} item The grid object, should inherit from GridItem.
|
||||
* @param {Object} insertTo The position to insert the item into our list.
|
||||
* @param {Boolean} expandGroup Expand the group this item is will be in.
|
||||
*/
|
||||
add: function(item, insertTo, expandGroup) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.identifier) {
|
||||
// If we already have an item with this identifier, exit.
|
||||
// This avoids a potential race condition where we might have duplicate
|
||||
// items with the same identifiers in the grid. This should not happen.
|
||||
if (this.icons[item.identifier]) {
|
||||
console.log('Error, duplicate identifier: ',
|
||||
item.identifier, new Error().stack);
|
||||
return;
|
||||
}
|
||||
|
||||
this.icons[item.identifier] = item;
|
||||
} else if (item.detail.type !== 'divider' &&
|
||||
item.detail.type !== 'placeholder') {
|
||||
// If the item does not have an identifier, and is not a placeholder
|
||||
// or divider, do not add it to the grid.
|
||||
console.log('Error, could not load identifier for object: ',
|
||||
JSON.stringify(item.detail));
|
||||
return;
|
||||
}
|
||||
|
||||
// If insertTo it is a number, splice.
|
||||
if (!isNaN(parseFloat(insertTo)) && isFinite(insertTo)) {
|
||||
this.items.splice(insertTo, 0, item);
|
||||
} else {
|
||||
insertTo = this.items.length;
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
if (expandGroup) {
|
||||
for (var i = insertTo + 1, iLen = this.items.length;
|
||||
i < iLen; i++) {
|
||||
var divider = this.items[i];
|
||||
if (divider.detail.type === 'divider') {
|
||||
if (divider.detail.collapsed) {
|
||||
divider.expand();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds nearest item by and returns an index, or null if an item isn't
|
||||
* found.
|
||||
* @param {Number} x relative to the screen
|
||||
* @param {Number} y relative to the screen
|
||||
* @param {Boolean} isRow whether the position is to be considered as a row
|
||||
* instead of an individual point.
|
||||
*/
|
||||
getNearestItemIndex: function(x, y, isRow) {
|
||||
var foundIndex = null;
|
||||
var leastDistance = null;
|
||||
var itemMiddleOffset = this.layout.gridItemWidth / 2;
|
||||
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
|
||||
var item = this.items[i];
|
||||
|
||||
// Don't consider items that can't be dragged
|
||||
if (!item.isDraggable()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If sections are disabled, don't consider dividers
|
||||
if (this.config.features.disableSections &&
|
||||
item.detail.type === 'divider') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not consider collapsed items, unless they are dividers.
|
||||
if (item.detail.type !== 'divider' &&
|
||||
item.element.classList.contains('collapsed')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var middleX = item.x + itemMiddleOffset;
|
||||
var middleY = item.y + item.pixelHeight / 2;
|
||||
|
||||
var xDistance = (isRow || item.detail.type === 'divider') ?
|
||||
0 : x - middleX;
|
||||
var yDistance = y - middleY;
|
||||
|
||||
var distance = Math.sqrt(
|
||||
xDistance * xDistance +
|
||||
yDistance * yDistance);
|
||||
if (leastDistance === null || distance < leastDistance) {
|
||||
leastDistance = distance;
|
||||
foundIndex = i;
|
||||
}
|
||||
}
|
||||
return foundIndex;
|
||||
},
|
||||
|
||||
start: function() {
|
||||
this.element.addEventListener('click', this.clickIcon);
|
||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
},
|
||||
|
||||
stop: function() {
|
||||
this.element.removeEventListener('click', this.clickIcon);
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
},
|
||||
|
||||
findItemFromElement: function(element, excludeCollapsedIcons) {
|
||||
while (element && element.parentNode !== this.element) {
|
||||
element = element.parentNode;
|
||||
}
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var i, iLen = this.items.length;
|
||||
var identifier = element.dataset.identifier;
|
||||
var icon = this.icons[identifier];
|
||||
|
||||
// If the element didn't have an identifier, try to search for it
|
||||
// manually.
|
||||
if (!icon) {
|
||||
for (i = 0; i < iLen; i++) {
|
||||
if (this.items[i].element === element) {
|
||||
icon = this.items[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (icon && excludeCollapsedIcons) {
|
||||
// If this is a collapsed item, return its group instead
|
||||
if (icon.detail.type !== 'divider' &&
|
||||
icon.detail.type !== 'placeholder' &&
|
||||
icon.element.classList.contains('collapsed')) {
|
||||
for (i = icon.detail.index + 1; i < iLen; i++) {
|
||||
if (this.items[i].detail.type === 'divider') {
|
||||
return this.items[i];
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Collapsed icon found with no group');
|
||||
icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
return icon;
|
||||
},
|
||||
|
||||
onVisibilityChange: function() {
|
||||
this._launchingApp = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*/
|
||||
clickIcon: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var inEditMode = this.dragdrop && this.dragdrop.inEditMode;
|
||||
|
||||
var action = 'launch';
|
||||
if (e.target.classList.contains('remove')) {
|
||||
action = 'remove';
|
||||
}
|
||||
var icon = this.findItemFromElement(e.target);
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'launch') {
|
||||
// We do not allow users to launch icons in edit mode
|
||||
if (inEditMode && e.target.classList.contains('icon')) {
|
||||
// Check if we're trying to edit a bookmark
|
||||
if (!icon.isEditable()) {
|
||||
return;
|
||||
}
|
||||
action = 'edit';
|
||||
} else {
|
||||
// If the icon can't be launched, bail out early
|
||||
if (!icon[action]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a 'launching' class to the icon to style it with CSS.
|
||||
icon.element.classList.add('launching');
|
||||
|
||||
// XXX: We can't have nice things. Remove the launching class after an
|
||||
// arbitrary time to restore the state. We want the icon to return
|
||||
// to its original state after launching the app, but visibilitychange
|
||||
// will not work because activities do not fire it.
|
||||
var returnTimeout = 500;
|
||||
setTimeout(function stateReturn() {
|
||||
if (icon.element) {
|
||||
icon.element.classList.remove('launching');
|
||||
}
|
||||
}, returnTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
if ((icon.detail.type === 'app' || icon.detail.type === 'bookmark') &&
|
||||
this._launchingApp) {
|
||||
return;
|
||||
}
|
||||
if ((icon.detail.type === 'app' && icon.appState === 'ready') ||
|
||||
icon.detail.type === 'bookmark') {
|
||||
this._launchingApp = true;
|
||||
if (this._launchingTimeout) {
|
||||
window.clearTimeout(this._launchingTimeout);
|
||||
this._launchingTimeout = null;
|
||||
}
|
||||
// This avoids some edge cases if we didn't get visibilitychange anyway.
|
||||
this._launchingTimeout = window.setTimeout(function() {
|
||||
this._launchingTimeout = null;
|
||||
this._launchingApp = false;
|
||||
}.bind(this), APP_LAUNCH_TIMEOUT);
|
||||
}
|
||||
|
||||
icon[action](e.target);
|
||||
},
|
||||
|
||||
/**
|
||||
* Scrubs the list of items, removing empty sections.
|
||||
*/
|
||||
cleanItems: function(skipDivider) {
|
||||
var appCount = 0;
|
||||
var toRemove = [];
|
||||
|
||||
this.items.forEach(function(item, idx) {
|
||||
if (item instanceof GaiaGrid.Divider) {
|
||||
if (appCount === 0) {
|
||||
toRemove.push(idx);
|
||||
}
|
||||
appCount = 0;
|
||||
} else {
|
||||
appCount++;
|
||||
}
|
||||
}, this);
|
||||
|
||||
toRemove.reverse();
|
||||
toRemove.forEach(function(idx) {
|
||||
var removed = this.items.splice(idx, 1)[0];
|
||||
removed.remove();
|
||||
}, this);
|
||||
|
||||
// There should always be a divider at the end, it's hidden in CSS when
|
||||
// not in edit mode.
|
||||
if (skipDivider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the last item is not a divider, add a new divider at the end.
|
||||
var lastItem = this.items[this.items.length - 1];
|
||||
if (!lastItem || !(lastItem instanceof GaiaGrid.Divider)) {
|
||||
this.items.push(new GaiaGrid.Divider());
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes placeholders from the grid.
|
||||
*/
|
||||
removeAllPlaceholders: function() {
|
||||
var toSplice = [];
|
||||
var previousItem;
|
||||
this.items.forEach(function(item, idx) {
|
||||
if (item instanceof GaiaGrid.Placeholder) {
|
||||
|
||||
// If the previous item is a divider, or there is no previous item,
|
||||
// and we are in edit mode, we do not remove the placeholder.
|
||||
// This is so the section will remain even if the user drags the
|
||||
// icon around. Bug 1014982
|
||||
if ((!previousItem ||
|
||||
(previousItem && previousItem instanceof GaiaGrid.Divider)) &&
|
||||
this.dragdrop && this.dragdrop.inDragAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
toSplice.push(idx);
|
||||
}
|
||||
|
||||
previousItem = item;
|
||||
}, this);
|
||||
|
||||
toSplice.reverse().forEach(function(idx) {
|
||||
this.items.splice(idx, 1)[0].remove();
|
||||
}, this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the grid view of all items.
|
||||
*/
|
||||
clear: function() {
|
||||
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
|
||||
var item = this.items[i];
|
||||
if (item.element) {
|
||||
this.element.removeChild(item.element);
|
||||
|
||||
// We must de-reference element explicitly so we can re-use item
|
||||
// objects the next time we call render.
|
||||
item.element = null;
|
||||
item.lastX = null;
|
||||
item.lastY = null;
|
||||
item.lastScale = null;
|
||||
}
|
||||
}
|
||||
this.items = [];
|
||||
this.icons = {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates placeholders and injects them into the grid.
|
||||
* @param {Array} coordinates [x,y] coordinates on the grid of the first
|
||||
* item in grid units.
|
||||
* @param {Integer} idx The position of the first placeholder.
|
||||
* @param {Integer} idx The number of placeholders to create.
|
||||
*/
|
||||
createPlaceholders: function(coordinates, idx, count) {
|
||||
var isRTL = (document.documentElement.dir === 'rtl');
|
||||
for (var i = 0; i < count; i++) {
|
||||
var item = new GaiaGrid.Placeholder();
|
||||
this.items.splice(idx + i, 0, item);
|
||||
item.setPosition(idx + i);
|
||||
|
||||
var xPosition = (coordinates[0] + i) * this.layout.gridItemWidth;
|
||||
if (isRTL) {
|
||||
xPosition =
|
||||
(this.layout.constraintSize - this.layout.gridItemWidth) -
|
||||
xPosition;
|
||||
}
|
||||
item.setCoordinates(xPosition, this.layout.offsetY);
|
||||
|
||||
item.render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders all icons.
|
||||
* Positions app icons and dividers accoriding to available space
|
||||
* on the grid.
|
||||
* @param {Object} options Options to render with including:
|
||||
* - skipDivider {Boolean} Whether or not to skip the divider
|
||||
* - rerender {Boolean} Whether we should clean elements and re-render.
|
||||
*/
|
||||
render: function(options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
|
||||
this.removeAllPlaceholders();
|
||||
this.cleanItems(options.skipDivider);
|
||||
|
||||
// Reset offset steps
|
||||
var oldHeight = this.layout.offsetY;
|
||||
this.layout.offsetY = 0;
|
||||
|
||||
// Grid render coordinates
|
||||
var x = 0;
|
||||
var y = 0;
|
||||
|
||||
/**
|
||||
* Steps the y-axis.
|
||||
* @param {Object} item
|
||||
*/
|
||||
function step(item) {
|
||||
var pixelHeight = item.pixelHeight;
|
||||
self.layout.stepYAxis(pixelHeight);
|
||||
|
||||
x = 0;
|
||||
y++;
|
||||
}
|
||||
|
||||
var pendingCachedIcons = 0;
|
||||
var onCachedIconRendered = () => {
|
||||
if (--pendingCachedIcons <= 0) {
|
||||
this.element.removeEventListener('cached-icon-rendered',
|
||||
onCachedIconRendered);
|
||||
this.element.dispatchEvent(new CustomEvent('cached-icons-rendered'));
|
||||
}
|
||||
};
|
||||
this.element.addEventListener('cached-icon-rendered',
|
||||
onCachedIconRendered);
|
||||
|
||||
var nextDivider = null;
|
||||
var oddDivider = true;
|
||||
var isRTL = (document.documentElement.dir === 'rtl');
|
||||
for (var idx = 0; idx < this.items.length; idx++) {
|
||||
var item = this.items[idx];
|
||||
|
||||
// Remove the element if we are re-rendering.
|
||||
if (options.rerender && item.element) {
|
||||
this.element.removeChild(item.element);
|
||||
item.element = null;
|
||||
}
|
||||
|
||||
if (item.detail.type === 'divider') {
|
||||
nextDivider = null;
|
||||
} else {
|
||||
if (!nextDivider) {
|
||||
for (var i = idx + 1; i < this.items.length; i++) {
|
||||
if (this.items[i].detail.type === 'divider') {
|
||||
nextDivider = this.items[i];
|
||||
oddDivider = !oddDivider;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure to leave room for group headers
|
||||
if (nextDivider &&
|
||||
!nextDivider.detail.collapsed) {
|
||||
this.layout.offsetY += nextDivider.headerHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// If this item is in a collapsed group, we need to skip rendering.
|
||||
if (nextDivider && nextDivider.detail.collapsed) {
|
||||
item.setPosition(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the item would go over the boundary before rendering,
|
||||
// step the y-axis.
|
||||
if (x > 0 && item.gridWidth > 1 &&
|
||||
x + item.gridWidth >= this.layout.cols) {
|
||||
|
||||
// Insert placeholders to fill remaining space
|
||||
var remaining = this.layout.cols - x;
|
||||
this.createPlaceholders([x, y], idx, remaining);
|
||||
|
||||
// Increment the current index due to divider insertion
|
||||
idx += remaining;
|
||||
item = this.items[idx];
|
||||
|
||||
// Step the y-axis by the size of the last row.
|
||||
// For now we just check the height of the last item.
|
||||
var lastItemInRow = this.items[idx - 1];
|
||||
step(lastItemInRow);
|
||||
}
|
||||
|
||||
item.setPosition(idx);
|
||||
|
||||
if (!options.skipItems) {
|
||||
item.hasCachedIcon && ++pendingCachedIcons;
|
||||
var xPosition = x * this.layout.gridItemWidth;
|
||||
if (isRTL) {
|
||||
xPosition =
|
||||
(this.layout.constraintSize - this.layout.gridItemWidth) -
|
||||
xPosition;
|
||||
}
|
||||
item.setCoordinates(xPosition, this.layout.offsetY);
|
||||
if (!item.active) {
|
||||
item.render();
|
||||
}
|
||||
|
||||
if (item.detail.type === 'divider') {
|
||||
if (oddDivider) {
|
||||
item.element.classList.add('odd');
|
||||
} else {
|
||||
item.element.classList.remove('odd');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment the x-step by the sizing of the item.
|
||||
// If we go over the current boundary, reset it, and step the y-axis.
|
||||
x += item.gridWidth;
|
||||
if ((x >= this.layout.cols) && (idx < this.items.length - 1)) {
|
||||
step(item);
|
||||
}
|
||||
}
|
||||
|
||||
// All the children of this element are absolutely positioned and then
|
||||
// transformed, so the grid actually has no height. Fire an event that
|
||||
// embedders can listen to discover the grid height.
|
||||
if (this.layout.offsetY != oldHeight) {
|
||||
if (this.dragdrop && this.dragdrop.inDragAction) {
|
||||
// Delay size changes during drags to avoid jankiness when dragging
|
||||
// items around due to touch positions changing.
|
||||
this.layout.offsetY = oldHeight;
|
||||
} else {
|
||||
this.element.dispatchEvent(new CustomEvent('gaiagrid-resize',
|
||||
{ detail: this.layout.offsetY }));
|
||||
}
|
||||
}
|
||||
|
||||
this.element.setAttribute('cols', this.layout.cols);
|
||||
pendingCachedIcons === 0 && onCachedIconRendered();
|
||||
this.loadDragDrop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads dragdrop libraries and instantiates if necessary.
|
||||
* DragDrop libraries are lazy laoded to save on startup time. They are
|
||||
* loaded after the initial paint in order to paint the icons as fast
|
||||
* as possible.
|
||||
*/
|
||||
loadDragDrop: function() {
|
||||
if (!this.dragdrop && this.config.features.dragdrop) {
|
||||
LazyLoader.load('shared/elements/gaia_grid/js/grid_dragdrop.js', () => {
|
||||
if (this.dragdrop) {
|
||||
return;
|
||||
}
|
||||
this.dragdrop = new GridDragDrop(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.GridView = GridView;
|
||||
|
||||
}(window));
|
||||
160
gaia/shared/elements/gaia_grid/js/grid_zoom.js
Executable file
|
|
@ -0,0 +1,160 @@
|
|||
'use strict';
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const pinchThreshold = Math.round(window.innerWidth / 12);
|
||||
|
||||
function GridZoom(gridView) {
|
||||
this.gridView = gridView;
|
||||
|
||||
this.touches = 0;
|
||||
this.zoomStartTouches = [];
|
||||
this.zoomStartDistance = 0;
|
||||
|
||||
this.container = document.getElementById('zoom');
|
||||
this.arrows = this.container.querySelector('.arrows');
|
||||
this.curtain = this.container.querySelector('.curtain');
|
||||
this.indicator = this.container.querySelector('.indicator');
|
||||
|
||||
this.start();
|
||||
window.addEventListener('gaiagrid-dragdrop-begin', this.stop.bind(this));
|
||||
window.addEventListener('gaiagrid-dragdrop-finish', this.start.bind(this));
|
||||
}
|
||||
|
||||
GridZoom.prototype = {
|
||||
|
||||
/**
|
||||
* Starts listening for touchstart events.
|
||||
* This is what starts listening events to start the drag & drop operation.
|
||||
*/
|
||||
start: function() {
|
||||
window.addEventListener('touchstart', this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stops listening for touchstart events.
|
||||
* This will effectively disable pinch-to-zoom.
|
||||
*/
|
||||
stop: function() {
|
||||
window.removeEventListener('touchstart', this);
|
||||
},
|
||||
|
||||
_attachGestureListeners: function() {
|
||||
window.addEventListener('touchmove', this);
|
||||
window.addEventListener('touchend', this);
|
||||
},
|
||||
|
||||
_stopGestureListeners: function() {
|
||||
window.removeEventListener('touchmove', this);
|
||||
window.removeEventListener('touchend', this);
|
||||
},
|
||||
|
||||
_resetState: function() {
|
||||
this.zoomInProgress = false;
|
||||
|
||||
this.container.hidden = true;
|
||||
this.indicator.classList.remove('active');
|
||||
this.arrows.classList.remove('zooming', 'grow', 'shrink');
|
||||
this.arrows.style.transform = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* General Event Handler
|
||||
*/
|
||||
handleEvent: function(e) {
|
||||
if (e.type === 'touchend' && this.zoomStartTouches) {
|
||||
if (!this.zoomInProgress) {
|
||||
this._resetState();
|
||||
}
|
||||
|
||||
this._stopGestureListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.touches || e.touches.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort touches by ascending pageX position.
|
||||
var touches = [e.touches[0], e.touches[1]].sort(function(a, b) {
|
||||
return a.pageX - b.pageX;
|
||||
});
|
||||
|
||||
var touchDistance = Math.sqrt(
|
||||
(touches[0].pageX - touches[1].pageX) *
|
||||
(touches[0].pageX - touches[1].pageX) +
|
||||
(touches[0].pageY - touches[1].pageY) *
|
||||
(touches[0].pageY - touches[1].pageY));
|
||||
|
||||
var layout = this.gridView.layout;
|
||||
|
||||
switch(e.type) {
|
||||
case 'touchstart':
|
||||
this.container.hidden = false;
|
||||
this.zoomStartTouches = touches;
|
||||
this.zoomStartDistance = touchDistance;
|
||||
|
||||
if (layout.cols < layout.maxIconsPerRow) {
|
||||
this.arrows.classList.add('grow');
|
||||
} else {
|
||||
this.arrows.classList.add('shrink');
|
||||
}
|
||||
|
||||
this.indicator.dataset.cols = layout.cols;
|
||||
this._attachGestureListeners();
|
||||
break;
|
||||
|
||||
case 'touchmove':
|
||||
if (layout.cols < layout.maxIconsPerRow &&
|
||||
touchDistance < this.zoomStartDistance &&
|
||||
Math.abs(touchDistance - this.zoomStartDistance) >
|
||||
pinchThreshold) {
|
||||
this.gridView.cols = layout.maxIconsPerRow;
|
||||
this.zoomInProgress = true;
|
||||
} else if (layout.cols > layout.minIconsPerRow &&
|
||||
touchDistance > this.zoomStartDistance &&
|
||||
Math.abs(touchDistance - this.zoomStartDistance) >
|
||||
pinchThreshold) {
|
||||
this.gridView.cols = layout.minIconsPerRow;
|
||||
this.zoomInProgress = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// For now just implement a canned animation
|
||||
this._stopGestureListeners();
|
||||
|
||||
var ontransitionend = function() {
|
||||
this.arrows.removeEventListener('transitionend', ontransitionend);
|
||||
// Change the indicator color at the end.
|
||||
this.indicator.classList.add('active');
|
||||
|
||||
this.gridView.render();
|
||||
|
||||
// Reset zoom element state after a set time.
|
||||
var zoomHideTime = 400;
|
||||
setTimeout(this._resetState.bind(this), zoomHideTime);
|
||||
}.bind(this);
|
||||
|
||||
this.arrows.addEventListener('transitionend', ontransitionend);
|
||||
|
||||
this.indicator.dataset.cols = layout.cols;
|
||||
|
||||
this.arrows.classList.add('zooming');
|
||||
// Force a sync reflow
|
||||
document.body.clientHeight;
|
||||
var scaleTransform = 'scale(1)';
|
||||
if (this.zoomInProgress && touchDistance < this.zoomStartDistance) {
|
||||
scaleTransform = 'scale(0.4)';
|
||||
}
|
||||
this.arrows.style.transform = scaleTransform;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
exports.GridZoom = GridZoom;
|
||||
|
||||
}(window));
|
||||
127
gaia/shared/elements/gaia_grid/js/items/bookmark.js
Executable file
|
|
@ -0,0 +1,127 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
/* global GridIconRenderer */
|
||||
/* global MozActivity */
|
||||
/* jshint nonew: false */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const TYPE = 'bookmark';
|
||||
|
||||
/**
|
||||
* Represents a single bookmark icon on the homepage.
|
||||
*/
|
||||
function Bookmark(record, features) {
|
||||
this.detail = record;
|
||||
this.features = features || {};
|
||||
this.detail.type = TYPE;
|
||||
}
|
||||
|
||||
Bookmark.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.GridItem.prototype,
|
||||
|
||||
/**
|
||||
* Bookmarks use a custom icon renderer because they are likely much
|
||||
* smaller than a standard icon.
|
||||
*/
|
||||
renderer: GridIconRenderer.TYPE.FAVICON,
|
||||
|
||||
/**
|
||||
* Returns the height in pixels of each icon.
|
||||
*/
|
||||
get pixelHeight() {
|
||||
return this.grid.layout.gridItemHeight;
|
||||
},
|
||||
|
||||
/**
|
||||
* Width in grid units for each icon.
|
||||
*/
|
||||
gridWidth: 1,
|
||||
|
||||
get name() {
|
||||
return this.detail.name;
|
||||
},
|
||||
|
||||
get icon() {
|
||||
return this.detail.icon || this.defaultIcon;
|
||||
},
|
||||
|
||||
get identifier() {
|
||||
return this.detail.id;
|
||||
},
|
||||
|
||||
update: GaiaGrid.GridItem.prototype.updateFromDatastore,
|
||||
|
||||
/**
|
||||
* Bookmarks are always editable unless noted otherwise in features.
|
||||
*/
|
||||
isEditable: function() {
|
||||
return this.features && this.features.isEditable !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bookmarks are always removable unless noted otherwise in features.
|
||||
*/
|
||||
isRemovable: function() {
|
||||
return this.features && this.features.isRemovable !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bookmarks are always draggable unless noted otherwise in features.
|
||||
*/
|
||||
isDraggable: function() {
|
||||
return this.features && this.features.isDraggable !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* This method overrides the GridItem.render function.
|
||||
*/
|
||||
render: function() {
|
||||
GaiaGrid.GridItem.prototype.render.call(this);
|
||||
this.element.classList.add('bookmark');
|
||||
if (this.isEditable()) {
|
||||
this.element.classList.add('editable');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Launches the bookmark in a browser window.
|
||||
*/
|
||||
launch: function() {
|
||||
var features = {
|
||||
name: this.name,
|
||||
icon: this.icon,
|
||||
remote: true
|
||||
};
|
||||
|
||||
var url = this.detail.url;
|
||||
if (this.features.search) {
|
||||
features.searchName = this.name;
|
||||
features.searchUrl = url;
|
||||
}
|
||||
|
||||
window.open(url, '_samescope', Object.keys(features)
|
||||
.map(function eachFeature(key) {
|
||||
return encodeURIComponent(key) + '=' +
|
||||
encodeURIComponent(features[key]);
|
||||
}).join(','));
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a web activity to edit the bookmark.
|
||||
*/
|
||||
edit: function() {
|
||||
new MozActivity({
|
||||
name: 'save-bookmark',
|
||||
data: {
|
||||
type: 'url',
|
||||
url: this.detail.id
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.Bookmark = Bookmark;
|
||||
|
||||
}(window));
|
||||
73
gaia/shared/elements/gaia_grid/js/items/divider.js
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
/**
|
||||
* Represents a single divider on the homepage.
|
||||
*/
|
||||
function Divider() {
|
||||
this.detail = {
|
||||
type: 'divider',
|
||||
index: 0
|
||||
};
|
||||
}
|
||||
|
||||
Divider.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.GridItem.prototype,
|
||||
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
headerHeight: 0,
|
||||
|
||||
/**
|
||||
* Height in pixels of each divider.
|
||||
*/
|
||||
get pixelHeight() {
|
||||
return (this.grid.layout.cols > 3) ? 50 : 60;
|
||||
},
|
||||
|
||||
/**
|
||||
* Width in grid units for each divider.
|
||||
*/
|
||||
gridWidth: 4,
|
||||
|
||||
scale: 1,
|
||||
|
||||
/**
|
||||
* Renders the icon to the grid component.
|
||||
*/
|
||||
render: function() {
|
||||
// Generate the content if we need to
|
||||
if (!this.element) {
|
||||
// Divider is a <section> and the rest of items are <div> containers
|
||||
// in order to hide the last divider via :last-of-type pseudo-class
|
||||
var divider = this.element = document.createElement('section');
|
||||
divider.className = 'divider';
|
||||
|
||||
var span = document.createElement('span');
|
||||
span.className = 'spacer';
|
||||
divider.appendChild(span);
|
||||
|
||||
this.grid.element.appendChild(divider);
|
||||
}
|
||||
|
||||
this.element.style.transform = 'translate(0 ,' + this.y + 'px)';
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
if (this.element) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
},
|
||||
|
||||
isDraggable: function() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.Divider = Divider;
|
||||
|
||||
}(window));
|
||||
666
gaia/shared/elements/gaia_grid/js/items/grid_item.js
Executable file
|
|
@ -0,0 +1,666 @@
|
|||
'use strict';
|
||||
/* global GridIconRenderer */
|
||||
/* global Promise */
|
||||
/* global UrlHelper */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
// event names
|
||||
const ICON_BLOB_DECORATED_EVENT = 'iconblobdecorated';
|
||||
const ICON_BLOB_ERROR_EVENT = 'gaiagrid-iconbloberror';
|
||||
const FETCH_XHR_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* XHR wrapper for fetching blobs with timeout logic.
|
||||
*
|
||||
* @param {String} uri to fetch.
|
||||
* @return {Promise[Blob]}
|
||||
*/
|
||||
function fetchBlob(uri) {
|
||||
return new Promise(function(accept, reject) {
|
||||
var xhr = new XMLHttpRequest({
|
||||
mozAnon: true,
|
||||
mozSystem: true
|
||||
});
|
||||
|
||||
xhr.open('GET', uri, true);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.timeout = FETCH_XHR_TIMEOUT;
|
||||
|
||||
// remember that send can throw for some non http protocols. The promise
|
||||
// wrapper here protects us.
|
||||
xhr.send();
|
||||
|
||||
xhr.onload = function() {
|
||||
var status = xhr.status;
|
||||
if (status !== 0 && status !== 200) {
|
||||
reject(
|
||||
new Error('Got HTTP status ' + status + ' trying to load ' + uri)
|
||||
);
|
||||
return;
|
||||
}
|
||||
accept(xhr.response);
|
||||
};
|
||||
|
||||
xhr.onerror = xhr.ontimeout = function() {
|
||||
reject(new Error('Error while HTTP GET: ', uri));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a generic grid item from which other items can inherit from.
|
||||
*/
|
||||
function GridItem() {
|
||||
this.detail = {};
|
||||
}
|
||||
|
||||
GridItem.prototype = {
|
||||
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
/**
|
||||
* Height in pixels of each item.
|
||||
*/
|
||||
pixelHeight: 0,
|
||||
|
||||
/**
|
||||
* Width in grid units for each item.
|
||||
*/
|
||||
gridWidth: 1,
|
||||
|
||||
scale: 1,
|
||||
|
||||
/**
|
||||
* Whether or not this icon will persist to the database.
|
||||
*/
|
||||
persistToDB: true,
|
||||
|
||||
/**
|
||||
* Cached values of the last transform of the element, to avoid redundant
|
||||
* style changes.
|
||||
*/
|
||||
lastX: null,
|
||||
lastY: null,
|
||||
lastScale: null,
|
||||
|
||||
/**
|
||||
* Whether or not this item has a cached icon or not.
|
||||
*/
|
||||
get hasCachedIcon() {
|
||||
return this.detail && this.detail.decoratedIconBlob;
|
||||
},
|
||||
|
||||
/**
|
||||
* The icon renderer to use. Sub-classes may override this, and individual
|
||||
* objects may as well with a custom detail.renderer property.
|
||||
* Bookmarks use this for example to render both web results from E.me,
|
||||
* and favicons from the web with different styles.
|
||||
*/
|
||||
renderer: GridIconRenderer.TYPE.STANDARD,
|
||||
|
||||
/**
|
||||
* Every grid item has a desired icon (over the network) if we fail to fetch
|
||||
* or render it for some reason we display another. This string keeps track
|
||||
* of the states:
|
||||
*
|
||||
*
|
||||
* none: no action has been taken yet to display or fetch the icon.
|
||||
* pending: fetching the icon
|
||||
* success: icon was fetched and displayed
|
||||
* error: icon could not be fetched or displayed
|
||||
*
|
||||
*/
|
||||
iconState: 'none',
|
||||
|
||||
/**
|
||||
* Default icon url to use when no other icon can be downloaded
|
||||
*/
|
||||
get defaultIcon() {
|
||||
// XXX: ideally this would be relative to the component
|
||||
return '/shared/elements/gaia_grid/images/default_icon.png';
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a reference to the current grid.
|
||||
* We can currently only have one grid per page.
|
||||
*/
|
||||
get grid() {
|
||||
return document.getElementsByTagName('gaia-grid')[0]._grid;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if this item is removable.
|
||||
*/
|
||||
isRemovable: function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if this item is editable.
|
||||
*/
|
||||
isEditable: function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if this item is draggable.
|
||||
*/
|
||||
isDraggable: function() {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if the icon is hosted at an origin.
|
||||
*/
|
||||
isIconFromOrigin: function() {
|
||||
return this.icon.startsWith('http') || this.icon.startsWith('app:');
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the item position
|
||||
*/
|
||||
setPosition: function(position) {
|
||||
this.detail.index = position;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the item coordinates
|
||||
*/
|
||||
setCoordinates: function(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatches a gaiagrid-attention signal with the rect of the item.
|
||||
*/
|
||||
requestAttention: function() {
|
||||
var rect = {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.gridWidth * this.grid.layout.gridItemWidth,
|
||||
height: this.pixelHeight
|
||||
};
|
||||
|
||||
this.grid.element.dispatchEvent(
|
||||
new CustomEvent('gaiagrid-attention', { detail: rect }));
|
||||
},
|
||||
|
||||
closestIconSizeFromList: function(choices) {
|
||||
if (!choices) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var sizes = Object.keys(choices);
|
||||
if (!sizes.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
|
||||
|
||||
var filter = (a) => a >= maxSize ? a : 0;
|
||||
|
||||
// Ideally, get an icon that is equal to our grid size, or else
|
||||
// the smallest icon which is larger than our grid size.
|
||||
var bestSize = Math.min(...sizes.map(filter));
|
||||
|
||||
// If no icons are larger than (or equal to ) our grid size, get
|
||||
// the largest one.
|
||||
bestSize = bestSize || Math.max(...sizes);
|
||||
return bestSize || 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Given a list of icons that match a size, return the closest icon to
|
||||
* reduce possible pixelation by picking a wrong size.
|
||||
* @param {Object} choices An object mapping icon size to icon URL.
|
||||
*/
|
||||
closestIconFromList: function(choices) {
|
||||
var icon;
|
||||
|
||||
// Check for W3C web manifest format for icons
|
||||
if (Array.isArray(choices)) {
|
||||
var manifest = {
|
||||
'icons': choices
|
||||
};
|
||||
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
|
||||
icon = window.IconsHelper.getBestIconFromWebManifest(manifest, maxSize);
|
||||
return icon ? (new URL(icon, this.app.manifestURL)).href : null;
|
||||
}
|
||||
|
||||
var accurateSize = this.closestIconSizeFromList(choices);
|
||||
|
||||
if (accurateSize === 0) {
|
||||
return this.defaultIcon;
|
||||
}
|
||||
|
||||
icon = choices[accurateSize];
|
||||
|
||||
// Handle relative URLs
|
||||
if (!UrlHelper.hasScheme(icon)) {
|
||||
icon = this.app.origin + icon;
|
||||
}
|
||||
|
||||
return icon;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the given image src as the icon (with processing).
|
||||
*
|
||||
* @param {String} url The image url to display.
|
||||
*/
|
||||
renderIconFromSrc: function(url) {
|
||||
var background = new Image();
|
||||
background.src = url;
|
||||
background.onload = this._decorateIcon.bind(this, background);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Renders the given blob as the icon (with processing).
|
||||
*
|
||||
* @param {Blob} blob to render as icon.
|
||||
*/
|
||||
renderIconFromBlob: function(blob) {
|
||||
var background = new Image();
|
||||
background.src = URL.createObjectURL(blob);
|
||||
background.onload = () => {
|
||||
this._decorateIcon(background);
|
||||
URL.revokeObjectURL(background.src);
|
||||
};
|
||||
background.onerror = () => {
|
||||
URL.revokeObjectURL(background.src);
|
||||
|
||||
// If we already have a cached icon, do not overwrite it on error.
|
||||
if (this.hasCachedIcon) {
|
||||
this._stampElementWithIcon('blobcache');
|
||||
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderIconFromSrc(this.defaultIcon);
|
||||
this._stampElementWithIcon(this.defaultIcon);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Begins decoration process for a given icon.
|
||||
*
|
||||
* @param {HTMLImageElement} img An image element to display from.
|
||||
*/
|
||||
_decorateIcon: function(img) {
|
||||
var strategy = this.detail.renderer || this.renderer;
|
||||
this.rendererInstance = new GridIconRenderer(this);
|
||||
this.rendererInstance[strategy](img).
|
||||
then(this._displayDecoratedIcon.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Finishes decoration process and displays the augmented icon.
|
||||
*
|
||||
* @param {Blob} blob The image blob to display.
|
||||
*/
|
||||
_displayDecoratedIcon: function(blob, isCachedIcon) {
|
||||
if (!this.element) {
|
||||
// The icon could be removed while it is being decorated
|
||||
return;
|
||||
}
|
||||
|
||||
var style = this.element.style;
|
||||
|
||||
if (!style.backgroundSize) {
|
||||
style.height = this.grid.layout.gridItemHeight + 'px';
|
||||
// icon size + padding for shadows implemented in the icon renderer
|
||||
var backgroundHeight =
|
||||
((this.grid.layout.gridIconSize * (1 / this.grid.layout.percent)) +
|
||||
GridIconRenderer.prototype.unscaledCanvasPadding);
|
||||
|
||||
if (this.grid.layout.gridItemHeight > backgroundHeight) {
|
||||
style.backgroundSize = backgroundHeight + 'px';
|
||||
} else {
|
||||
style.backgroundSize = style.height;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCachedIcon) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
style.backgroundImage = 'url(' + url + ')';
|
||||
this.element.dataset.backgroundImage = url;
|
||||
var img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
this.grid.element.dispatchEvent(
|
||||
new CustomEvent('cached-icon-rendered')
|
||||
);
|
||||
};
|
||||
img.src = url;
|
||||
return;
|
||||
}
|
||||
|
||||
this._compareBlobs(blob, this.detail.decoratedIconBlob).
|
||||
then((equal) => {
|
||||
if (equal) {
|
||||
return;
|
||||
}
|
||||
|
||||
style.backgroundImage = 'url(' + URL.createObjectURL(blob) + ')';
|
||||
this.detail.decoratedIconBlob = blob;
|
||||
this.grid.element.dispatchEvent(
|
||||
new CustomEvent(ICON_BLOB_DECORATED_EVENT, {
|
||||
detail: this
|
||||
})
|
||||
);
|
||||
var bgImg = this.element.dataset.backgroundImage;
|
||||
bgImg && URL.revokeObjectURL(bgImg);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* It compares two blobs.
|
||||
*/
|
||||
_compareBlobs: function(blob1, blob2) {
|
||||
return new Promise(function(resolve) {
|
||||
if (!blob1 || !blob2 || blob1.type !== blob2.type ||
|
||||
blob1.size !== blob2.size) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// We skip the first bytes that typically are headers
|
||||
var startBytes = 127;
|
||||
var bytesHash = 16;
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onloadend = function() {
|
||||
var result1 = reader.result;
|
||||
|
||||
reader = new FileReader();
|
||||
|
||||
reader.onloadend = reader.onerror = function() {
|
||||
resolve(result1 === reader.result);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob2.slice(startBytes, startBytes + bytesHash));
|
||||
};
|
||||
|
||||
reader.onerror = function() {
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob1.slice(startBytes, startBytes + bytesHash));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch an icon blob by a URI. This can be used as an extension hook to
|
||||
* implement caching logic into the fetching process.
|
||||
*
|
||||
* @return {Promise[Null|Blob]}
|
||||
*/
|
||||
fetchIconBlob: function() {
|
||||
return fetchBlob(this.icon);
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to stamp the element with an icon uri. This stamp is for
|
||||
* debugging/testing only and has no other effect.
|
||||
*/
|
||||
_stampElementWithIcon: function(uri) {
|
||||
// ensure we don't stamp URI
|
||||
if (!this.element || uri.startsWith('data:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do not allow long strings on the stamp
|
||||
if (uri.length > 255) {
|
||||
uri = uri.slice(0, 255);
|
||||
}
|
||||
|
||||
// test prefix used to indicate this has no functional value.
|
||||
this.element.dataset.testIcon = uri;
|
||||
},
|
||||
|
||||
/**
|
||||
Fetch and render the desired icon (`.icon`) if possible otherwise fallback
|
||||
to the default icon.
|
||||
|
||||
XXX: This method is not concurrency safe one image may override another
|
||||
without ordering.
|
||||
*/
|
||||
renderIcon: function(renderCachedIcon) {
|
||||
var icon = this.icon;
|
||||
this.iconState = 'pending';
|
||||
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderCachedIcon && this.hasCachedIcon) {
|
||||
// Display cached icons before trying to get icons again.
|
||||
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
|
||||
var resolveIcon = () => {
|
||||
this.grid.element.removeEventListener('cached-icons-rendered',
|
||||
resolveIcon);
|
||||
this.doRenderIcon(icon);
|
||||
};
|
||||
this.grid.element.addEventListener('cached-icons-rendered',
|
||||
resolveIcon);
|
||||
} else {
|
||||
this.doRenderIcon(icon);
|
||||
}
|
||||
},
|
||||
|
||||
doRenderIcon: function(icon) {
|
||||
// fast path if the icon is not from an origin display it outright
|
||||
if (!this.isIconFromOrigin()) {
|
||||
// XXX: Should we convert data uri(s) to blobs?
|
||||
this._stampElementWithIcon(icon);
|
||||
this.renderIconFromSrc(icon);
|
||||
// state set after in case of some exception or whatever
|
||||
this.iconState = 'success';
|
||||
|
||||
// noop
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// root element of the web component
|
||||
var eventTarget = this.grid.element;
|
||||
|
||||
// handle any cases where the blob is null or the icon cannot be
|
||||
// fetched.
|
||||
var blobNotFound = () => {
|
||||
this.iconState = 'error';
|
||||
|
||||
if (!this.hasCachedIcon) {
|
||||
this.renderIconFromSrc(this.defaultIcon);
|
||||
this._stampElementWithIcon(this.defaultIcon);
|
||||
} else {
|
||||
this._stampElementWithIcon('blobcache');
|
||||
}
|
||||
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent(ICON_BLOB_ERROR_EVENT, {
|
||||
detail: this
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return this.fetchIconBlob().
|
||||
then((blob) => {
|
||||
// if the fetch was a success but no blob could be found short
|
||||
// short circuit... note that being offline is _not_ an error.
|
||||
if (!blob) {
|
||||
return blobNotFound();
|
||||
}
|
||||
|
||||
this.renderIconFromBlob(blob);
|
||||
this.iconState = 'success';
|
||||
this._stampElementWithIcon(icon);
|
||||
}).
|
||||
catch((err) => {
|
||||
blobNotFound();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
Safely remove this item from the grid and DOM.
|
||||
*/
|
||||
removeFromGrid: function() {
|
||||
var idx = this.grid.items.indexOf(this);
|
||||
|
||||
// This should never happen but is remotely possible item is not in the
|
||||
// grid.
|
||||
if (idx === -1) {
|
||||
console.error('Attempting to remove self before item has been added!');
|
||||
return;
|
||||
}
|
||||
|
||||
// update the state of the grid and DOM so this item is no longer
|
||||
// referenced.
|
||||
this.grid.items.splice(idx, 1);
|
||||
delete this.grid.icons[this.identifier];
|
||||
|
||||
if (this.element) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
|
||||
// ensure we don't end up with empty cruft..
|
||||
this.grid.render({ from: idx - 1 });
|
||||
},
|
||||
|
||||
/**
|
||||
Removes item from the dom and dispatches a removeitem event.
|
||||
*/
|
||||
remove: function() {
|
||||
this.grid.element.dispatchEvent(new CustomEvent('removeitem', {
|
||||
detail: this
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the icon to the container.
|
||||
*/
|
||||
render: function() {
|
||||
var scale = this.grid.layout.percent * this.scale;
|
||||
|
||||
// Generate an element if we need to
|
||||
if (!this.element) {
|
||||
var tile = document.createElement('div');
|
||||
tile.className = 'icon';
|
||||
tile.dataset.identifier = this.identifier;
|
||||
tile.dataset.isDraggable = this.isDraggable();
|
||||
tile.setAttribute('role', 'link');
|
||||
tile.setAttribute('tabindex', 0);
|
||||
tile.style.width = (this.grid.layout.constraintSize / 3) + 'px';
|
||||
|
||||
// This <p> has been added in order to place the title with respect
|
||||
// to this container via CSS without touching JS.
|
||||
var nameContainerEl = document.createElement('p');
|
||||
nameContainerEl.style.marginTop = ((this.grid.layout.gridIconSize *
|
||||
(1 / this.grid.layout.percent)) +
|
||||
(GridIconRenderer.prototype.unscaledCanvasPadding / 2)) + 'px';
|
||||
tile.appendChild(nameContainerEl);
|
||||
|
||||
var nameEl = document.createElement('span');
|
||||
nameEl.className = 'title';
|
||||
nameEl.setAttribute('dir', 'auto');
|
||||
|
||||
nameContainerEl.appendChild(nameEl);
|
||||
|
||||
// Add delete link if this icon is removable
|
||||
if (this.isRemovable()) {
|
||||
var removeEl = document.createElement('span');
|
||||
removeEl.className = 'remove';
|
||||
tile.appendChild(removeEl);
|
||||
}
|
||||
|
||||
this.element = tile;
|
||||
this.updateTitle();
|
||||
this.renderIcon(true);
|
||||
this.grid.element.appendChild(tile);
|
||||
}
|
||||
|
||||
this.transform(this.x, this.y, scale);
|
||||
},
|
||||
|
||||
/**
|
||||
* Positions and scales an icon.
|
||||
*/
|
||||
transform: function(x, y, scale, element) {
|
||||
scale = scale || 1;
|
||||
|
||||
if (!element) {
|
||||
if (x === this.lastX && y === this.lastY && scale === this.lastScale) {
|
||||
return;
|
||||
}
|
||||
element = this.element;
|
||||
this.lastX = x;
|
||||
this.lastY = y;
|
||||
this.lastScale = scale;
|
||||
}
|
||||
|
||||
element.style.transform =
|
||||
'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set whether the item is being dragged.
|
||||
*/
|
||||
setActive: function(active) {
|
||||
if (active) {
|
||||
this.element.classList.add('active');
|
||||
this.active = true;
|
||||
} else {
|
||||
this.element.classList.remove('active');
|
||||
this.active = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
Updates the title of the icon on the grid.
|
||||
*/
|
||||
updateTitle: function() {
|
||||
// it is remotely possible that we have not .rendered yet
|
||||
if (!this.element) {
|
||||
return;
|
||||
}
|
||||
var nameEl = this.element.querySelector('.title');
|
||||
|
||||
if (this.asyncName) {
|
||||
this.asyncName().then(function(name) {
|
||||
nameEl.textContent = name;
|
||||
});
|
||||
} else {
|
||||
nameEl.textContent = this.name;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates an icon on the page from a datastore record.
|
||||
* Used for bookmarks.
|
||||
* @param {Object} record The datastore record.
|
||||
*/
|
||||
updateFromDatastore: function(record) {
|
||||
var iconChanged = record.icon !== this.icon;
|
||||
var nameChanged = record.name !== this.name;
|
||||
|
||||
var type = this.detail.type;
|
||||
var lastIcon = this.icon;
|
||||
record.type = type;
|
||||
this.detail = record;
|
||||
if (nameChanged) {
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
if (iconChanged && record.icon) {
|
||||
this.renderIcon();
|
||||
} else if (!record.icon) {
|
||||
this.detail.icon = lastIcon;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.GridItem = GridItem;
|
||||
|
||||
}(window));
|
||||
430
gaia/shared/elements/gaia_grid/js/items/group.js
Executable file
|
|
@ -0,0 +1,430 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
/**
|
||||
* The relative size of icons when in a collapsed group.
|
||||
*/
|
||||
const COLLAPSE_RATIO = 0.33;
|
||||
|
||||
/**
|
||||
* Maximum number of icons that are visible in a collapsed group.
|
||||
*/
|
||||
const COLLAPSED_GROUP_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Space to be reserved at the sides of collapsed group items, in pixels.
|
||||
*/
|
||||
const COLLAPSED_GROUP_MARGIN_LEFT = 23;
|
||||
const COLLAPSED_GROUP_MARGIN_RIGHT = 53;
|
||||
|
||||
/**
|
||||
* Time, in ms, before removing the 'toggling' class from the expand/collapse
|
||||
* toggle element.
|
||||
*/
|
||||
const TOGGLE_TIMEOUT = 250;
|
||||
|
||||
/**
|
||||
* A replacement for the default Divider class that implements group
|
||||
* collapsing and provides convenience functions for group drag'n'drop.
|
||||
*/
|
||||
function Group(detail) {
|
||||
this.detail = detail || {};
|
||||
this.detail.type = 'divider';
|
||||
this.detail.index = 0;
|
||||
this.pendingCollapse = this.detail.collapsed = !!this.detail.collapsed;
|
||||
}
|
||||
|
||||
Group.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.GridItem.prototype,
|
||||
|
||||
gridWidth: 4,
|
||||
|
||||
/**
|
||||
* Height in pixels of the header part of the group.
|
||||
*/
|
||||
get headerHeight() {
|
||||
return this.detail.collapsed ? 20 : 30;
|
||||
},
|
||||
|
||||
/**
|
||||
* The collapsed state. Used to defer the actual state change of
|
||||
* collapse/expand so that anything re-rendering the grid between setting
|
||||
* the state and the re-render caused by the state change doesn't cause
|
||||
* the group to be positioned incorrectly.
|
||||
*/
|
||||
pendingCollapse: false,
|
||||
pendingCollapseTimeout: null,
|
||||
|
||||
/**
|
||||
* Height in pixels of the background of the group.
|
||||
*/
|
||||
backgroundHeight: 0,
|
||||
lastBackgroundHeight: null,
|
||||
|
||||
/**
|
||||
* Height in pixels of the separator at the bottom of the group.
|
||||
*/
|
||||
separatorHeight: 0,
|
||||
|
||||
/**
|
||||
* The scale used for collapsed icons, taking into account column size.
|
||||
*/
|
||||
collapseRatio: COLLAPSE_RATIO,
|
||||
|
||||
/**
|
||||
* Height in pixels of the group. When collapsed, this includes the
|
||||
* icons associated with the group.
|
||||
*/
|
||||
get pixelHeight() {
|
||||
return this.detail.collapsed ?
|
||||
this.backgroundHeight :
|
||||
this.separatorHeight;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the number of items in the group. Relies on the item index
|
||||
* being correct.
|
||||
*/
|
||||
get size() {
|
||||
var size = 0;
|
||||
for (var i = this.detail.index - 1, items = this.grid.items;
|
||||
i >= 0 && items[i].detail.type !== 'divider'; i--) {
|
||||
size++;
|
||||
}
|
||||
return size;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the element and its children for this group item.
|
||||
*/
|
||||
_createElement: function() {
|
||||
var group = this.element = document.createElement('section');
|
||||
group.className = 'divider group newly-created';
|
||||
|
||||
// Create the background (only seen in edit mode)
|
||||
var span = document.createElement('span');
|
||||
span.className = 'background';
|
||||
group.appendChild(span);
|
||||
this.backgroundSpanElement = span;
|
||||
|
||||
// Create an element for a drop-shadow (only seen when active)
|
||||
span = document.createElement('span');
|
||||
span.className = 'shadow';
|
||||
group.appendChild(span);
|
||||
this.shadowSpanElement = span;
|
||||
|
||||
// Create the header (container for the move gripper and
|
||||
// expand/collapse toggle)
|
||||
span = document.createElement('span');
|
||||
span.className = 'header';
|
||||
group.appendChild(span);
|
||||
this.headerSpanElement = span;
|
||||
|
||||
// Create the move gripper
|
||||
span = document.createElement('span');
|
||||
span.className = 'gripper';
|
||||
this.headerSpanElement.appendChild(span);
|
||||
|
||||
// Create the expand/collapse toggle
|
||||
var button = document.createElement('button');
|
||||
button.className = 'toggle';
|
||||
button.dataset.l10nId = 'gaia-grid-toggle-expanded';
|
||||
this.headerSpanElement.appendChild(button);
|
||||
this.toggleElement = button;
|
||||
|
||||
// Create the group separator (only seen in non-edit mode)
|
||||
span = document.createElement('span');
|
||||
span.className = 'separator';
|
||||
group.appendChild(span);
|
||||
this.dividerSpanElement = span;
|
||||
|
||||
// Create a child span for the separator to act as the indicator for
|
||||
// dropping icons/groups during editing.
|
||||
this.dividerSpanElement.appendChild(document.createElement('span'));
|
||||
|
||||
this.grid.element.appendChild(group);
|
||||
this.separatorHeight = this.dividerSpanElement.clientHeight;
|
||||
this.lastBackgroundHeight = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the apps within the group. If the group is not collapsed, this
|
||||
* just validates the style class of the group.
|
||||
*/
|
||||
_renderChildren: function(nApps) {
|
||||
var isRTL = (document.documentElement.dir === 'rtl');
|
||||
if (!this.detail.collapsed) {
|
||||
this.element.classList.remove('collapsed');
|
||||
return;
|
||||
}
|
||||
|
||||
// If collapsed, we need to style and position the icons in the group
|
||||
// into one row.
|
||||
this.element.classList.add('collapsed');
|
||||
|
||||
var y = this.y;
|
||||
var index = this.detail.index;
|
||||
|
||||
var width = Math.round(
|
||||
(this.grid.layout.constraintSize -
|
||||
COLLAPSED_GROUP_MARGIN_LEFT - COLLAPSED_GROUP_MARGIN_RIGHT) /
|
||||
COLLAPSED_GROUP_SIZE);
|
||||
var x = isRTL ?
|
||||
(this.grid.layout.constraintSize - COLLAPSED_GROUP_MARGIN_RIGHT) :
|
||||
COLLAPSED_GROUP_MARGIN_LEFT;
|
||||
y += this.headerHeight;
|
||||
|
||||
var maxGridItemWidth =
|
||||
this.grid.layout.constraintSize / this.grid.layout.minIconsPerRow;
|
||||
this.collapseRatio =
|
||||
(maxGridItemWidth / this.grid.layout.gridItemWidth) * COLLAPSE_RATIO;
|
||||
|
||||
for (var i = index - nApps; i < index; i++) {
|
||||
var item = this.grid.items[i];
|
||||
if (this.detail.collapsed) {
|
||||
item.scale = this.collapseRatio;
|
||||
|
||||
var itemVisible = (i - (index - nApps)) < COLLAPSED_GROUP_SIZE;
|
||||
if (!itemVisible) {
|
||||
item.setCoordinates(isRTL ? x + width : x - width, y);
|
||||
} else {
|
||||
item.setCoordinates(x, y);
|
||||
x += isRTL ? -width: width;
|
||||
}
|
||||
|
||||
item.render();
|
||||
item.element.classList.add('collapsed');
|
||||
if (!itemVisible) {
|
||||
item.element.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the y-position of the group. When the group is expanded, its y
|
||||
* position is actually the y-position of the separator underneath the
|
||||
* group. This gets the visible y-position of the group.
|
||||
*/
|
||||
getRealYPosition: function(nApps) {
|
||||
if (this.detail.collapsed) {
|
||||
return this.y;
|
||||
}
|
||||
return this.grid.items[this.detail.index - nApps].y -
|
||||
this.headerHeight;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the icon to the grid component.
|
||||
*/
|
||||
render: function() {
|
||||
// Generate the content if we need to
|
||||
var createdElements = false;
|
||||
if (!this.element) {
|
||||
createdElements = true;
|
||||
this._createElement();
|
||||
}
|
||||
|
||||
// Render children
|
||||
var nApps = this.size;
|
||||
this._renderChildren(nApps);
|
||||
|
||||
// Calculate group position.
|
||||
// If we're not collapsed, the group's position will be underneath its
|
||||
// icons, but we want it to display above.
|
||||
var y = this.getRealYPosition(nApps);
|
||||
|
||||
if (y !== this.lastY) {
|
||||
// Place the header span
|
||||
this.headerSpanElement.style.transform =
|
||||
'translate(0px, ' + y + 'px)';
|
||||
}
|
||||
|
||||
if (this.toggleElement) {
|
||||
var toggleLabel = this.detail.collapsed ? 'collapsed' : 'expanded';
|
||||
this.toggleElement.dataset.l10nId = 'gaia-grid-toggle-' + toggleLabel;
|
||||
}
|
||||
|
||||
// Calculate the height of the background span
|
||||
if (this.detail.collapsed) {
|
||||
this.backgroundHeight =
|
||||
Math.round(this.collapseRatio * this.grid.layout.gridIconSize * 1.5);
|
||||
} else {
|
||||
var height = Math.ceil(nApps / this.grid.layout.cols);
|
||||
this.backgroundHeight = (height || 1) * this.grid.layout.gridItemHeight;
|
||||
}
|
||||
this.backgroundHeight += this.headerHeight;
|
||||
|
||||
if (y != this.lastY ||
|
||||
this.backgroundHeight !== this.lastBackgroundHeight) {
|
||||
// Place and size the background span element
|
||||
this.backgroundSpanElement.style.transform =
|
||||
'translate(0px, ' + y + 'px) scale(1, ' + this.backgroundHeight + ')';
|
||||
|
||||
// Place and size the shadow span element
|
||||
this.shadowSpanElement.style.transform =
|
||||
'translateY(' + y + 'px)';
|
||||
this.shadowSpanElement.style.height = this.backgroundHeight + 'px';
|
||||
|
||||
// Place the divider after this point
|
||||
this.dividerSpanElement.style.transform =
|
||||
'translate(0px, ' + (y + this.backgroundHeight) + 'px)';
|
||||
}
|
||||
|
||||
// Update the cached size values
|
||||
this.lastBackgroundHeight = this.backgroundHeight;
|
||||
this.lastY = y;
|
||||
|
||||
// Now include the separator in the background height
|
||||
this.backgroundHeight += this.separatorHeight;
|
||||
|
||||
// Fade in newly-created groups
|
||||
if (createdElements) {
|
||||
// Force a reflow to make sure the transform transition doesn't play
|
||||
this.element.clientTop;
|
||||
this.element.classList.remove('newly-created');
|
||||
}
|
||||
},
|
||||
|
||||
forEachItem: function(callback) {
|
||||
var nApps = this.size;
|
||||
var index = this.detail.index;
|
||||
for (var i = index - nApps; i < index; i++) {
|
||||
callback(this.grid.items[i]);
|
||||
}
|
||||
},
|
||||
|
||||
setActive: function(active) {
|
||||
GaiaGrid.GridItem.prototype.setActive.call(this, active);
|
||||
|
||||
// Make sure we're collapsed
|
||||
this.collapse();
|
||||
|
||||
// Mark our child items as active/inactive with us so they pick up the
|
||||
// right style when dragged.
|
||||
var callback = active ?
|
||||
function(item) { item.element.classList.add('active'); } :
|
||||
function(item) { item.element.classList.remove('active'); };
|
||||
this.forEachItem(callback);
|
||||
},
|
||||
|
||||
/*
|
||||
* As the start of a collapse/expand animation can involve a lot of work,
|
||||
* the response isn't always instant. To help alleviate this, we show a
|
||||
* quick response on the toggle element first before initiating the rest
|
||||
* of the work.
|
||||
*/
|
||||
_rerenderOnToggle: function() {
|
||||
if (this.pendingCollapseTimeout) {
|
||||
clearTimeout(this.pendingCollapseTimeout);
|
||||
this.pendingCollapseTimeout = null;
|
||||
}
|
||||
|
||||
if (this.toggleElement) {
|
||||
this.toggleElement.classList.add('toggling');
|
||||
setTimeout(() => { this.toggleElement.classList.remove('toggling'); },
|
||||
TOGGLE_TIMEOUT);
|
||||
}
|
||||
|
||||
// Ideally, this setTimeout would be a requestAnimationFrame, but the
|
||||
// above class-adding seems to end up coalesced with the code inside
|
||||
// requestAnimationFrame, rather than it happening on the next frame.
|
||||
// The same thing happens with any setTimeout lower than about 20ms too.
|
||||
this.pendingCollapseTimeout = setTimeout(() => {
|
||||
this.detail.collapsed = this.pendingCollapse;
|
||||
this.pendingCollapseTimeout = null;
|
||||
|
||||
if (!this.detail.collapsed) {
|
||||
// Remove collapsed styling from all icons
|
||||
this.forEachItem(function(item) {
|
||||
item.scale = 1;
|
||||
if (item.element) {
|
||||
item.element.classList.remove('collapsed');
|
||||
item.element.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If the item is active, re-render it so that its pixel-height is
|
||||
// correct when the rest of the grid is rendered.
|
||||
if (this.active) {
|
||||
this.render();
|
||||
}
|
||||
|
||||
this.grid.render();
|
||||
|
||||
var dragging = this.grid.dragdrop && this.grid.dragdrop.inDragAction;
|
||||
if (dragging) {
|
||||
// If we're dragging, make sure to reposition the icon in the correct
|
||||
// place, as the render call won't redraw us
|
||||
this.grid.dragdrop.updateIconPosition();
|
||||
} else {
|
||||
// If we're not dragging, save the collapsed state
|
||||
window.dispatchEvent(new CustomEvent('gaiagrid-saveitems'));
|
||||
|
||||
// Request attention so that we're as visible as we can be after
|
||||
// expanding/collapsing
|
||||
this.requestAttention();
|
||||
}
|
||||
}, 20);
|
||||
},
|
||||
|
||||
collapse: function() {
|
||||
if (this.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCollapse = true;
|
||||
this._rerenderOnToggle();
|
||||
},
|
||||
|
||||
expand: function() {
|
||||
if (!this.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCollapse = false;
|
||||
this._rerenderOnToggle();
|
||||
},
|
||||
|
||||
launch: function(target) {
|
||||
if (target !== this.toggleElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.detail.collapsed) {
|
||||
this.expand();
|
||||
} else {
|
||||
this.collapse();
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
if (this.element) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
},
|
||||
|
||||
isDraggable: function() {
|
||||
return true;
|
||||
},
|
||||
|
||||
requestAttention: function() {
|
||||
var rect = {
|
||||
x: 0,
|
||||
y: this.getRealYPosition(this.size),
|
||||
width: this.gridWidth * this.grid.layout.gridItemWidth,
|
||||
height: this.backgroundHeight
|
||||
};
|
||||
|
||||
this.grid.element.dispatchEvent(
|
||||
new CustomEvent('gaiagrid-attention', { detail: rect }));
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.Divider = Group; // Override the non-grouping divider
|
||||
|
||||
}(window));
|
||||
51
gaia/shared/elements/gaia_grid/js/items/marketplace_app.js
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
/* global GridIconRenderer */
|
||||
/* global MozActivity */
|
||||
/* jshint nonew: false */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const TYPE = 'marketplace-app';
|
||||
|
||||
/**
|
||||
* Represents a single bookmark icon on the homepage.
|
||||
*/
|
||||
function MarketPlaceApp(record) {
|
||||
this.detail = record;
|
||||
this.detail.type = TYPE;
|
||||
}
|
||||
|
||||
MarketPlaceApp.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.Bookmark.prototype,
|
||||
|
||||
renderer: GridIconRenderer.TYPE.STANDARD,
|
||||
|
||||
/**
|
||||
* Returns the icon image path.
|
||||
*/
|
||||
get icon() {
|
||||
var icon = this._accurateIcon;
|
||||
|
||||
if (!icon) {
|
||||
icon = this._accurateIcon = this.closestIconFromList(
|
||||
this.detail.icons);
|
||||
}
|
||||
|
||||
return icon;
|
||||
},
|
||||
|
||||
launch: function() {
|
||||
new MozActivity({
|
||||
name: 'marketplace-app',
|
||||
data: {
|
||||
slug: this.detail.slug
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
exports.GaiaGrid.MarketPlaceApp = MarketPlaceApp;
|
||||
|
||||
}(window));
|
||||
369
gaia/shared/elements/gaia_grid/js/items/mozapp.js
Executable file
|
|
@ -0,0 +1,369 @@
|
|||
'use strict';
|
||||
/* global ConfirmDialogHelper, GaiaGrid */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
const IDENTIFIER_SEP = '-';
|
||||
|
||||
var MOZAPP_EVENTS = [
|
||||
'downloadsuccess',
|
||||
'downloaderror',
|
||||
'downloadavailable',
|
||||
'downloadapplied',
|
||||
'progress'
|
||||
];
|
||||
|
||||
var APP_LOADING = 'loading';
|
||||
var APP_ERROR = 'error';
|
||||
var APP_UNRECOVERABLE = 'unrecoverable';
|
||||
var APP_PAUSED = 'paused';
|
||||
var APP_READY = 'ready';
|
||||
|
||||
function localizeString(str) {
|
||||
var userLang = document.documentElement.lang;
|
||||
|
||||
// We want to make sure that we translate only if we're using
|
||||
// a runtime pseudolocale.
|
||||
// mozL10n.ctx.qps contains only runtime pseudolocales
|
||||
if (navigator.mozL10n &&
|
||||
navigator.mozL10n.ctx.qps.indexOf(userLang) !== -1) {
|
||||
return navigator.mozL10n.qps[userLang].translate(str);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents single app icon on the homepage.
|
||||
*/
|
||||
function Mozapp(app, entryPoint, details) {
|
||||
this.app = app;
|
||||
this.entryPoint = entryPoint;
|
||||
|
||||
this.detail = {
|
||||
type: 'app',
|
||||
manifestURL: app.manifestURL,
|
||||
entryPoint: entryPoint,
|
||||
index: 0,
|
||||
// XXX: Somewhat ugly hack around the constructor args
|
||||
decoratedIconBlob: details && details.decoratedIconBlob
|
||||
};
|
||||
|
||||
// Yes this is clobbering all other listeners to the app MozApp
|
||||
// should be in charge here otherwise the icon will be incorrect.
|
||||
MOZAPP_EVENTS.forEach(function(type) {
|
||||
this.app['on' + type] = this.handleEvent.bind(this);
|
||||
}, this);
|
||||
|
||||
// Determine and set the initial state of the app when it is inserted.
|
||||
this.setAppState(this._determineState(app));
|
||||
}
|
||||
|
||||
/**
|
||||
`app.manifest.role`s which should not be displayed on the grid.
|
||||
*/
|
||||
Mozapp.HIDDEN_ROLES = [
|
||||
'system', 'input', 'homescreen', 'search', 'addon', 'langpack'
|
||||
];
|
||||
|
||||
Mozapp.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.GridItem.prototype,
|
||||
|
||||
|
||||
/**
|
||||
Determine if this application is supposed to be hidden.
|
||||
|
||||
@return Boolean
|
||||
*/
|
||||
_isHiddenRole: function() {
|
||||
var manifest = this.app.manifest;
|
||||
|
||||
// don't know what the role is so it is not hidden
|
||||
if (!manifest || !manifest.role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if it is in the hidden role list return true
|
||||
return Mozapp.HIDDEN_ROLES.indexOf(manifest.role) !== -1;
|
||||
},
|
||||
|
||||
/**
|
||||
Figure out which state the app is in
|
||||
*/
|
||||
_determineState: function(app) {
|
||||
// is there is a pending download but we cannot download then this app is
|
||||
// in an unrecoverable state due to some fatal error.
|
||||
if (
|
||||
// Must be pending (meaning not launchable)
|
||||
app.installState === 'pending' &&
|
||||
// Without any option for downloading the app
|
||||
!app.downloadAvailable &&
|
||||
// And not be currently applying a download
|
||||
!app.readyToApplyDownload
|
||||
) {
|
||||
return APP_UNRECOVERABLE;
|
||||
}
|
||||
|
||||
// If the app is currently downloading life is good and we should display
|
||||
// loading.
|
||||
if (app.downloading) {
|
||||
return APP_LOADING;
|
||||
}
|
||||
|
||||
// Bug 1027347 - downloadError is always present even if there is no error
|
||||
// so we check both for the error and that the error has a
|
||||
// name.
|
||||
if (app.downloadError && app.downloadError.name) {
|
||||
|
||||
// If the app was paused while the homescreen was running the
|
||||
// downloaderror event will fire with the DOWNLOAD_CANCELED error.
|
||||
if (app.downloadError.name === 'DOWNLOAD_CANCELED') {
|
||||
return APP_PAUSED;
|
||||
}
|
||||
|
||||
return APP_ERROR;
|
||||
}
|
||||
|
||||
// Note that this logic is correct because app.downloading is caught above
|
||||
// separately.
|
||||
if (app.installState === 'pending') {
|
||||
return APP_PAUSED;
|
||||
}
|
||||
|
||||
return APP_READY;
|
||||
},
|
||||
|
||||
handleEvent: function(event) {
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
case 'downloaderror':
|
||||
case 'downloadsuccess':
|
||||
this.setAppState(this._determineState(this.app));
|
||||
break;
|
||||
|
||||
case 'downloadapplied':
|
||||
// Ensure that a hidden app has not somehow been added to the grid...
|
||||
if (this._isHiddenRole()) {
|
||||
console.warn('Removing hidden app from the grid', this.name);
|
||||
return this.removeFromGrid();
|
||||
}
|
||||
|
||||
// we may have updated icons so recalculate the correct icon.
|
||||
delete this._accurateIcon;
|
||||
delete this._accurateIconSize;
|
||||
|
||||
// Need to set app state here to correctly handle the pause/resume
|
||||
// case.
|
||||
this.setAppState(this._determineState(this.app));
|
||||
// XXX: This introduces a potential race condition.
|
||||
// GridItem.renderIcon is not concurrency safe one image may override
|
||||
// another without ordering.
|
||||
this.renderIcon();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('downloadapplied', {
|
||||
detail: {
|
||||
id: this.app.manifestURL
|
||||
}
|
||||
}));
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// Overrides GridItem.fetchIconBlob
|
||||
fetchIconBlob: function() {
|
||||
if (!this._accurateIconSize) {
|
||||
this._accurateIconSize =
|
||||
this.closestIconSizeFromList(this.descriptor.icons);
|
||||
}
|
||||
|
||||
var result = this._accurateIconSize ?
|
||||
navigator.mozApps.mgmt.getIcon(this.app, this._accurateIconSize,
|
||||
this.entryPoint || '') :
|
||||
GaiaGrid.GridItem.prototype.fetchIconBlob.call(this);
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the height in pixels of each icon.
|
||||
*/
|
||||
get pixelHeight() {
|
||||
return this.grid.layout.gridItemHeight;
|
||||
},
|
||||
|
||||
/**
|
||||
* Width in grid units for each icon.
|
||||
*/
|
||||
gridWidth: 1,
|
||||
|
||||
get name() {
|
||||
var userLang = document.documentElement.lang;
|
||||
|
||||
if (navigator.mozL10n && userLang in navigator.mozL10n.qps) {
|
||||
return navigator.mozL10n.qps[userLang].
|
||||
translate(this.descriptor.short_name || this.descriptor.name);
|
||||
}
|
||||
|
||||
var locales = this.descriptor.locales;
|
||||
var localized =
|
||||
locales && locales[userLang] &&
|
||||
(locales[userLang].short_name || locales[userLang].name);
|
||||
|
||||
return localized || this.descriptor.short_name || this.descriptor.name;
|
||||
},
|
||||
|
||||
asyncName: function() {
|
||||
var userLang = document.documentElement.lang;
|
||||
|
||||
var ep = this.entryPoint || undefined;
|
||||
|
||||
if (!this.app.getLocalizedValue) {
|
||||
return new Promise((resolve, reject) => { reject(); });
|
||||
}
|
||||
|
||||
return this.app.getLocalizedValue('short_name', userLang, ep).then(
|
||||
shortName => localizeString(shortName),
|
||||
this.app.getLocalizedValue.bind(this.app, 'name', userLang, ep)).then(
|
||||
name => localizeString(name),
|
||||
() => this.name
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the icon image path.
|
||||
*/
|
||||
get icon() {
|
||||
var icon = this._accurateIcon;
|
||||
|
||||
if (!icon) {
|
||||
icon = this._accurateIcon = this.closestIconFromList(
|
||||
this.descriptor.icons);
|
||||
}
|
||||
|
||||
return icon;
|
||||
},
|
||||
|
||||
get descriptor() {
|
||||
var manifest = this.app.manifest || this.app.updateManifest;
|
||||
|
||||
if (this.entryPoint && manifest.entry_points &&
|
||||
manifest.entry_points[this.entryPoint]) {
|
||||
return manifest.entry_points[this.entryPoint];
|
||||
}
|
||||
return manifest;
|
||||
},
|
||||
|
||||
identifierSeparator: IDENTIFIER_SEP,
|
||||
|
||||
get identifier() {
|
||||
var identifier = [this.app.manifestURL];
|
||||
|
||||
if (this.entryPoint) {
|
||||
identifier.push(this.entryPoint);
|
||||
}
|
||||
|
||||
return identifier.join(IDENTIFIER_SEP);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if this app is removable.
|
||||
*/
|
||||
isRemovable: function() {
|
||||
return this.app.removable;
|
||||
},
|
||||
|
||||
/**
|
||||
Show a dialog to handle unrecoverable errors.
|
||||
*/
|
||||
unrecoverableError: function() {
|
||||
navigator.mozApps.mgmt.uninstall(this.app);
|
||||
},
|
||||
|
||||
cancel: function() {
|
||||
var dialog = new ConfirmDialogHelper({
|
||||
type: 'pause',
|
||||
title: {id: 'gaia-grid-stop-download-title', args: { name: this.name }},
|
||||
body: 'gaia-grid-stop-download-body',
|
||||
cancel: {
|
||||
title: 'gaia-grid-cancel'
|
||||
},
|
||||
confirm: {
|
||||
title: 'gaia-grid-stop-download-action',
|
||||
type: 'danger',
|
||||
cb: () => this.app.cancelDownload()
|
||||
}
|
||||
});
|
||||
dialog.show(document.body);
|
||||
},
|
||||
|
||||
resume: function() {
|
||||
var dialog = new ConfirmDialogHelper({
|
||||
type: 'resume',
|
||||
title: 'gaia-grid-resume-download-title',
|
||||
body: {id: 'gaia-grid-resume-download-body', args: { name: this.name }},
|
||||
cancel: {
|
||||
title: 'gaia-grid-cancel'
|
||||
},
|
||||
confirm: {
|
||||
title: 'gaia-grid-resume-download-action',
|
||||
cb: () => {
|
||||
// enter the loading state optimistically
|
||||
this.setAppState(APP_LOADING);
|
||||
this.app.download();
|
||||
}
|
||||
}
|
||||
});
|
||||
dialog.show(document.body);
|
||||
},
|
||||
|
||||
setAppState: function(state) {
|
||||
this.appState = state;
|
||||
|
||||
if (this.element) {
|
||||
this.element.dataset.appState = state;
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// If there is no element before we can render we need to update the
|
||||
// app state. Render may also be called to reposition elements.
|
||||
var needsStateSet = !this.element;
|
||||
|
||||
GaiaGrid.GridItem.prototype.render.apply(this, arguments);
|
||||
|
||||
if (needsStateSet) {
|
||||
// ensure the newly rendered item has an app state
|
||||
this.setAppState(this.appState);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves click action.
|
||||
*/
|
||||
launch: function() {
|
||||
var app = this.app;
|
||||
|
||||
switch (this._determineState(app)) {
|
||||
case APP_UNRECOVERABLE:
|
||||
return this.unrecoverableError();
|
||||
case APP_ERROR:
|
||||
case APP_PAUSED:
|
||||
return this.resume();
|
||||
case APP_LOADING:
|
||||
return this.cancel();
|
||||
}
|
||||
|
||||
window.performance.mark('appLaunch@' + app.origin);
|
||||
|
||||
if (this.entryPoint) {
|
||||
return app.launch(this.entryPoint);
|
||||
}
|
||||
|
||||
// Default action is to launch the app.
|
||||
return app.launch();
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.Mozapp = Mozapp;
|
||||
|
||||
}(window));
|
||||
73
gaia/shared/elements/gaia_grid/js/items/placeholder.js
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
'use strict';
|
||||
/* global GaiaGrid */
|
||||
|
||||
(function(exports) {
|
||||
|
||||
/**
|
||||
* The placeholder represents an empty place on the grid.
|
||||
* This is generally used to detect distance from an empty spot on the grid.
|
||||
*/
|
||||
function Placeholder() {
|
||||
this.detail = {
|
||||
type: 'placeholder',
|
||||
index: 0
|
||||
};
|
||||
}
|
||||
|
||||
Placeholder.prototype = {
|
||||
|
||||
__proto__: GaiaGrid.GridItem.prototype,
|
||||
|
||||
/**
|
||||
* Returns the height in pixels of each icon.
|
||||
*/
|
||||
get pixelHeight() {
|
||||
return this.grid.layout.gridItemHeight;
|
||||
},
|
||||
|
||||
/**
|
||||
* When the placeholder is rendered in the last row, for group creation
|
||||
* purposes, there is special handling required for height.
|
||||
*/
|
||||
createsGroupOnDrop: false,
|
||||
|
||||
/**
|
||||
* Width in grid units for each icon.
|
||||
*/
|
||||
gridWidth: 1,
|
||||
|
||||
/**
|
||||
* Placeholders do not save. They are re-generated on render calls.
|
||||
*/
|
||||
persistToDB: false,
|
||||
|
||||
get name() {
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders a transparent placeholder.
|
||||
*/
|
||||
render: function() {
|
||||
// Generate an element if we need to
|
||||
if (!this.element) {
|
||||
var tile = document.createElement('div');
|
||||
tile.className = 'icon placeholder';
|
||||
tile.style.height = this.pixelHeight + 'px';
|
||||
this.element = tile;
|
||||
this.grid.element.appendChild(tile);
|
||||
}
|
||||
|
||||
this.transform(this.x, this.y, this.grid.layout.percent);
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
if (this.element) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.GaiaGrid.Placeholder = Placeholder;
|
||||
|
||||
}(window));
|
||||
18
gaia/shared/elements/gaia_grid/locales/gaia_grid.en-US.properties
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
# Prompt that appears when a user tries to stop a download.
|
||||
gaia-grid-stop-download-title=Stop {{name}}
|
||||
gaia-grid-stop-download-body=Do you want to stop the download in progress?
|
||||
gaia-grid-stop-download-action=Stop
|
||||
|
||||
# Prompt that appears when a user tries to resume a download.
|
||||
gaia-grid-resume-download-title=Restart Download
|
||||
gaia-grid-resume-download-body=Do you want to download {{name}}?
|
||||
gaia-grid-resume-download-action=Download
|
||||
|
||||
gaia-grid-cancel=Cancel
|
||||
# LOCALIZATION NOTE(*.ariaLabel): The following strings are spoken by screen
|
||||
# readers and not shown on the screen.
|
||||
gaia-grid-toggle-collapsed.ariaLabel=Expand group
|
||||
gaia-grid-toggle-expanded.ariaLabel=Collapse group
|
||||
|
||||
# Hint that appears in edit mode for groups with no title set.
|
||||
gaia-grid-enter-group-name=Enter a group name
|
||||
254
gaia/shared/elements/gaia_grid/script.js
Executable file
|
|
@ -0,0 +1,254 @@
|
|||
/* global GaiaGrid */
|
||||
/* global GridView */
|
||||
|
||||
/**
|
||||
* The GaiaGrid component is a helper to display grid-like results.
|
||||
* You can find this component used in places like the homescreen and search
|
||||
* application.
|
||||
*/
|
||||
window.GaiaGrid = (function(win) {
|
||||
'use strict';
|
||||
|
||||
// Extend from the HTMLElement prototype
|
||||
var proto = Object.create(HTMLElement.prototype);
|
||||
|
||||
// Allow baseurl to be overridden (used for demo page)
|
||||
var baseurl = window.GaiaGridBaseurl ||
|
||||
'/shared/elements/gaia_grid/';
|
||||
|
||||
/* Configuration from the grid is picked up by attributes on the element.
|
||||
* The following attributes are valid:
|
||||
* - dragdrop
|
||||
* Enables drag-and-drop of icons and groups within the grid.
|
||||
* - zoom
|
||||
* Enables changing the grid column-width with a pinch gesture.
|
||||
* - disable-sections
|
||||
* Only valid with dragdrop enabled, this disables creating new dividers
|
||||
* or groups.
|
||||
*/
|
||||
proto.createdCallback = function() {
|
||||
var shadow = this.createShadowRoot();
|
||||
this._template = template.content.cloneNode(true);
|
||||
this._styleHack();
|
||||
|
||||
// Todo: render in the shadowRoot
|
||||
// By changing the element to this._template we should be able to render
|
||||
// to the shadowRoot, although some things are not yet working.
|
||||
this._grid = new GridView({
|
||||
element: this,
|
||||
features: {
|
||||
dragdrop: this.getAttribute('dragdrop') !== null,
|
||||
zoom: this.getAttribute('zoom') !== null,
|
||||
disableSections: this.getAttribute('disable-sections') !== null
|
||||
}
|
||||
});
|
||||
|
||||
shadow.appendChild(this._template);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for GridView.prototype.add
|
||||
*/
|
||||
proto.add = function() {
|
||||
this._grid.add.apply(this._grid, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for GridView.prototype.clear
|
||||
*/
|
||||
proto.clear = function() {
|
||||
this._grid.clear.apply(this._grid, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for GridView.prototype.render
|
||||
*/
|
||||
proto.render = function() {
|
||||
this._grid.layout.calculateSize();
|
||||
this._grid.render.apply(this._grid, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for GridView.prototype.start
|
||||
*/
|
||||
proto.start = function() {
|
||||
this._grid.start.apply(this._grid, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for GridView.prototype.stop
|
||||
*/
|
||||
proto.stop = function() {
|
||||
this._grid.stop.apply(this._grid, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a reference of the grid icons.
|
||||
*/
|
||||
proto.getIcons = function() {
|
||||
return this._grid.icons;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a copy of the grid items.
|
||||
*/
|
||||
proto.getItems = function() {
|
||||
return this._grid.items;
|
||||
};
|
||||
|
||||
proto.getIcon = function(identifier) {
|
||||
return this._grid.icons[identifier];
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an icon by identifier.
|
||||
* @param {String} identifier
|
||||
*/
|
||||
proto.removeIconByIdentifier = function(identifier) {
|
||||
delete this._grid.icons[identifier];
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an item by an index.
|
||||
* @param {Integer} itemIndex
|
||||
*/
|
||||
proto.removeItemByIndex = function(itemIndex) {
|
||||
this._grid.items.splice(itemIndex, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes and returns the last divider, if there is one.
|
||||
*/
|
||||
proto.popDivider = function() {
|
||||
var items = this._grid.items;
|
||||
if (items.length && items[items.length - 1].detail.type === 'divider') {
|
||||
return items.pop();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends an item to the end of the group. If there is a divider at the end,
|
||||
* the item is placed before said divider.
|
||||
*/
|
||||
proto.appendItem = function(item) {
|
||||
var items = this._grid.items;
|
||||
var lastIndex = items.length - 1;
|
||||
|
||||
if (items[lastIndex].detail.type === 'divider') {
|
||||
this.add(item, lastIndex);
|
||||
} else {
|
||||
this.add(item);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If the last item is a collapsed divider, expand that divider before
|
||||
* appending the item.
|
||||
*/
|
||||
proto.appendItemToExpandedGroup = function(item) {
|
||||
var items = this._grid.items;
|
||||
var lastIndex = items.length - 1;
|
||||
|
||||
if (items[lastIndex].detail.type === 'divider' &&
|
||||
items[lastIndex].detail.collapsed) {
|
||||
items[lastIndex].expand();
|
||||
}
|
||||
|
||||
this.appendItem(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets an element that should be considered as obscuring the top of the grid
|
||||
* when in edit mode.
|
||||
*/
|
||||
proto.setEditHeaderElement = function(element) {
|
||||
if (this._grid.dragdrop) {
|
||||
this._grid.dragdrop.editHeaderElement = element;
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(proto, 'maxIconSize', {
|
||||
get: function() {
|
||||
return this._grid.layout.gridMaxIconSize;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the position of the last item which is not a divider
|
||||
*/
|
||||
proto.getIndexLastIcon = function() {
|
||||
var items = this._grid.items;
|
||||
for (var i = this._grid.items.length - 1; i >= 0; i--) {
|
||||
if (items[i] instanceof GaiaGrid.Mozapp) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
proto.removeNonVisualElements = function() {
|
||||
|
||||
var nonVisualElements = [GaiaGrid.Placeholder];
|
||||
|
||||
function isNonVisual(elem) {
|
||||
var retVal = false;
|
||||
for (var i = 0, iLen = nonVisualElements.length; i < iLen && !retVal;
|
||||
i++) {
|
||||
retVal = elem instanceof nonVisualElements[i];
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
var i = 0 ;
|
||||
var iLen = this._grid.items.length;
|
||||
while (i < iLen) {
|
||||
this._grid.items[i].detail.index = i;
|
||||
if (isNonVisual(this._grid.items[i])) {
|
||||
this._grid.items.splice(i,1);
|
||||
iLen -= 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move item on orig position to dst position
|
||||
* This function only moves elements on the items array, without calling
|
||||
* render.
|
||||
* @param {number} orig Element's position to move
|
||||
* @param {number} orig New position of the item
|
||||
*/
|
||||
proto.moveTo = function(orig, dst) {
|
||||
this._grid.items.splice(dst, 0,
|
||||
this._grid.items.splice(orig, 1)[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* We clone the scoped stylesheet and append
|
||||
* it outside the shadow-root so that we can
|
||||
* style projected <content> without the need
|
||||
* of the :content selector.
|
||||
*
|
||||
* When the :content selector lands, we won't
|
||||
* need this hack anymore and can style projected
|
||||
* <content> from stylesheets within the shadow root.
|
||||
* (bug 992249)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
proto._styleHack = function() {
|
||||
var style = this._template.querySelector('style');
|
||||
this.appendChild(style.cloneNode(true));
|
||||
};
|
||||
|
||||
var stylesheet = baseurl + 'style.css';
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = `<style scoped>
|
||||
@import url(${stylesheet});</style>
|
||||
<content></content>`;
|
||||
|
||||
// Register and return the constructor
|
||||
return document.registerElement('gaia-grid', { prototype: proto });
|
||||
})(window);
|
||||
409
gaia/shared/elements/gaia_grid/style.css
Executable file
|
|
@ -0,0 +1,409 @@
|
|||
.icon[data-app-state="loading"]:after {
|
||||
content: "";
|
||||
background: no-repeat top center url('images/app_install_spinner.png');
|
||||
background-size: contain;
|
||||
height: 8.4rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
/**
|
||||
Keyframe must be defined outside of the web component.
|
||||
See bug 1018269
|
||||
*/
|
||||
|
||||
animation: rotate 2.25s linear infinite;
|
||||
}
|
||||
|
||||
.icon[data-app-state="loading"] {
|
||||
/**
|
||||
We must align this value with the value of [data-app-state]:after height the background size
|
||||
here is overridden at the element level elsewhere which causes the spinner to be aligned too
|
||||
low.
|
||||
*/
|
||||
background-size: 8.4rem !important;
|
||||
background-image: url('images/app_installing.png') !important;
|
||||
}
|
||||
|
||||
.icon[data-app-state="unrecoverable"] {
|
||||
background-image: url('images/app_install_unrecoverable.png') !important;
|
||||
}
|
||||
|
||||
.icon[data-app-state="error"] {
|
||||
background-image: url('images/app_install_failed.png') !important;
|
||||
}
|
||||
|
||||
.icon[data-app-state="paused"] {
|
||||
background-image: url('images/app_install_canceled.png') !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
transform-origin: 0 0;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
background: no-repeat top center;
|
||||
z-index: 3;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.icon.launching {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
/* XXX: Override the .remove element */
|
||||
.edit-mode .collection.hovered .remove {
|
||||
background: url(images/add.png) no-repeat;
|
||||
background-size: 2rem;
|
||||
}
|
||||
|
||||
.icon p {
|
||||
pointer-events: none;
|
||||
height: 3.2rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon .title {
|
||||
display: inline-block;
|
||||
margin-top: 0.1rem;
|
||||
/* We need this rule in order not to hide the drop shadow */
|
||||
padding: 0 0.2rem 1rem;
|
||||
text-shadow: 0.1rem 0.1rem 0.3rem rgba(0, 0, 0, 0.3),
|
||||
0.2rem 0.4rem 1rem rgba(0, 0, 0, 0.4);
|
||||
font-size: 1.4rem;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: calc(100% - 0.4rem);
|
||||
box-sizing: border-box;
|
||||
vertical-align: top;
|
||||
transition: opacity var(--transition-duration);
|
||||
}
|
||||
|
||||
/* We allow more space for text in the search app by hooking into the text-rows attribute on gaia-grid. */
|
||||
[text-rows="3"] .icon .title {
|
||||
height: 5.8rem;
|
||||
}
|
||||
|
||||
[cols="4"] .icon .title {
|
||||
/* Font size is the same independent on grid configuration */
|
||||
font-size: calc(1.2rem * 1.333);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.94rem;
|
||||
}
|
||||
|
||||
.edit-mode .icon.collapsed {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon.collapsed .title {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon.hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
padding-top: 2.3rem;
|
||||
border-left: 0.6rem solid transparent;
|
||||
border-right: 0.6rem solid transparent;
|
||||
box-sizing: border-box;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
[cols="4"] .divider {
|
||||
padding-top: 1.6rem;
|
||||
}
|
||||
|
||||
.divider span.spacer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.5));
|
||||
margin: 0 25%;
|
||||
width: 50%;
|
||||
height: 0.2rem;
|
||||
transition: opacity var(--transition-duration);
|
||||
}
|
||||
|
||||
.dragging .icon.active:not(.hidden) {
|
||||
z-index: 6;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.dragging .divider:not(.group),
|
||||
.icon:not(.active),
|
||||
.dropped {
|
||||
transition: transform var(--transition-duration),
|
||||
opacity var(--transition-duration);
|
||||
}
|
||||
|
||||
.icon.active {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.edit-mode .icon .remove {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
left: 0.5rem;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
background: url(images/remove.png) no-repeat;
|
||||
background-size: 2rem;
|
||||
/*
|
||||
Ensure that remove is on top of the icon and has some extra
|
||||
padding so it is easy to click.
|
||||
*/
|
||||
z-index: 200;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.icon .remove,
|
||||
.icon.collapsed .remove,
|
||||
.edit-mode .icon.active .remove {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-mode .icon.editable .title {
|
||||
background-color: rgba(132, 132, 132, 0.7);
|
||||
border-radius: 2rem;
|
||||
margin-top: .4rem;
|
||||
padding: 0 1rem;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
Disable events for placeholders, so we don't trigger touch events in edit mode.
|
||||
We only do this in edit-mode, otherwise we might trigger entering edit mode
|
||||
instead of the context menu.
|
||||
*/
|
||||
.edit-mode .placeholder {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#confirm-message {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dragging .group.active {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.divider.group {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.group > span {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
transition: background-color var(--transition-duration),
|
||||
opacity var(--transition-duration);
|
||||
}
|
||||
|
||||
.group:not(.active):not(.newly-created) > span {
|
||||
transition: background-color var(--transition-duration),
|
||||
opacity var(--transition-duration),
|
||||
transform var(--transition-duration);
|
||||
}
|
||||
|
||||
.group.active > span {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.group .header {
|
||||
height: 4rem;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.group .header span,
|
||||
.group .header button {
|
||||
position: absolute;
|
||||
height: 4rem;
|
||||
top: 0;
|
||||
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 2rem;
|
||||
|
||||
transition: transform 0.25s, opacity 0.25s;
|
||||
}
|
||||
|
||||
.group .header .gripper {
|
||||
left: calc(50% - 4.2rem);
|
||||
width: 8.4rem;
|
||||
background-image: url('images/move_gripper.png');
|
||||
opacity: 0;
|
||||
transform: translate(0, -100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edit-mode .group:not(.collapsed) .header .gripper {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.group .header .toggle {
|
||||
width: 5.34rem;
|
||||
right: 0;
|
||||
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
background-color: transparent;
|
||||
background-image: url('images/collapse_arrow.png');
|
||||
transform-origin: 50% 1.6rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.group .header .toggle.toggling {
|
||||
opacity: 0.5;
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
.group.collapsed .header .toggle {
|
||||
transform: translateY(2rem) rotateZ(180deg);
|
||||
}
|
||||
|
||||
.group.active .header .toggle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.group .background {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
height: 0.1rem;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.group.odd:not(.newly-created) .background,
|
||||
.edit-mode .group:not(.newly-created) .background {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group.active .background {
|
||||
background-color: rgba(51, 51, 51, 0.9);
|
||||
}
|
||||
|
||||
.group.hovering .background,
|
||||
.group.drop-target .background {
|
||||
background-color: rgba(27, 185, 225, 0.5);
|
||||
}
|
||||
|
||||
.group .shadow {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edit-mode .group.active .shadow {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0.9rem 1.2rem rgba(0, 0, 0, 0.63);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group .separator {
|
||||
margin: 0;
|
||||
height: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.group .separator > span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.25s, opacity 0.25s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.group.odd .separator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group.odd .separator > span {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edit-mode .group .separator,
|
||||
.edit-mode .group .separator > span {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* For preventing entering edit mode when long-pressing on a placeholder */
|
||||
.edit-mode .group .background,
|
||||
.edit-mode .group .shadow,
|
||||
|
||||
.group.collapsed .header,
|
||||
.group.collapsed .background,
|
||||
.group.collapsed .shadow {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edit-mode .group.collapsed .background {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dragging .group:not(.active) .separator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dragging .group:not(.active):not(.invalid-drop) .separator > span {
|
||||
background-color: #0095dd;
|
||||
transform: scaleY(0.325);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dragging .group.hovered:not(.active) .separator > span {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* A hint element for dragging items to the top of the group */
|
||||
.edit-mode::after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.8rem;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
background-color: #0095dd;
|
||||
|
||||
position: relative;
|
||||
top: -0.8rem;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.edit-mode.hover-over-top::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Shift container downwards when hovering over the top to reveal the group
|
||||
* creation hint.
|
||||
*/
|
||||
.edit-mode {
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
.edit-mode.hover-over-top {
|
||||
transform: translateY(0.8rem);
|
||||
}
|
||||
|
||||
/* RTL View */
|
||||
:-moz-dir(rtl) .group .header .toggle {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
24
gaia/shared/elements/gaia_grid/style/external.css
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
Some css properties are global (keyframes) and cannot be specified
|
||||
to to only apply to our shadow dom so we hack around this by including
|
||||
this file without scoping it.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Bug 1018269 - we need to specify the keyframe outside
|
||||
of the web component...
|
||||
*/
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(1deg)
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
:-moz-dir(rtl) gaia-grid {
|
||||
direction: ltr;
|
||||
}
|
||||