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.
373
assets/webapps/bookmark.gaiamobile.org/shared/elements/gaia-header/dist/gaia-header.js
vendored
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
;(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GaiaHeader=f()}})(function(){var define,module,exports;return(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){;(function(define){define(function(require,exports,module){'use strict';var debug=0?console.log.bind(console):function(){};var cache={};var MIN=16;var MAX=24;var BUFFER=3;module.exports=function(config){debug('font fit',config);var space=config.space-BUFFER;var min=config.min||MIN;var max=config.max||MAX;var text=trim(config.text);var fontSize=max;var textWidth;var font;do{font=config.font.replace(/\d+px/,fontSize+'px');textWidth=getTextWidth(text,font);}while(textWidth>space&&fontSize!==min&&fontSize--);return{textWidth:textWidth,fontSize:fontSize,overflowing:textWidth>space};};function getTextWidth(text,font){var ctx=getCanvasContext(font);var width=ctx.measureText(text).width;debug('got text width',width);return width;}
|
||||
function getCanvasContext(font){debug('get canvas context',font);var cached=cache[font];if(cached){return cached;}
|
||||
var canvas=document.createElement('canvas');canvas.setAttribute('moz-opaque','true');canvas.setAttribute('width','1px');canvas.setAttribute('height','1px');debug('created canvas',canvas);var ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.font=font;return cache[font]=ctx;}
|
||||
function trim(text){return text.replace(/\s+/g,' ').trim();}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('font-fit',this));},{}],2:[function(require,module,exports){;(function(define){'use strict';define(function(require,exports,module){var textContent=Object.getOwnPropertyDescriptor(Node.prototype,'textContent');var innerHTML=Object.getOwnPropertyDescriptor(Element.prototype,'innerHTML');var removeAttribute=Element.prototype.removeAttribute;var setAttribute=Element.prototype.setAttribute;var noop=function(){};exports.register=function(name,props){var baseProto=getBaseProto(props.extends);var template=props.template||baseProto.templateString;var extensible=props.extensible=props.hasOwnProperty('extensible')?props.extensible:true;delete props.extends;if(template){if(extensible&&props.template){props.templateString=props.template;}
|
||||
var output=processCss(template,name);props.template=document.createElement('template');props.template.innerHTML=output.template;props.lightCss=output.lightCss;props.globalCss=props.globalCss||'';props.globalCss+=output.globalCss;}
|
||||
injectGlobalCss(props.globalCss);delete props.globalCss;var descriptors=mixin(props.attrs||{},base.descriptors);props._attrs=props.attrs;delete props.attrs;var proto=createProto(baseProto,props);Object.defineProperties(proto,descriptors);try{return document.registerElement(name,{prototype:proto});}catch(e){if(e.name!=='NotSupportedError'){throw e;}}};var base={properties:{GaiaComponent:true,attributeChanged:noop,attached:noop,detached:noop,created:noop,createdCallback:function(){if(this.dirObserver){addDirObserver();}
|
||||
injectLightCss(this);this.created();},attributeChangedCallback:function(name,from,to){var prop=toCamelCase(name);if(this._attrs&&this._attrs[prop]){this[prop]=to;}
|
||||
this.attributeChanged(name,from,to);},attachedCallback:function(){if(this.dirObserver){this.setInnerDirAttributes=setInnerDirAttributes.bind(null,this);document.addEventListener('dirchanged',this.setInnerDirAttributes);}
|
||||
this.attached();},detachedCallback:function(){if(this.dirObserver){document.removeEventListener('dirchanged',this.setInnerDirAttributes);}
|
||||
this.detached();},setupShadowRoot:function(){if(!this.template){return;}
|
||||
var node=document.importNode(this.template.content,true);this.createShadowRoot().appendChild(node);if(this.dirObserver){setInnerDirAttributes(this);}
|
||||
return this.shadowRoot;},setAttr:function(name,value){var internal=this.shadowRoot.firstElementChild;setAttribute.call(internal,name,value);setAttribute.call(this,name,value);},removeAttr:function(name){var internal=this.shadowRoot.firstElementChild;removeAttribute.call(internal,name);removeAttribute.call(this,name);}},descriptors:{textContent:{set:function(value){textContent.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:function(){return textContent.get();}},innerHTML:{set:function(value){innerHTML.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:innerHTML.get}}};var defaultPrototype=createProto(HTMLElement.prototype,base.properties);function getBaseProto(proto){if(!proto){return defaultPrototype;}
|
||||
proto=proto.prototype||proto;return!proto.GaiaComponent?createProto(proto,base.properties):proto;}
|
||||
function createProto(proto,props){return mixin(Object.create(proto),props);}
|
||||
var hasShadowCSS=(function(){var div=document.createElement('div');try{div.querySelector(':host');div.querySelector('::content');return true;}
|
||||
catch(e){return false;}})();var regex={shadowCss:/(?:\:host|\:\:content)[^{]*\{[^}]*\}/g,':host':/(?:\:host)/g,':host()':/\:host\((.+)\)(?: \:\:content)?/g,':host-context':/\:host-context\((.+)\)([^{,]+)?/g,'::content':/(?:\:\:content)/g};function processCss(template,name){var globalCss='';var lightCss='';if(!hasShadowCSS){template=template.replace(regex.shadowCss,function(match){var hostContext=regex[':host-context'].exec(match);if(hostContext){globalCss+=match.replace(regex['::content'],'').replace(regex[':host-context'],'$1 '+name+'$2').replace(/ +/g,' ');}else{lightCss+=match.replace(regex[':host()'],name+'$1').replace(regex[':host'],name).replace(regex['::content'],name);}
|
||||
return'';});}
|
||||
return{template:template,lightCss:lightCss,globalCss:globalCss};}
|
||||
function injectGlobalCss(css){if(!css){return;}
|
||||
var style=document.createElement('style');style.innerHTML=css.trim();headReady().then(function(){document.head.appendChild(style);});}
|
||||
function headReady(){return new Promise(function(resolve){if(document.head){return resolve();}
|
||||
window.addEventListener('load',function fn(){window.removeEventListener('load',fn);resolve();});});}
|
||||
function injectLightCss(el){if(hasShadowCSS){return;}
|
||||
var stylesheet=el.querySelector('style');if(!stylesheet){stylesheet=document.createElement('style');stylesheet.setAttribute('scoped','');stylesheet.appendChild(document.createTextNode(el.lightCss));el.appendChild(stylesheet);}
|
||||
el.lightStyle=stylesheet;}
|
||||
function toCamelCase(string){return string.replace(/-(.)/g,function replacer(string,p1){return p1.toUpperCase();});}
|
||||
var dirObserver;function setInnerDirAttributes(component){var dir=component.dir||document.dir;Array.from(component.shadowRoot.children).forEach(element=>{if(element.nodeName!=='STYLE'){element.dir=dir;}});}
|
||||
function addDirObserver(){if(dirObserver){return;}
|
||||
dirObserver=new MutationObserver(onChanged);dirObserver.observe(document.documentElement,{attributeFilter:['dir'],attributes:true});function onChanged(mutations){document.dispatchEvent(new Event('dirchanged'));}}
|
||||
function mixin(target,source){for(var key in source){target[key]=source[key];}
|
||||
return target;}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-component',this));},{}],3:[function(require,module,exports){(function(define){'use strict';define(function(require,exports,module){var base=window.GAIA_ICONS_BASE_URL||window.COMPONENTS_BASE_URL||'bower_components/';if(!document.documentElement){window.addEventListener('load',load);}else{load();}
|
||||
function load(){if(isLoaded()){return;}
|
||||
var link=document.createElement('link');link.rel='stylesheet';link.type='text/css';link.href=base+'gaia-icons/gaia-icons.css';document.head.appendChild(link);exports.loaded=true;}
|
||||
function isLoaded(){return exports.loaded||document.querySelector('link[href*=gaia-icons]')||document.documentElement.classList.contains('gaia-icons-loaded');}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-icons',this));},{}],4:[function(require,module,exports){;(function(define){'use strict';define(function(require,exports,module){var component=require('gaia-component');var fontFit=require('font-fit');require('gaia-icons');var debug=0?console.log.bind(console):function(){};const KNOWN_ACTIONS={menu:'menu',back:'back',close:'close'};const TITLE_FONT='italic 300 24px FiraSans';const TITLE_PADDING=10;const MINIMUM_FONT_SIZE_CENTERED=20;const MINIMUM_FONT_SIZE_UNCENTERED=16;const MAXIMUM_FONT_SIZE=23;module.exports=component.register('gaia-header',{extensible:false,dirObserver:true,created:function(){debug('created');this.setupShadowRoot();this.els={actionButton:this.shadowRoot.querySelector('.action-button'),titles:this.getElementsByTagName('h1')};this.els.actionButton.addEventListener('click',e=>this.onActionButtonClick(e));this.observer=new MutationObserver(this.onMutation.bind(this));this.ignoreDir=this.hasAttribute('ignore-dir');this.titleEnd=this.getAttribute('title-end');this.titleStart=this.getAttribute('title-start');this.noFontFit=this.getAttribute('no-font-fit');this.notFlush=this.hasAttribute('not-flush');this.action=this.getAttribute('action');this.unresolved={};this.pending={};this._resizeThrottlingId=null;this.onResize=this.onResize.bind(this);},attached:function(){debug('attached');this.runFontFitSoon();this.observerStart();window.addEventListener('resize',this.onResize);},detached:function(){debug('detached');window.removeEventListener('resize',this.onResize);this.observerStop();this.clearPending();},clearPending:function(){for(var key in this.pending){this.pending[key].clear();delete this.pending[key];}
|
||||
window.cancelAnimationFrame(this._resizeThrottlingId);this._resizeThrottlingId=null;},runFontFit:function(){debug('run font-fit');if(this.noFontFit){return Promise.resolve();}
|
||||
var titles=this.els.titles;var space=this.getTitleSpace();var styles=[].map.call(titles,el=>this.getTitleStyle(el,space));return this.setTitleStylesSoon(styles);},runFontFitSoon:function(){debug('run font-fit soon');if(this.pending.runFontFitSoon){return;}
|
||||
this.pending.runFontFitSoon=this.nextTick(()=>{delete this.pending.runFontFitSoon;this.runFontFit();});},getTitleStyle:function(el,space){debug('get el style',el,space);var text=el.textContent;var styleId=space.start+text+space.end+'#'+space.value;if(!text||!text.trim()){return debug('exit: no text');}
|
||||
if(getStyleId(el)===styleId){return debug('exit: no change');}
|
||||
var marginStart=this.getTitleMarginStart();var textSpace=space.value-Math.abs(marginStart);var fontFitResult=this.fontFit(text,textSpace,{min:MINIMUM_FONT_SIZE_CENTERED});var overflowing=fontFitResult.overflowing;var padding={start:0,end:0};if(overflowing){debug('title overflowing');padding.start=!space.start?TITLE_PADDING:0;padding.end=!space.end?TITLE_PADDING:0;textSpace=space.value-padding.start-padding.end;fontFitResult=this.fontFit(text,textSpace);marginStart=0;}
|
||||
return{id:styleId,fontSize:fontFitResult.fontSize,marginStart:marginStart,overflowing:overflowing,padding:padding};},setTitleStylesSoon:function(styles){debug('set title styles soon',styles);var key='setStyleTitlesSoon';this._titleStyles=styles;if(this.unresolved[key]){return this.unresolved[key];}
|
||||
this.unresolved[key]=new Promise((resolve)=>{this.pending[key]=this.nextTick(()=>{var styles=this._titleStyles;var els=this.els.titles;[].forEach.call(els,(el,i)=>{if(!styles[i]){return debug('exit');}
|
||||
this.setTitleStyle(el,styles[i]);});delete this._titleStyles;delete this.unresolved[key];delete this.pending[key];resolve();});});},setTitleStyle:function(el,style){debug('set title style',style);this.observerStop();if(this.ignoreDir){el.style.marginLeft=style.marginStart+'px';el.style.paddingLeft=style.padding.start+'px';el.style.paddingRight=style.padding.end+'px';}else{el.style.marginInlineStart=style.marginStart+'px';el.style.paddingInlineStart=style.padding.start+'px';el.style.paddingInlineEnd=style.padding.end+'px';}
|
||||
el.style.fontSize=style.fontSize+'px';setStyleId(el,style.id);this.observerStart();},fontFit:function(text,space,opts={}){debug('font fit:',text,space,opts);var fontFitArgs={font:TITLE_FONT,min:opts.min||MINIMUM_FONT_SIZE_UNCENTERED,max:MAXIMUM_FONT_SIZE,text:text,space:space};return fontFit(fontFitArgs);},observerStart:function(){if(this.observing){return;}
|
||||
this.observer.observe(this,{childList:true,attributes:true,subtree:true});this.observing=true;debug('observer started');},observerStop:function(){if(!this.observing){return;}
|
||||
this.observer.disconnect();this.observing=false;debug('observer stopped');},onResize:function(e){debug('onResize',this._resizeThrottlingId);if(this._resizeThrottlingId!==null){return;}
|
||||
this._resizeThrottlingId=window.requestAnimationFrame(()=>{this._resizeThrottlingId=null;this.runFontFitSoon();});},onMutation:function(mutations){debug('on mutation',mutations);if(!this.pending.runFontFitSoon){this.runFontFit();}},getTitleSpace:function(){var start=this.titleStart;var end=this.titleEnd;var space=this.getWidth()-start-end;var result={value:space,start:start,end:end};debug('get title space',result);return result;},getWidth:function(){var value=this.notFlush?this.clientWidth:window.innerWidth;debug('get width',value);return value;},triggerAction:function(){if(this.action){this.els.actionButton.click();}},onActionButtonClick:function(){debug('action button click');var config={detail:{type:this.action}};var e=new CustomEvent('action',config);setTimeout(()=>this.dispatchEvent(e));},getTitleMarginStart:function(){var start=this.titleStart;var end=this.titleEnd;var marginStart=end-start;debug('get title margin start',marginStart);return marginStart;},getButtonsBeforeTitle:function(){var children=this.children;var l=children.length;var els=[];for(var i=0;i<l;i++){var el=children[i];if(el.tagName==='H1'){break;}
|
||||
if(!contributesToLayout(el)){continue;}
|
||||
els.push(el);}
|
||||
if(this.action){els.push(this.els.actionButton);}
|
||||
return els;},getButtonsAfterTitle:function(){var children=this.children;var els=[];for(var i=children.length-1;i>=0;i--){var el=children[i];if(el.tagName==='H1'){break;}
|
||||
if(!contributesToLayout(el)){continue;}
|
||||
els.push(el);}
|
||||
return els;},sumButtonWidths:function(buttons){var defaultWidth=50;var sum=buttons.reduce((prev,button)=>{var isStandardButton=button===this.els.actionButton;var width=isStandardButton?defaultWidth:button.clientWidth;return prev+width;},0);debug('sum button widths',buttons,sum);return sum;},attrs:{action:{get:function(){return this._action;},set:function(value){var action=KNOWN_ACTIONS[value];if(action===this._action){return;}
|
||||
this.setAttr('action',action);this._action=action;}},titleStart:{get:function(){debug('get title-start');if('_titleStart'in this){return this._titleStart;}
|
||||
var buttons=this.getButtonsBeforeTitle();var value=this.sumButtonWidths(buttons);debug('get title-start',buttons,value);return value;},set:function(value){debug('set title-start',value);value=parseInt(value,10);if(value===this._titleStart||isNaN(value)){return;}
|
||||
this.setAttr('title-start',value);this._titleStart=value;debug('set');}},titleEnd:{get:function(){debug('get title-end');if('_titleEnd'in this){return this._titleEnd;}
|
||||
var buttons=this.getButtonsAfterTitle();return this.sumButtonWidths(buttons);},set:function(value){debug('set title-end',value);value=parseInt(value,10);if(value===this._titleEnd||isNaN(value)){return;}
|
||||
this.setAttr('title-end',value);this._titleEnd=value;}},noFontFit:{get:function(){return this._noFontFit||false;},set:function(value){debug('set no-font-fit',value);value=!!(value||value==='');if(value===this.noFontFit){return;}
|
||||
this._noFontFit=value;if(value){this.setAttr('no-font-fit','');}
|
||||
else{this.removeAttr('no-font-fit');}}},ignoreDir:{get:function(){return this._ignoreDir||false;},set:function(value){debug('set ignore-dir',value);value=!!(value||value==='');if(value===this.ignoreDir){return;}
|
||||
this._ignoreDir=value;this.dirObserver=!value;if(value){this.setAttr('ignore-dir','');}
|
||||
else{this.removeAttr('ignore-dir');}}}},template:`<div class="inner">
|
||||
<button class="action-button">
|
||||
<content select="[l10n-action]"></content>
|
||||
</button>
|
||||
<content></content>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
-moz-user-select: none;
|
||||
|
||||
--gaia-header-button-color:
|
||||
var(--header-button-color,
|
||||
var(--header-color,
|
||||
var(--link-color,
|
||||
inherit)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [hidden]
|
||||
*/
|
||||
|
||||
:host[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** Reset
|
||||
---------------------------------------------------------*/
|
||||
|
||||
::-moz-focus-inner { border: 0; }
|
||||
|
||||
/** Inner
|
||||
---------------------------------------------------------*/
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
-moz-user-select: none;
|
||||
|
||||
background:
|
||||
var(--header-background,
|
||||
var(--background,
|
||||
#fff));
|
||||
}
|
||||
|
||||
/** Action Button
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. Hidden by default
|
||||
*/
|
||||
|
||||
.action-button {
|
||||
position: relative;
|
||||
|
||||
display: none; /* 1 */
|
||||
width: 50px;
|
||||
font-size: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
|
||||
align-items: center;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms 280ms;
|
||||
color:
|
||||
var(--header-action-button-color,
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [action=back]
|
||||
* [action=menu]
|
||||
* [action=close]
|
||||
*
|
||||
* 1. For icon vertical-alignment
|
||||
*/
|
||||
|
||||
[action=back] .action-button,
|
||||
[action=menu] .action-button,
|
||||
[action=close] .action-button {
|
||||
display: flex; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* :active
|
||||
*/
|
||||
|
||||
.action-button:active {
|
||||
transition: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/** Action Button Icon
|
||||
---------------------------------------------------------*/
|
||||
|
||||
.action-button:before {
|
||||
font-family: 'gaia-icons';
|
||||
font-style: normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[action=close] .action-button:before { content: 'close' }
|
||||
[action=menu] .action-button:before { content: 'menu' }
|
||||
|
||||
[action=back]:-moz-dir(ltr) .action-button:before { content: 'left' }
|
||||
[action=back]:-moz-dir(rtl) .action-button:before { content: 'right' }
|
||||
|
||||
/** Action Button Icon
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. To enable vertical alignment.
|
||||
*/
|
||||
|
||||
.action-button:before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/** Action Button Text
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* To provide custom localized content for
|
||||
* the action-button, we allow the user
|
||||
* to provide an element with the class
|
||||
* .l10n-action. This node is then
|
||||
* pulled inside the real action-button.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* <gaia-header action="back">
|
||||
* <span l10n-action aria-label="Back">Localized text</span>
|
||||
* <h1>title</h1>
|
||||
* </gaia-header>
|
||||
*/
|
||||
|
||||
::content [l10n-action] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
/** Title
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 1. Vertically center text. We can't use flexbox
|
||||
* here as it breaks text-overflow ellipsis
|
||||
* without an inner div.
|
||||
*/
|
||||
|
||||
::content h1 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
line-height: 50px; /* 1 */
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-size: 24px;
|
||||
|
||||
color:
|
||||
var(--header-title-color,
|
||||
var(--header-color,
|
||||
var(--title-color,
|
||||
var(--text-color,
|
||||
inherit))));
|
||||
}
|
||||
|
||||
/**
|
||||
* [ignore-dir]
|
||||
*
|
||||
* When the <gaia-header> component has an [ignore-dir] attribute, header
|
||||
* direction is forced to LTR but we still want the <h1> text to be reversed
|
||||
* so that strings like '1 selected' become 'selected 1'.
|
||||
*
|
||||
* When we're happy for <gaia-header> to be fully RTL responsive we won't need
|
||||
* these rules anymore, but this depends on all Gaia apps being ready.
|
||||
*
|
||||
* This should be safe to remove when bug 1179459 lands.
|
||||
*/
|
||||
|
||||
:host[ignore-dir] {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
:host[ignore-dir]:-moz-dir(rtl) h1 {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/** Buttons
|
||||
---------------------------------------------------------*/
|
||||
|
||||
::content a,
|
||||
::content button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 50px;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
outline: 0;
|
||||
border: 0;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms 280ms;
|
||||
color: var(--gaia-header-button-color);
|
||||
}
|
||||
|
||||
/**
|
||||
* :active
|
||||
*/
|
||||
|
||||
::content a:active,
|
||||
::content button:active {
|
||||
transition: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* [hidden]
|
||||
*/
|
||||
|
||||
::content a[hidden],
|
||||
::content button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* [disabled]
|
||||
*/
|
||||
|
||||
::content a[disabled],
|
||||
::content button[disabled] {
|
||||
pointer-events: none;
|
||||
color: var(--header-disabled-button-color);
|
||||
}
|
||||
|
||||
/** Icon Buttons
|
||||
---------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Icons are a different color to text
|
||||
*/
|
||||
|
||||
::content .icon,
|
||||
::content [data-icon] {
|
||||
color:
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color));
|
||||
}
|
||||
|
||||
/**
|
||||
* If users want their action button
|
||||
* to be in the component's light-dom
|
||||
* they can add an .action class
|
||||
* to make it look like the
|
||||
* shadow action button.
|
||||
*/
|
||||
|
||||
::content .action {
|
||||
color:
|
||||
var(--header-action-button-color,
|
||||
var(--header-icon-color,
|
||||
var(--gaia-header-button-color)));
|
||||
}
|
||||
|
||||
/**
|
||||
* [data-icon]:empty
|
||||
*
|
||||
* Icon buttons with no textContent,
|
||||
* should always be 50px.
|
||||
*
|
||||
* This is to prevent buttons being
|
||||
* larger than they should be before
|
||||
* icon-font has loaded.
|
||||
*/
|
||||
|
||||
::content [data-icon]:empty {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
</style>`,nextTick:nextTick});function contributesToLayout(el){return el.localName!=='style'&&!el.hasAttribute('l10n-action');}
|
||||
function setStyleId(el,id){el._styleId=id;}
|
||||
function getStyleId(el){return el._styleId;}
|
||||
function nextTick(fn){var cleared;Promise.resolve().then(()=>{if(!cleared){fn();}});return{clear:function(){cleared=true;}};}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-header',this));},{"font-fit":1,"gaia-component":2,"gaia-icons":3}]},{},[4])(4)});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/* Generated by grunt-webfont */
|
||||
/* Based on https://github.com/endtwist/fontcustom/blob/master/lib/fontcustom/templates/fontcustom.css */
|
||||
|
||||
@font-face {
|
||||
font-family: "gaia-icons";
|
||||
src: url("fonts/gaia-icons.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[data-icon]:before,
|
||||
.ligature-icons {
|
||||
font-family: "gaia-icons";
|
||||
content: attr(data-icon);
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
text-decoration: inherit;
|
||||
text-transform: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-size: 30px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
[data-icon]:not([data-l10n-id]):not([aria-label]):not([aria-hidden="true"]):before {
|
||||
/* Highlight cases where icons are not localized and still visible to screen
|
||||
reader */
|
||||
background: rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-icon]:not([data-l10n-id]):not([aria-label]):not([aria-hidden="true"]):after {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
width: 20rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
content: "Icon is not localized and is still visible to screen reader. Please add data-l10n-id or aria-label for localization or use aria-hidden to hide the icon from screen reader.";
|
||||
z-index: 9999;
|
||||
}
|
||||
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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));
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
;'use strict';(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={unscaledCanvasPadding:UNSCALED_CANVAS_PADDING*2,get _maxSize(){return this._icon.grid.layout.gridMaxIconSize;},_createCanvas:function(){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;},_createClipCanvas:function(){var canvas=document.createElement('canvas');canvas.width=this._maxSize+(CANVAS_PADDING*2);canvas.height=this._maxSize+(CANVAS_PADDING*2);return canvas;},_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;},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);});},favicon:function(img){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;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();iconWidth=iconHeight=this._maxSize*0.55;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);});},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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||