Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,28 @@
;;(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));

View file

@ -0,0 +1,7 @@
/* XXX Bug 1018269
* animation keyframes have to be specified outside of the shadow DOM.
*/
@keyframes rotate {
from { transform: rotate(1deg); }
to { transform: rotate(360deg); }
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><path opacity=".85" fill="#5F5F5F" d="M42 0c23.3 0 42 18.7 42 42S65.3 84 42 84 0 65.3 0 42 18.7 0 42 0z"/><path fill="#FFF" d="M60.7 40.1c-.9-9.3-9.3-16.8-18.7-16.8-10.3 0-18.7 8.4-18.7 18.7S31.7 60.7 42 60.7c3.7 0 7.5-.9 10.3-2.8l-2.8-3.7C46.7 56.1 44.8 57 42 57c-8.4 0-14.9-6.5-14.9-14.9s6.5-15 14.9-15c7.5 0 13.1 5.6 14 13.1h-6.5l7.5 7.5c.9.9 2.8.9 3.7 0l7.5-7.5h-7.5z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><path opacity=".85" fill="#E51E1E" d="M42 0c23.3 0 42 18.7 42 42S65.3 84 42 84 0 65.3 0 42 18.7 0 42 0z"/><path fill="#FFF" d="M60.7 40.1c-.9-9.3-9.3-16.8-18.7-16.8-10.3 0-18.7 8.4-18.7 18.7S31.7 60.7 42 60.7c3.7 0 7.5-.9 10.3-2.8l-2.8-3.7C46.7 56.1 44.8 57 42 57c-8.4 0-14.9-6.5-14.9-14.9s6.5-15 14.9-15c7.5 0 13.1 5.6 14 13.1h-6.5l7.5 7.5c.9.9 2.8.9 3.7 0l7.5-7.5h-7.5z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="24.134" y1="31.414" x2="81.176" y2="46.699"><stop offset="0" stop-color="#00B4CF"/><stop offset=".979" stop-color="#00B4CF" stop-opacity="0"/></linearGradient><path fill-rule="evenodd" clip-rule="evenodd" fill="url(#a)" d="M83.9 39.5v2.6h-3.3c0 21.3-17.3 38.6-38.7 38.6C20.6 80.7 3.3 63.4 3.3 42 3.3 20.6 20.6 3.3 42 3.3V0C18.8 0 0 18.8 0 42s18.8 42 42 42 42-18.8 42-42c0-.8 0-1.7-.1-2.5z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><path opacity=".85" fill="#E51E1E" d="M42 0c23.3 0 42 18.8 42 42 0 23.3-18.8 42-42 42-23.1.1-42-18.7-42-42C0 18.8 18.9 0 42 0z"/><path fill="#FFF" d="M42 25.4c-.9 0-2.5 2.5-3.4 5.5 1.1-.2 2.3-.3 3.5-.3s2.3.1 3.3.3c-.9-3-2.5-5.5-3.4-5.5M27.1 39.1l5.7-5.7c1.7-8.3 6.7-16.7 9.2-16.7s7.5 8.4 9.2 16.7l5.7 5.7V54h-4l-5.6-5.6c-.6.6-1.2 1.1-1.9 1.5h-7c-.7-.4-1.3-.9-1.9-1.5L31.1 54h-4V39.1M16.6 59.2c.4.9 1.3 1.5 2.4 1.5h46c1 0 1.9-.6 2.4-1.5H16.6z"/></svg>

After

Width:  |  Height:  |  Size: 513 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><path opacity=".85" fill="#333" d="M42 0c23.3 0 42 18.8 42 42 0 23.3-18.8 42-42 42-23.1.1-42-18.7-42-42C0 18.8 18.9 0 42 0z"/><path fill="#FFF" d="M29.9 50c.4.9 1.3 1.5 2.4 1.5h19.3c1 0 1.9-.6 2.4-1.5H29.9zm22.3-17.8L51 33.4v.1L42.4 42c-.3.3-.7.3-1 0l-8.6-8.6v-.1l-1.2-1.2c-1.6.3-3.1.6-4.6.9.2.5.4 1 .9 1.5l11.3 11.3c1.5 1.5 3.8 1.5 5.3 0l11.3-11.3c.4-.4.7-.9.9-1.5-1.4-.3-2.9-.6-4.5-.8z"/></svg>

After

Width:  |  Height:  |  Size: 459 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="42" x2="42" y2="84"><stop offset="0" stop-color="#E5EBEF"/><stop offset="1" stop-color="#EDF1F4"/></linearGradient><path fill="url(#a)" d="M42 0c23.3 0 42 18.8 42 42 0 23.3-18.8 42-42 42-23.1.1-42-18.7-42-42C0 18.8 18.9 0 42 0z"/><path fill="#9AB0C0" d="M41.5 25.9c-.9 0-2.5 2.6-3.5 5.7 1.1-.2 2.3-.3 3.6-.3 1.2 0 2.4.1 3.4.3-1-3.1-2.6-5.7-3.5-5.7M43 53.3h2.2c.3.9.4 2 .4 3.1 0 4-3.1 7.2-4.1 7.2s-4.1-3.2-4.1-7.2c0-1.1.1-2.2.4-3.1H40c-.2.5-.3 1.2-.3 1.9 0 1.8 1.4 3.3 1.9 3.3s1.9-1.5 1.9-3.3c-.2-.7-.3-1.4-.5-1.9M26.1 40l5.9-5.9c1.7-8.6 6.9-17.2 9.5-17.2s7.8 8.7 9.5 17.2l5.9 5.9v15.4h-4.1l-5.7-5.7c-.6.6-1.2 1.1-1.9 1.5h-7.3c-.7-.4-1.3-.9-1.9-1.5l-5.7 5.7h-4.1L26.1 40"/></svg>

After

Width:  |  Height:  |  Size: 799 B

View file

@ -0,0 +1,32 @@
;'use strict';window.GaiaAppIcon=(function(exports){const LAUNCH_TIMEOUT=500;const PREDEFINED_ICONS={default:'default_icon.svg',unrecoverable:'app_install_unrecoverable.svg',installing:'app_installing.svg',paused:'app_install_canceled.svg',failed:'app_install_failed.svg'};const SHADOW_BLUR=1;const SHADOW_OFFSET={x:1,y:1};const SHADOW_COLOR='rgba(0, 0, 0, 0.2)';const DEFAULT_BACKGROUND_COLOR='rgb(228, 234, 238)';const CANVAS_PADDING=2*window.devicePixelRatio;const FAVICON_SCALE=0.55;var proto=Object.create(HTMLElement.prototype);var base=window.GAIA_ICONS_BASE_URL||window.COMPONENTS_BASE_URL||'bower_components/';var baseurl=window.GaiaAppIconBaseurl||base+'gaia-site-icon/';proto.createdCallback=function(){this._template=template.content.cloneNode(true);var shadow=this.createShadowRoot();shadow.appendChild(this._template);this._container=shadow.getElementById('image-container');this._titleContainer=shadow.getElementById('title-container');this._subtitle=shadow.getElementById('subtitle');this._showName=true;this._image=null;this._app=null;this._bookmark=null;this._entryPoint='';this._size=-1;this._hasIcon=false;this._hasUserSetIcon=false;this._hasPredefinedIcon=false;this._iconUrl=null;this._pendingIconUrl=null;this._pendingIconRefresh=false;this._lastState=null;this._predefinedIcons={};for(var key in PREDEFINED_ICONS){this._predefinedIcons[key]=baseurl+'images/'+PREDEFINED_ICONS[key];}
this.tabIndex=0;this.setAttribute('role','link');var activate=()=>{if(!this.dispatchEvent(new CustomEvent('activated',{cancelable:true}))){return;}
this.launch();};this.addEventListener('keydown',(e)=>{switch(e.keyCode){case 32:case 13:activate();}});this.addEventListener('click',activate);this.refresh();};Object.defineProperty(proto,'app',{get:function(){return this._app;},set:function(app){if(this._app){this._app.removeEventListener('progress',this);this._app.removeEventListener('downloaderror',this);this._app.removeEventListener('downloadsuccess',this);this._app.removeEventListener('downloadapplied',this);}
delete this.dataset.testIconUrl;this._cancelIconLoad();this._removeOldIcon();this._hasIcon=false;this._app=app;if(!app){return;}
this._bookmark=null;this._app.addEventListener('progress',this);this._app.addEventListener('downloaderror',this);this._app.addEventListener('downloadsuccess',this);this._app.addEventListener('downloadapplied',this);this._container.classList.add('initial-load');},enumerable:true});Object.defineProperty(proto,'entryPoint',{get:function(){return this._entryPoint;},set:function(entryPoint){this._entryPoint=entryPoint?entryPoint:'';this.refresh();},enumerable:true});Object.defineProperty(proto,'state',{get:function(){if(!this.app){return'unavailable';}
if(this.app.installState==='pending'&&!this.app.downloadAvailable&&!this.app.readyToApplyDownload){return'unrecoverable';}
if(this.app.downloading){return'installing';}
if(this.app.downloadError){if(this.app.downloadError.name==='DOWNLOAD_CANCELED'){return'paused';}else{return'error';}}
if(this.app.installState==='pending'){return'paused';}
return'installed';},enumberable:true});Object.defineProperty(proto,'name',{get:function(){return this._subtitle?this._subtitle.textContent:'';},enumberable:true});Object.defineProperty(proto,'size',{get:function(){return(this._size===-1)?this.clientWidth:this._size;},set:function(size){this._size=size>=0?size:-1;},enumerable:true});Object.defineProperty(proto,'bookmark',{get:function(){return this._bookmark;},set:function(bookmark){if(!this._bookmark||this._bookmark.id!==bookmark.id){this.app=null;}
this._bookmark=bookmark;},enumerable:true});Object.defineProperty(proto,'icon',{get:function(){if(window.VulpesCompat&&this.app&&this._vulpesSourceIcon)return Promise.resolve(this._vulpesSourceIcon);return new Promise((resolve,reject)=>{if(!this._hasIcon||this._hasPredefinedIcon){return reject();}
var image=this._container.querySelector('img');if(!image){return reject();}
var canvas=document.createElement('canvas');var size=this.size*window.devicePixelRatio;canvas.width=canvas.height=size;var ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.drawImage(image,0,0,size,size);try{canvas.toBlob((blob)=>{resolve(blob);});}catch(e){console.error('Failed to get canvas data for icon');reject(e);}});},set:function(blob){if(blob){this._prepareIconLoader();this._hasUserSetIcon=true;this._pendingIconUrl='user-set';this._image.src=URL.createObjectURL(blob);}else{this._pendingIconUrl=null;delete this.dataset.testIconUrl;this._cancelIconLoad();this._removeOldIcon();}},enumerable:true});Object.defineProperty(proto,'showName',{get:function(){return this._showName;},set:function(show){show=show?true:false;if(this._showName!==show){this._showName=show;this._titleContainer.classList.toggle('hidden',!show);}},enumerable:true});Object.defineProperty(proto,'isUserSet',{get:function(){return this._iconUrl==='user-set';},enumerable:true});proto._cancelIconLoad=function(){if(!this._image){return;}
this._pendingIconUrl=null;this._image.onload=null;this._image.src='';this._image=null;};proto._removeOldIcon=function(){while(this._container.children.length>1){this._container.removeChild(this._container.lastChild);}};proto.launch=function(){if(!this.app&&!this.bookmark){return;}
this.classList.add('launching');setTimeout(()=>{this.classList.remove('launching');},LAUNCH_TIMEOUT);if(this.app){window.performance.mark('appLaunch@'+this.app.origin);this.app.launch(this.entryPoint);}else{var features={name:this.bookmark.name,remote:true};if(this.bookmark.scope){features.scope=this.bookmark.scope;}
window.open(this.bookmark.url,'_samescope',Object.keys(features).map(function eachFeature(key){return encodeURIComponent(key)+'='+
encodeURIComponent(features[key]);}).join(','));}};proto._prepareIconLoader=function(){this._cancelIconLoad();this._hasPredefinedIcon=false;this._hasUserSetIcon=false;this._image=document.createElement('img');this._image.setAttribute('role','presentation');this._image.onload=()=>{this._image.onload=()=>{this._removeOldIcon();this._container.appendChild(this._image);this._container.classList.remove('initial-load');this._image=null;this._hasIcon=true;if(this._pendingIconUrl){this._iconUrl=this.dataset.testIconUrl=this._pendingIconUrl;this._pendingIconUrl=null;}
if(!this._hasPredefinedIcon){this.dispatchEvent(new CustomEvent('icon-loaded'));}
if(this._pendingIconRefresh){this._image=null;this._pendingIconRefresh=false;this.refresh();}};if(!this._hasUserSetIcon){var canvas=document.createElement('canvas');var size=this.size*window.devicePixelRatio;canvas.width=canvas.height=size;var ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.shadowColor=SHADOW_COLOR;ctx.shadowBlur=SHADOW_BLUR;ctx.shadowOffsetX=SHADOW_OFFSET.x;ctx.shadowOffsetY=SHADOW_OFFSET.y;if(this.bookmark&&!this._hasPredefinedIcon){if(this._image.width<=size/2){ctx.beginPath();ctx.arc(size/2,size/2,size/2-CANVAS_PADDING,0,2*Math.PI);ctx.fillStyle=DEFAULT_BACKGROUND_COLOR;ctx.fill();ctx.shadowBlur=0;ctx.shadowOffsetY=0;ctx.mozImageSmoothingEnabled=false;var iconSize=size*FAVICON_SCALE;ctx.drawImage(this._image,(size-iconSize)/2,(size-iconSize)/2,iconSize,iconSize);}else{var clipCanvas=document.createElement('canvas');clipCanvas.width=clipCanvas.height=size;var clipCtx=clipCanvas.getContext('2d',{willReadFrequently:true});clipCtx.beginPath();clipCtx.arc(size/2,size/2,size/2-CANVAS_PADDING,0,2*Math.PI);clipCtx.clip();clipCtx.drawImage(this._image,CANVAS_PADDING,CANVAS_PADDING,size-CANVAS_PADDING*2,size-CANVAS_PADDING*2);ctx.drawImage(clipCanvas,0,0);}}else{ctx.drawImage(this._image,CANVAS_PADDING,CANVAS_PADDING,size-CANVAS_PADDING*2,size-CANVAS_PADDING*2);}
canvas.toBlob(function(image,blob){if(image.onload){image.src=URL.createObjectURL(blob);}}.bind(this,this._image));}
if(this._hasUserSetIcon){this._image.onload();}};this._image.onerror=(e)=>{console.error('Failed to load icon',e);this._pendingIconUrl=null;if(this._pendingIconRefresh){this._pendingIconRefresh=false;this._image=null;this.refresh();}else if(!this._hasIcon){this._image.onerror=null;this._setPredefinedIcon('default');}else{this._image=null;}};};proto._setPredefinedIcon=function(name){this._hasPredefinedIcon=true;this._image.src=this._pendingIconUrl=this._predefinedIcons[name];};proto._localizeString=function(str){var userLang=document.documentElement.lang;if(navigator.mozL10n&&navigator.mozL10n.ctx.qps.indexOf(userLang)!==-1){return navigator.mozL10n.qps[userLang].translate(str);}
return str;};proto.updateName=function(){if(this.app){var userLang=document.documentElement.lang;var ep=this.entryPoint||undefined;this.app.getLocalizedValue('short_name',userLang,ep).then((shortName)=>{this._subtitle.textContent=this._localizeString(shortName);},()=>{this.app.getLocalizedValue('name',userLang,ep).then((name)=>{this._subtitle.textContent=this._localizeString(name);},(e)=>{var manifest=this.app.manifest||this.app.updateManifest;if(manifest){var nameObject=(manifest.entry_points&&this.entryPoint)?manifest.entry_points[this.entryPoint]:manifest;this._subtitle.textContent=this._localizeString(nameObject.short_name||nameObject.name);}else{console.error('Error retrieving app name',e);this._subtitle.textContent='';}});});}else if(this.bookmark){this._subtitle.textContent=this.bookmark.name;}else{this._subtitle.textContent='';}};proto.refresh=function(){if(!this._template){return;}
this.updateName();this._container.classList.remove('downloading');if(this.app){this.dataset.identifier=this.app.manifestURL+
(this.entryPoint?'/'+this.entryPoint:'');}else if(this.bookmark){this.dataset.identifier=this.bookmark.id;}else{delete this.dataset.identifier;}
if(this._image){this._pendingIconRefresh=true;return;}
var size=this.size*window.devicePixelRatio;if(size<1){return;}
this._prepareIconLoader();if(this.bookmark){if(!this._hasIcon){this._setPredefinedIcon('default');}
return;}
var state=this._lastState=this.state;switch(state){case'unrecoverable':this._setPredefinedIcon('unrecoverable');break;case'installing':this._setPredefinedIcon('installing');this._container.classList.add('downloading');break;case'paused':this._setPredefinedIcon('paused');break;case'error':this._setPredefinedIcon('failed');break;case'installed':var handleError=function(image,e){if(e.message!=='NO_ICON'&&e.name!=='NO_ICON'){console.error('Failed to retrieve icon',e);}if(image.onload&&!this._hasIcon){this._setPredefinedIcon('default');}else{this._image=null;}};var getImage=()=>{navigator.mozApps.mgmt.getIcon(this.app,size,this.entryPoint).then(function(image,blob){this._pendingIconUrl='app-icon';if(window.VulpesCompat){this._vulpesSourceIcon=blob;this._hasUserSetIcon=true;}if(image.onload){image.src=URL.createObjectURL(blob);}}.bind(this,this._image),handleError.bind(this,this._image));};getImage();break;}};proto.handleEvent=function(e){switch(e.type){case'progress':case'downloadsuccess':if(this.state!==this._lastState){this.refresh();}
break;case'downloaderror':case'downloadapplied':this.refresh();break;}};var template=document.createElement('template');var stylesheet=baseurl+'style.css';template.innerHTML=`<style>@import url(${stylesheet});</style>
<div id="image-container"><div id="spinner"></div></div>
<div id="title-container"><div dir="auto" id="subtitle"></div></div>`;return document.registerElement('gaia-app-icon',{prototype:proto});})(window);

View file

@ -0,0 +1,54 @@
gaia-app-icon {
display: flex;
flex-direction: column;
font-size: 0;
}
#image-container {
position: relative;
transition: visibility 0.2s, opacity 0.2s;
}
#image-container.initial-load {
opacity: 0;
visibility: hidden;
}
#image-container > #spinner {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#image-container.downloading > #spinner {
display: block;
background: no-repeat center center url('images/app_install_spinner.svg');
background-size: contain;
animation: rotate 2s infinite linear;
}
#image-container,
#image-container > * {
width: 100%;
}
#subtitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
margin-top: 0.3rem;
}
#title-container {
transition: opacity 0.2s, visibility 0.2s;
}
#title-container.hidden {
opacity: 0;
visibility: hidden;
height: 0;
}

View file

@ -0,0 +1,246 @@
/*
Gaia theme variables
*/
:root {
/** Grey Colors
---------------------------------------------------------*/
--color-alpha: #333333;
--color-beta: #ffffff;
--color-gamma: #4d4d4d;
--color-delta: #5f5f5f;
--color-epsilon: #858585;
--color-zeta: #a6a6a6;
--color-eta: #c7c7c7;
--color-theta: #e7e7e7;
--color-iota: #f4f4f4;
/** Brand Colors
---------------------------------------------------------*/
--color-darkblue: #00539f;
--color-blue: #00caf2;
--color-turquoise: #27c8c2;
--color-darkorange: #e66000;
--color-orange: #ff9500;
--color-yellow: #ffcb00;
--color-violet: #c40c84;
--color-warning: #fbbd3c;
--color-destructive: #e2443a;
--color-preffered: #00ba91;
/** Background
---------------------------------------------------------*/
--background: var(--color-beta);
--background-plus: var(--color-theta);
--background-minus: var(--color-zeta);
/** Borders
---------------------------------------------------------*/
--border-color: var(--color-theta);
/** Text Color
---------------------------------------------------------*/
--text-color: var(--color-gamma);
--text-color-minus: var(--color-eta);
--label-color: #000;
/** Button
---------------------------------------------------------*/
--button-background: var(--color-iota);
/** Links
---------------------------------------------------------*/
--link-color: var(--highlight-color);
/** Inputs
---------------------------------------------------------*/
--input-background: var(--color-beta);
--input-color: var(--color-alpha);
--input-clear-background: #909ca7;
/** Buttons
---------------------------------------------------------*/
--button-box-shadow:
inset 0 -1px 0 #d8d8d8,
inset 0 1px 0 #eeeeee;
--button-box-shadow-active: var(--button-box-shadow);
/** Header
---------------------------------------------------------*/
--header-disabled-button-color: rgba(255,255,255,0.3);
/** Transitions
---------------------------------------------------------*/
--transition-duration: 0.2s;
}
.theme-productivity {
/** Brand Color
---------------------------------------------------------*/
--highlight-color: var(--color-orange);
/** Header
---------------------------------------------------------*/
--header-background: var(--highlight-color);
--header-color: var(--color-beta);
}
.theme-communications {
/** Brand Color
---------------------------------------------------------*/
--highlight-color: var(--color-turquoise);
/** Links
---------------------------------------------------------*/
--link-color: #177874;
/** Header
---------------------------------------------------------*/
--header-background: var(--highlight-color);
--header-color: var(--color-beta);
--header-icon-color: var(--color-beta);
--header-button-color: var(--link-color);
}
.theme-settings {
/** Background
---------------------------------------------------------*/
--background: var(--color-iota);
--background-plus: var(--color-beta);
--background-minus: var(--color-epsilon);
--background-minus-minus: var(--color-zeta);
/** Highlight
---------------------------------------------------------*/
--highlight-color: var(--color-blue);
/** Borders
---------------------------------------------------------*/
--border-color: var(--color-theta);
/** Text Color
---------------------------------------------------------*/
--text-color: var(--color-gamma);
/** Titles
---------------------------------------------------------*/
--title-color: var(--background-minus);
/** Buttons
---------------------------------------------------------*/
--button-background: var(--background-plus);
/** Switch
---------------------------------------------------------*/
--switch-background: var(--background-minus-minus);
/** Checkbox
---------------------------------------------------------*/
--checkbox-border-color: var(--background-minus-minus);
/** Header
---------------------------------------------------------*/
--header-color: var(--title-color);
--header-background: var(--background);
--header-button-color: var(--highlight-color);
--header-disabled-button-color: var(--color-theta);
--header-action-button-color: var(--title-color);
}
.theme-media {
/** Highlight Color
---------------------------------------------------------*/
--highlight-color: var(--color-blue);
/** Background
---------------------------------------------------------*/
--background: var(--color-alpha);
--background-plus: var(--color-gamma);
--background-minus: #2B2B2B;
--background-minus-minus: #1a1a1a;
/** Text Color
---------------------------------------------------------*/
--text-color: var(--color-beta);
--label-color: var(--color-beta);
/** Button
---------------------------------------------------------*/
--button-background: var(--background-plus);
/** Header
---------------------------------------------------------*/
--header-background: var(--background);
--header-icon-color: var(--text-color);
--header-button-color: var(--highlight-color);
/** Borders
---------------------------------------------------------*/
--border-color: var(--color-gamma);
/** Inputs
---------------------------------------------------------*/
--input-background: var(--background-plus);
/** Text Input
---------------------------------------------------------*/
--text-input-background: var(--background-minus);
/** Switch
---------------------------------------------------------*/
--switch-head-border-color: var(--background-minus-minus);
--switch-background: var(--background-minus-minus);
/** Checkbox
---------------------------------------------------------*/
--checkbox-border-color: var(--background-minus-minus);
/** Buttons
---------------------------------------------------------*/
--button-box-shadow: none;
--button-box-shadow-active: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,844 @@
'use strict';
/* global devicePixelRatio */
/* global GaiaGrid */
(function(exports) {
const ACTIVE_SCALE = 1.4;
/* This is an alternative delay used to initiate icon movement when in edit
* mode. The normal contextmenu delay is too long once we're in edit mode
* and causes user confusion, but a short delay globally would cause too many
* accidental triggers of edit mode.
*/
const EDIT_LONG_PRESS_DELAY = 200;
/* The movement threshold to use for the above synthetic long-press. */
const EDIT_LONG_PRESS_THRESHOLD = 4 * devicePixelRatio;
/* This delay is the time passed once users stop the finger over an icon and
* the rearrange is performed */
const REARRANGE_DELAY = 30;
/* The page is scrolled via javascript if an icon is being moved, and is
* within a length of a page edge configured by this value */
const EDGE_PAGE_THRESHOLD = 50;
/* This is the delay before calling finish when handling touchend. */
const TOUCH_END_FINISH_DELAY = 20;
/* The maximum frequency with which the grid will be rearranged in response
* to the item position changing, in ms.
*/
const REARRANGE_FREQUENCY = 100;
/* The amount of cool-down time after a rearrange animation. To avoid
* rearranging too frequently and causing jank and user confusion.
*/
const REARRANGE_COOLDOWN = 750;
const SCREEN_HEIGHT = window.innerHeight;
const scrollStep = Math.round(SCREEN_HEIGHT / EDGE_PAGE_THRESHOLD);
/* The scroll step will be 5 times bigger over the edge */
const maxScrollStepFactor = 5;
function DragDrop(gridView) {
this.gridView = gridView;
this.container = gridView.element;
this.scrollable = document.documentElement;
this.container.addEventListener('touchstart', this);
this.container.addEventListener('touchmove', this);
this.container.addEventListener('touchend', this);
window.addEventListener('touchcancel', this);
this.container.addEventListener('click', this);
this.container.addEventListener('contextmenu', this);
}
DragDrop.prototype = {
/**
* The current touchmove target.
* @type {DomElement}
*/
target: null,
/**
* The edit-mode long-press timeout
*/
longPressTimeout: null,
/**
* The position of the first touchstart of the current event block
*/
touchStart: { x: 0, y: 0, screenX: 0, screenY: 0 },
/**
* If we have moved an icon, this indicates that we need to save the state
* @type {boolean}
*/
dirty: false,
/**
* Whether or not we are in edit mode.
* @type {boolean}
*/
inEditMode: false,
/**
* The item being hovered over.
* @type {GaiaGrid.GridItem}
*/
hoverItem: null,
/**
* The group being hovered over.
* @type {GaiaGrid.Group}
*/
hoverGroup: null,
/*
* An element that's aligned to the top of the grid and should be
* considered as obscuring the grid when in edit mode.
*/
editHeaderElement: null,
/**
* The timeout used to rearrange the grid during dragging.
*/
rearrangeGridTimeout: null,
/**
* The time of the last reposition call.
*/
lastRepositionTime: 0,
/**
* Returns the maximum active scale value.
*/
get activeScale() {
return ACTIVE_SCALE;
},
/**
* Returns true if we are currently dragging an icon.
*/
get inDragAction() {
return this.target && this.target.classList.contains('active');
},
/**
* Returns the delay before calling finish in response to a touch-end event.
*/
get touchEndFinishDelay() {
return TOUCH_END_FINISH_DELAY;
},
/**
* Begins the drag/drop interaction.
* Enlarges the icon.
* Sets additional data to make the touchmove handler faster.
*/
begin: function(e) {
// Stop icon launching while we are in active state
this.gridView.stop();
window.dispatchEvent(new CustomEvent('gaiagrid-dragdrop-begin'));
this.hoverItem = null;
this.rearrangeDelay = null;
this.doRearrange = null;
this.enterEditMode();
this.container.classList.add('dragging');
this.icon.scale = ACTIVE_SCALE;
this.icon.setActive(true);
this.container.parentNode.style.overflow = 'hidden';
// Work around e.pageX/e.pageY being null (to make it easier to work with
// injected events, or old versions of Marionette)
this.initialPageX = (typeof e.pageX === 'undefined') ?
this.icon.x : e.pageX;
this.initialPageY = (typeof e.pageY === 'undefined') ?
this.icon.y : e.pageY;
this.currentTouch = {
pageX: this.initialPageX,
pageY: this.initialPageY
};
var items = this.gridView.items;
var lastElement = items[items.length - 1];
this.maxScroll = lastElement.y + lastElement.pixelHeight +
(this.icon.pixelHeight * ACTIVE_SCALE);
// If this is a group, or the sole icon in a group, make sure the
// surrounding groups are marked as invalid-drop so you can't initiate a
// move that would have no result.
var itemIndex = this.icon.detail.index;
if (this.icon.detail.type === 'divider') {
this.icon.element.classList.add('invalid-drop');
if (itemIndex > 0) {
for (var i = itemIndex - 1; i >= 0; i--) {
var item = items[i];
if (item.detail.type === 'divider') {
item.element.classList.add('invalid-drop');
break;
}
}
}
} else {
var itemBefore = itemIndex ? items[itemIndex - 1] : null;
var itemAfter = items[itemIndex + 1];
if ((itemAfter.detail.type === 'placeholder' ||
itemAfter.detail.type === 'divider') &&
(!itemBefore || itemBefore.detail.type === 'divider')) {
if (itemBefore) {
itemBefore.element.classList.add('invalid-drop');
}
var group, groupIndex = itemIndex;
do {
group = items[++groupIndex];
} while (group.detail.type !== 'divider');
group.element.classList.add('invalid-drop');
}
}
// Redraw the icon at the new position and scale
this.updateIconPosition();
// Rearrange grid to highlight the group underneath the icon
this.rearrangeGrid();
},
finish: function() {
// Complete the repositioning timeout
if (this.rearrangeGridTimeout !== null) {
this.rearrangeGrid();
}
// Remove the dragging property after the icon has transitioned into
// place to avoid jank due to animations starting that are disabled
// when dragging.
this.icon.element.addEventListener('transitionend', this);
this.currentTouch = null;
if (this.doRearrange !== null) {
if (this.rearrangeDelay) {
clearTimeout(this.rearrangeDelay);
}
this.doRearrange.call(this);
} else {
this.icon.requestAttention();
}
// Hand back responsibility to Grid view to render the dragged item.
this.icon.scale = 1;
this.icon.setActive(false);
this.icon.element.classList.remove('hovering');
this.target = null;
this.gridView.render();
// Save icon state if we need to
if (this.dirty) {
window.dispatchEvent(new CustomEvent('gaiagrid-saveitems'));
}
this.dirty = false;
setTimeout(function nextTick() {
this.gridView.start();
window.dispatchEvent(new CustomEvent('gaiagrid-dragdrop-finish'));
}.bind(this));
},
finalize: function() {
this.container.parentNode.style.overflow = '';
this.container.classList.remove('dragging');
this.container.classList.remove('hover-over-top');
if (this.icon) {
this.icon.element.removeEventListener('transitionend', this);
this.icon = null;
}
if (this.hoverItem) {
this.hoverItem.element.classList.remove('hovered');
this.hoverItem = null;
}
if (this.hoverGroup) {
this.hoverGroup.element.classList.remove('drop-target');
this.hoverGroup = null;
}
for (var i = 0, iLen = this.gridView.items.length;
i < iLen; i++) {
var item = this.gridView.items[i];
item.element.classList.remove('invalid-drop');
}
},
/**
* The closer to edge the faster (bigger step).
** Distance 0px -> 5 times faster
** Distance 25px -> 2.5 times faster
** Distance 50px (EDGE_PAGE_THRESHOLD) -> 0 times
*/
getScrollStep: function(distanceToEdge) {
var factor = maxScrollStepFactor;
if (distanceToEdge > 0) {
factor *= ((EDGE_PAGE_THRESHOLD - distanceToEdge) /
EDGE_PAGE_THRESHOLD);
}
return Math.round(scrollStep * factor);
},
/**
* Positions the dragged icon, rearranges the grid and scrolls the page if
* needed.
* The page is scrolled via javascript if an icon is being moved,
* and is within a percentage of a page edge.
*/
positionAndScrollIfNeeded: function() {
var touch = this.currentTouch;
if (!touch || !this.inDragAction) {
this.isScrolling = false;
return;
}
var scrollStep;
var docScroll = this.scrollable.scrollTop;
var distanceFromTop = Math.abs(touch.pageY - docScroll);
var distanceFromHeader = distanceFromTop -
(this.editHeaderElement ? this.editHeaderElement.clientHeight : 0);
this.isScrolling = true;
if (distanceFromTop > SCREEN_HEIGHT - EDGE_PAGE_THRESHOLD) {
var maxY = this.maxScroll;
scrollStep = this.getScrollStep(SCREEN_HEIGHT - distanceFromTop);
// We cannot exceed the maximum scroll value
if (touch.pageY >= maxY || maxY - touch.pageY < scrollStep) {
this.isScrolling = false;
}
} else if (touch.pageY > 0 && distanceFromHeader < EDGE_PAGE_THRESHOLD) {
// We cannot go below the minimum scroll value
scrollStep = -this.getScrollStep(distanceFromHeader);
scrollStep = Math.max(scrollStep, -this.scrollable.scrollTop);
if (scrollStep >= 0) {
this.isScrolling = false;
}
} else {
this.isScrolling = false;
}
if (!this.isScrolling) {
this.updateIconPosition();
this.deferredRearrangeGrid();
return;
}
this.updateIconPosition();
this.scrollable.scrollTop += scrollStep;
touch.pageY += scrollStep;
exports.requestAnimationFrame(this.positionAndScrollIfNeeded.bind(this));
},
/**
* Highlights the group of the given icon index, or the group if the index
* belongs to a group.
* @param {Integer} index The grid index of the icon or group.
*/
highlightGroup: function(index) {
for (var i = index, iLen = this.gridView.items.length;
i < iLen; i++) {
var item = this.gridView.items[i];
if (item.detail.type === 'divider') {
if (this.hoverGroup != item) {
if (this.hoverGroup) {
this.hoverGroup.element.classList.remove('drop-target');
}
this.hoverGroup = item;
item.element.classList.add('drop-target');
}
break;
}
}
},
inFirstGroup: function(index) {
for (var i = index; i >= 0; i--) {
if (this.gridView.items[i].detail.type === 'divider') {
return false;
}
}
return true;
},
/**
* Positions an icon on the grid using the current touch coordinates.
*/
updateIconPosition: function() {
var oldX = this.icon.x;
var oldY = this.icon.y;
var newX = this.currentTouch.pageX - (this.initialPageX - this.icon.x);
var newY = this.currentTouch.pageY - (this.initialPageY - this.icon.y);
// Adjust new icon coordinates for the slightly inflated scale so that
// it appears centered around the touch point. Dividers aren't scaled
// during dragging.
if (this.icon.detail.type !== 'divider') {
newX = newX - ((this.icon.scale * this.gridView.layout.gridItemWidth) -
this.gridView.layout.gridItemWidth) / 2;
newY = newY - ((this.icon.scale * this.icon.pixelHeight) -
this.icon.pixelHeight) / 2;
}
this.icon.setCoordinates(newX, newY);
this.icon.render();
this.icon.setCoordinates(oldX, oldY);
},
/**
* Will call rearrangeGrid after a short period, calculated based on how
* recently the grid was last rearranged.
*/
deferredRearrangeGrid: function() {
if (this.rearrangeGridTimeout !== null) {
return;
}
var delay = Math.max(
REARRANGE_FREQUENCY, REARRANGE_COOLDOWN -
Math.max(0, Date.now() - this.lastRepositionTime));
this.rearrangeGridTimeout = setTimeout(() => {
this.rearrangeGridTimeout = null;
this.rearrangeGrid();
}, delay);
},
/**
* Rearranges the grid icons and groups based on the current position of
* the dragged icon.
*/
rearrangeGrid: function() {
if (this.rearrangeGridTimeout !== null) {
clearTimeout(this.rearrangeGridTimeout);
this.rearrangeGridTimeout = null;
}
if (this.isScrolling) {
// We don't want the grid to shift while we're auto-scrolling
return;
}
var iconIsDivider = this.icon.detail.type === 'divider';
var pageX = this.currentTouch.pageX - (this.initialPageX - this.icon.x);
var pageY = this.currentTouch.pageY - (this.initialPageY - this.icon.y);
// Reposition in the icons array if necessary.
// Find the icon with the closest X/Y position of the move,
// and insert ours before it.
var foundIndex = 0;
var insertDividerAtTop = !this.gridView.config.features.disableSections;
pageX += this.gridView.layout.gridItemWidth / 2;
pageY += this.icon.pixelHeight / 2;
if (pageY >= 0) {
foundIndex =
this.gridView.getNearestItemIndex(pageX, pageY, iconIsDivider);
// If we're dragging a group over the first icon, a divider will
// be inserted at the top.
if (!(iconIsDivider && foundIndex === 0)) {
this.container.classList.remove('hover-over-top');
insertDividerAtTop = false;
}
}
// Clear the rearrange callback and hover item if we aren't hovering over
// anything.
this.doRearrange = null;
if (this.rearrangeDelay) {
clearTimeout(this.rearrangeDelay);
this.rearrangeDelay = null;
}
if (this.hoverItem) {
this.hoverItem.element.classList.remove('hovered');
this.hoverItem = null;
}
// Add the 'hovering' class to the dragged icon.
if (foundIndex !== null) {
this.icon.element.classList.add('hovering');
} else {
this.icon.element.classList.remove('hovering');
}
// Nothing to do if we find the dragged icon or no icon
if (!insertDividerAtTop &&
(foundIndex === null || foundIndex === this.icon.detail.index)) {
if (!iconIsDivider) {
this.highlightGroup(this.icon.detail.index);
}
return;
}
var foundItem = this.gridView.items[foundIndex];
// If we're a divider being dragged, only allow dropping on either the
// first item or another divider.
if (iconIsDivider &&
foundIndex > 0 &&
foundItem.detail.type !== 'divider') {
// Remove the hovering class from a group if it's not hovering over
// a valid drop position or itself.
this.icon.element.classList.remove('hovering');
return;
}
// If the item isn't a group, trigger the
// hovered state on the found item.
if (!insertDividerAtTop && !iconIsDivider) {
this.hoverItem = foundItem;
this.hoverItem.element.classList.add('hovered');
}
// Delay rearranging when we're dragging groups to when they're dropped
var rearrangeAfterDelay = !iconIsDivider;
// Determine if we want to create a new divider. We do this when hovering
// over a divider with an icon, or when dragging an icon to the very top
// of the grid.
var createDivider = insertDividerAtTop;
if (!insertDividerAtTop && !iconIsDivider &&
(foundItem.detail.type === 'divider')) {
// Allow dropping into a collapsed group if the new position is in
// the top 2/3 of the group.
if (foundItem.detail.collapsed &&
pageY <= foundItem.y + (foundItem.pixelHeight * 2/3)) {
rearrangeAfterDelay = false;
foundItem.element.classList.remove('hovered');
// When dragging an item forwards, it gets placed after the found
// item, adjust to the item before the divider.
if (this.icon.detail.index < foundIndex) {
foundItem = this.gridView.items[foundIndex - 1];
}
} else {
createDivider = true;
}
}
if (createDivider) {
// Remove the group background highlight if we're between groups
if (this.hoverGroup) {
this.hoverGroup.element.classList.remove('drop-target');
this.hoverGroup = null;
}
// Cancel rearrangement if it would have no effect.
var redundantRearrange = false;
if (insertDividerAtTop) {
if (iconIsDivider) {
redundantRearrange = this.inFirstGroup(this.icon.detail.index - 1);
} else {
redundantRearrange =
(this.icon.detail.index === 0 &&
this.gridView.items[1].detail.type === 'placeholder');
}
} else {
redundantRearrange = this.gridView.items[foundIndex].element.
classList.contains('invalid-drop');
}
if (redundantRearrange) {
if (this.hoverItem) {
this.hoverItem.element.classList.remove('hovered');
this.hoverItem = null;
}
return;
} else if (insertDividerAtTop) {
this.container.classList.add('hover-over-top');
}
this.doRearrange =
this.createNewDivider.bind(this,
insertDividerAtTop ? null : foundItem);
} else {
if (!iconIsDivider) {
// Change the display of the group the icon is hovering over to
// indicate it can be dropped.
this.highlightGroup(foundIndex);
}
this.doRearrange = this.rearrange.bind(this, foundItem);
if (rearrangeAfterDelay) {
this.rearrangeDelay =
setTimeout(this.doRearrange.bind(this), REARRANGE_DELAY);
}
}
},
/**
* Creates a new divider in GridView.items and rearranges the currently
* dragged icon into the newly created section.
* @param {Object} tDivider The divider after which to place a new divider,
* or null to insert a divider at the beginning on the grid.
*/
createNewDivider: function(tDivider) {
var items = this.gridView.items;
var tIndex = tDivider ? items.indexOf(tDivider) + 1 : 0;
// Create the new divider
var newDivider = new GaiaGrid.Divider();
items.splice(tIndex, 0, newDivider);
// Place the dragged item into the new empty section
var sIndex = items.indexOf(this.icon);
this.rearrange(sIndex >= tIndex ? newDivider : tDivider);
// Make sure the new divider/group is in view
newDivider.requestAttention();
},
/**
* Rearranges items in GridView.items
* @param {Object} tItem The item to position the dragged icon at.
*/
rearrange: function(tItem) {
// Clear the rearrange callback
this.doRearrange = null;
this.rearrangeDelay = null;
// We get a reference to the position of this.icon within the items
// array. Because placeholders are shifting around while we are dragging,
// we can't trust the detail.index attribute. This will be fixed on every
// render call though.
var sIndex = this.gridView.items.indexOf(this.icon);
var tIndex = this.gridView.items.indexOf(tItem);
// Check how many items we need to rearrange (if it's a group, there will
// be multiple items.
var eIndex = sIndex;
if (this.icon.detail.type === 'divider') {
for (; sIndex > 0 &&
this.gridView.items[sIndex - 1].detail.type !== 'divider';
sIndex--) {}
// Modify the tIndex to make sure we're inserting immediately after
// another divider or at the start of the container.
if (this.gridView.items[tIndex].detail.type === 'divider') {
tIndex++;
} else {
tIndex = 0;
}
if (sIndex === tIndex) {
// Nothing to do.
return;
}
} else if (sIndex < tIndex) {
tIndex++;
}
// We may have changed tIndex, so refresh our item pointer
tItem = this.gridView.items[tIndex];
// Remove the items from the array
var lastItemOffset = eIndex - sIndex;
var toInsert = this.gridView.items.splice(sIndex, lastItemOffset + 1);
// Craft the parameter list to reinsert the items at the correct place
toInsert.unshift(tItem ? this.gridView.items.indexOf(tItem) :
this.gridView.items.length, 0);
// Reinsert items
this.rearrangeDelay = null;
this.dirty = true;
this.gridView.items.splice.apply(this.gridView.items, toInsert);
var oldX = this.icon.x;
var oldY = this.icon.y;
this.gridView.render();
// In this case, we don't want to compensate for the icon moving, so
// we need to correct our initial values to stop the icon from jumping
// after rearranging.
this.initialPageX -= oldX - this.icon.x;
this.initialPageY -= oldY - this.icon.y;
this.lastRepositionTime = Date.now();
},
enterEditMode: function() {
this.inEditMode = true;
this.container.classList.add('edit-mode');
document.body.classList.add('edit-mode');
this.gridView.element.dispatchEvent(
new CustomEvent('editmode-start'));
document.addEventListener('visibilitychange', this);
this.gridView.render();
},
exitEditMode: function() {
// If we're in the middle of a drag, cancel it.
if (this.icon) {
this.finish();
this.finalize();
}
this.inEditMode = false;
this.cancelLongPressTimeout();
this.container.classList.remove('edit-mode');
document.body.classList.remove('edit-mode');
this.gridView.element.dispatchEvent(new CustomEvent('editmode-end'));
document.removeEventListener('visibilitychange', this);
this.gridView.render();
},
cancelLongPressTimeout: function() {
if (this.longPressTimeout !== null) {
clearTimeout(this.longPressTimeout);
this.longPressTimeout = null;
}
},
inLongPressThreshold: function(x, y) {
return (
Math.abs(x - this.touchStart.screenX) < EDIT_LONG_PRESS_THRESHOLD &&
Math.abs(y - this.touchStart.screenY) < EDIT_LONG_PRESS_THRESHOLD);
},
/**
* General event handler.
*/
handleEvent: function(e) {
switch(e.type) {
case 'visibilitychange':
if (document.hidden) {
this.exitEditMode();
}
break;
case 'touchstart':
this.cancelLongPressTimeout();
if (e.touches.length > 1) {
if (this.inDragAction) {
this.finish();
}
return;
}
if (this.inEditMode && !this.icon) {
this.touchStart.x = e.touches[0].pageX;
this.touchStart.y = e.touches[0].pageY;
this.touchStart.screenX = e.touches[0].screenX;
this.touchStart.screenY = e.touches[0].screenY;
this.longPressTimeout = setTimeout(() => {
this.longPressTimeout = null;
this.handleEvent({
type: 'contextmenu',
target: e.target,
pageX: this.touchStart.x,
pageY: this.touchStart.y,
preventDefault: function() {},
stopImmediatePropagation: function() {}
});
}, EDIT_LONG_PRESS_DELAY);
}
break;
case 'contextmenu':
this.cancelLongPressTimeout();
if (this.icon) {
e.stopImmediatePropagation();
e.preventDefault();
return;
}
if (e.defaultPrevented) {
// other handlers already handled this.
// in the future, we should use the shadow root and dispatch a
// "contextmenu" event from here instead.
return;
}
if (!e.target) {
return;
}
this.icon = this.gridView.findItemFromElement(e.target, true);
if (!this.icon || !this.icon.element || !this.icon.isDraggable() ||
this.icon.detail.type === 'placeholder') {
this.icon = null;
return;
}
e.stopImmediatePropagation();
e.preventDefault();
this.target = this.icon.element;
this.begin(e);
break;
case 'touchmove':
var touch = e.touches[0];
if (this.inDragAction) {
this.currentTouch = {
pageX: touch.pageX,
pageY: touch.pageY
};
if (!this.isScrolling) {
this.positionAndScrollIfNeeded();
}
} else if (this.longPressTimeout !== null &&
!this.inLongPressThreshold(touch.screenX,
touch.screenY)) {
this.cancelLongPressTimeout();
}
break;
case 'click':
case 'touchcancel':
if (this.inDragAction) {
this.finish();
this.finalize();
}
this.cancelLongPressTimeout();
break;
case 'touchend':
if (this.inDragAction) {
// Ensure the app is not launched
e.stopImmediatePropagation();
e.preventDefault();
// As contextmenu event can be synthesized, it's possible for it
// to happen in the same event-loop as touchend, meaning
// finish would be called immediately after begin. This would
// mean that the icon doesn't get a chance to transition and
// the transitionend that we expect won't be called, so finalize
// also won't be called, leaving us in an inconsistent state.
// It would be so great if there was a transitionstart event :(
setTimeout(() => { this.finish(); }, TOUCH_END_FINISH_DELAY);
}
this.cancelLongPressTimeout();
break;
case 'transitionend':
this.finalize();
break;
}
}
};
exports.GridDragDrop = DragDrop;
}(window));

View file

@ -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));

View file

@ -0,0 +1,193 @@
'use strict';
/* global devicePixelRatio */
/* global verticalPreferences */
(function(exports) {
const maxIconsPerRow = 4;
// 320 / 5 = 64px | 480 / 5 = 96px | 540 / 5 = 108px | ...
const iconScaleFactorMaxIconsPerRow = 5;
const minIconsPerRow = 3;
// 320 / 3.8 = 84px | 480 / 3.8 = 126px | 540 / 3.8 = 142px | ...
const iconScaleFactorMinIconsPerRow = 3.8;
const distanceBetweenIconsWithMinIconsPerRow = 36;
const distanceBetweenIconsWithMaxIconsPerRow = 38;
const eachTextRowHeight = 20;
var constraintSize;
function GridLayout(gridView) {
this.gridView = gridView;
if (window.verticalPreferences) {
verticalPreferences.get('grid.cols').then(value => {
this.cols = value;
this.onReady();
}, this.onReady);
verticalPreferences.addEventListener('updated', this);
} else {
this.onReady();
}
}
GridLayout.prototype = {
minIconsPerRow: minIconsPerRow,
maxIconsPerRow: maxIconsPerRow,
_cols: minIconsPerRow,
_offsetY: 0,
_percent: 1,
/**
* The visible height of each divider.
* Calculated by the divider class and cached here.
*/
_dividerLineHeight: 0,
get percent() {
return this._percent;
},
get cols() {
return this._cols;
},
set cols(value) {
if (!value || value === this._cols) {
return;
}
if (window.verticalPreferences) {
verticalPreferences.put('grid.cols', value);
}
// Reset the y-offset because we will re-render everything anyway.
this._offsetY = 0;
this._percent = value == minIconsPerRow ? 1 :
minIconsPerRow / maxIconsPerRow;
this._cols = value;
},
/**
* The width of the grid.
*/
get constraintSize() {
if (!constraintSize) {
this.calculateSize();
}
return constraintSize;
},
/**
* The height of each grid item.
*/
get gridItemHeight() {
var height = this.gridIconSize +
(this._cols === minIconsPerRow ?
distanceBetweenIconsWithMinIconsPerRow :
distanceBetweenIconsWithMaxIconsPerRow);
// Increase height for additional rows of text.
var defaultTextRows = 2;
var definedRows = parseInt(
this.gridView.element.getAttribute('text-rows'), 10);
if (definedRows > defaultTextRows) {
height += (definedRows - defaultTextRows) * eachTextRowHeight;
}
return height;
},
/**
* The width of each grid item.
* This number changes based on current zoom level.
*/
get gridItemWidth() {
return this.constraintSize / this._cols;
},
/**
* Returns the maximum size in pixels for an icon image. It is the size when
* the grid is displayed with the minimum number of columns per row.
*/
get gridMaxIconSize() {
var baseSize = (this.constraintSize / iconScaleFactorMinIconsPerRow);
return baseSize * devicePixelRatio;
},
/**
* Returns the icon image size depending on grid configuration and screen
* characteristics.
*/
get gridIconSize() {
var numCols = this._cols;
var size = this.constraintSize / numCols;
if (numCols === minIconsPerRow) {
size = this.constraintSize / iconScaleFactorMinIconsPerRow;
} else if (numCols === maxIconsPerRow) {
size = this.constraintSize / iconScaleFactorMaxIconsPerRow;
}
return Math.floor(size);
},
/**
* Gets the current offset of the Y-axis for the current zoom level.
* This value is updated by calling zoom.stepYAxis. For example, each
* group of three icons, or a divider, should increment this value.
* The value is reset and recalculated when the zoom level changes.
*/
get offsetY() {
return this._offsetY;
},
set offsetY(value) {
this._offsetY = value;
},
/**
* After we render a row we need to store the current position of the y-axis
*/
stepYAxis: function(value) {
this._offsetY += value;
},
/**
* General event handler.
*/
handleEvent: function(e) {
switch(e.type) {
case 'updated':
var prop = e.target;
if (prop.name === 'grid.cols') {
this.cols = parseInt(prop.value, 10);
this.gridView.render();
}
break;
}
},
calculateSize: function() {
constraintSize = Math.min(window.screen.width, window.screen.height);
},
onReady: function() {
window.dispatchEvent(new CustomEvent('gaiagrid-layout-ready'));
}
};
exports.GridLayout = GridLayout;
}(window));

View file

@ -0,0 +1,588 @@
'use strict';
/* global GridDragDrop */
/* global GaiaGrid */
/* global GridLayout */
/* global GridZoom */
/* global LazyLoader */
(function(exports) {
/* The time for which we'll disable launching other icons after tapping on
* an app icon. This will also disable edit mode.
*/
const APP_LAUNCH_TIMEOUT = 3000;
/**
* GridView is a generic class to render and display a grid of items.
* @param {Object} config Configuration object containing:
* - element: The shadow root of the grid
*/
function GridView(config) {
this.config = config;
this.clickIcon = this.clickIcon.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
if (config.features.zoom) {
this.zoom = new GridZoom(this);
}
this.layout = new GridLayout(this);
// Set columns if we have a 'cols' attribute
if (config.element.hasAttribute('cols')) {
this.layout.cols = parseInt(config.element.getAttribute('cols'), 10);
}
// Enable event listeners when instantiated.
this.start();
}
GridView.prototype = {
/**
* List of all application icons.
* Maps an icon identifier to an icon object.
*/
icons: {},
/**
* Lists of all displayed objects in the homescreen.
* Includes app icons, dividers, and bookmarks.
*/
items: [],
/**
* Returns a reference to the gaia-grid element.
*/
get element() {
return this.config.element;
},
/**
* Sets the number of columns.
*/
set cols(value) {
this.layout.cols = value;
},
/**
* We are in the state of launching an app.
*/
_launchingApp: false,
/**
* Adds an item into the items array.
* If the item is an icon, add it to icons.
* @param {Object} item The grid object, should inherit from GridItem.
* @param {Object} insertTo The position to insert the item into our list.
* @param {Boolean} expandGroup Expand the group this item is will be in.
*/
add: function(item, insertTo, expandGroup) {
if (!item) {
return;
}
if (item.identifier) {
// If we already have an item with this identifier, exit.
// This avoids a potential race condition where we might have duplicate
// items with the same identifiers in the grid. This should not happen.
if (this.icons[item.identifier]) {
console.log('Error, duplicate identifier: ',
item.identifier, new Error().stack);
return;
}
this.icons[item.identifier] = item;
} else if (item.detail.type !== 'divider' &&
item.detail.type !== 'placeholder') {
// If the item does not have an identifier, and is not a placeholder
// or divider, do not add it to the grid.
console.log('Error, could not load identifier for object: ',
JSON.stringify(item.detail));
return;
}
// If insertTo it is a number, splice.
if (!isNaN(parseFloat(insertTo)) && isFinite(insertTo)) {
this.items.splice(insertTo, 0, item);
} else {
insertTo = this.items.length;
this.items.push(item);
}
if (expandGroup) {
for (var i = insertTo + 1, iLen = this.items.length;
i < iLen; i++) {
var divider = this.items[i];
if (divider.detail.type === 'divider') {
if (divider.detail.collapsed) {
divider.expand();
}
break;
}
}
}
},
/**
* Finds nearest item by and returns an index, or null if an item isn't
* found.
* @param {Number} x relative to the screen
* @param {Number} y relative to the screen
* @param {Boolean} isRow whether the position is to be considered as a row
* instead of an individual point.
*/
getNearestItemIndex: function(x, y, isRow) {
var foundIndex = null;
var leastDistance = null;
var itemMiddleOffset = this.layout.gridItemWidth / 2;
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
var item = this.items[i];
// Don't consider items that can't be dragged
if (!item.isDraggable()) {
continue;
}
// If sections are disabled, don't consider dividers
if (this.config.features.disableSections &&
item.detail.type === 'divider') {
continue;
}
// Do not consider collapsed items, unless they are dividers.
if (item.detail.type !== 'divider' &&
item.element.classList.contains('collapsed')) {
continue;
}
var middleX = item.x + itemMiddleOffset;
var middleY = item.y + item.pixelHeight / 2;
var xDistance = (isRow || item.detail.type === 'divider') ?
0 : x - middleX;
var yDistance = y - middleY;
var distance = Math.sqrt(
xDistance * xDistance +
yDistance * yDistance);
if (leastDistance === null || distance < leastDistance) {
leastDistance = distance;
foundIndex = i;
}
}
return foundIndex;
},
start: function() {
this.element.addEventListener('click', this.clickIcon);
window.addEventListener('visibilitychange', this.onVisibilityChange);
},
stop: function() {
this.element.removeEventListener('click', this.clickIcon);
window.removeEventListener('visibilitychange', this.onVisibilityChange);
},
findItemFromElement: function(element, excludeCollapsedIcons) {
while (element && element.parentNode !== this.element) {
element = element.parentNode;
}
if (!element) {
return null;
}
var i, iLen = this.items.length;
var identifier = element.dataset.identifier;
var icon = this.icons[identifier];
// If the element didn't have an identifier, try to search for it
// manually.
if (!icon) {
for (i = 0; i < iLen; i++) {
if (this.items[i].element === element) {
icon = this.items[i];
break;
}
}
}
if (icon && excludeCollapsedIcons) {
// If this is a collapsed item, return its group instead
if (icon.detail.type !== 'divider' &&
icon.detail.type !== 'placeholder' &&
icon.element.classList.contains('collapsed')) {
for (i = icon.detail.index + 1; i < iLen; i++) {
if (this.items[i].detail.type === 'divider') {
return this.items[i];
}
}
console.warn('Collapsed icon found with no group');
icon = null;
}
}
return icon;
},
onVisibilityChange: function() {
this._launchingApp = false;
},
/**
* Launches an app.
*/
clickIcon: function(e) {
e.preventDefault();
var inEditMode = this.dragdrop && this.dragdrop.inEditMode;
var action = 'launch';
if (e.target.classList.contains('remove')) {
action = 'remove';
}
var icon = this.findItemFromElement(e.target);
if (!icon) {
return;
}
if (action === 'launch') {
// We do not allow users to launch icons in edit mode
if (inEditMode && e.target.classList.contains('icon')) {
// Check if we're trying to edit a bookmark
if (!icon.isEditable()) {
return;
}
action = 'edit';
} else {
// If the icon can't be launched, bail out early
if (!icon[action]) {
return;
}
// Add a 'launching' class to the icon to style it with CSS.
icon.element.classList.add('launching');
// XXX: We can't have nice things. Remove the launching class after an
// arbitrary time to restore the state. We want the icon to return
// to its original state after launching the app, but visibilitychange
// will not work because activities do not fire it.
var returnTimeout = 500;
setTimeout(function stateReturn() {
if (icon.element) {
icon.element.classList.remove('launching');
}
}, returnTimeout);
}
}
if ((icon.detail.type === 'app' || icon.detail.type === 'bookmark') &&
this._launchingApp) {
return;
}
if ((icon.detail.type === 'app' && icon.appState === 'ready') ||
icon.detail.type === 'bookmark') {
this._launchingApp = true;
if (this._launchingTimeout) {
window.clearTimeout(this._launchingTimeout);
this._launchingTimeout = null;
}
// This avoids some edge cases if we didn't get visibilitychange anyway.
this._launchingTimeout = window.setTimeout(function() {
this._launchingTimeout = null;
this._launchingApp = false;
}.bind(this), APP_LAUNCH_TIMEOUT);
}
icon[action](e.target);
},
/**
* Scrubs the list of items, removing empty sections.
*/
cleanItems: function(skipDivider) {
var appCount = 0;
var toRemove = [];
this.items.forEach(function(item, idx) {
if (item instanceof GaiaGrid.Divider) {
if (appCount === 0) {
toRemove.push(idx);
}
appCount = 0;
} else {
appCount++;
}
}, this);
toRemove.reverse();
toRemove.forEach(function(idx) {
var removed = this.items.splice(idx, 1)[0];
removed.remove();
}, this);
// There should always be a divider at the end, it's hidden in CSS when
// not in edit mode.
if (skipDivider) {
return;
}
// If the last item is not a divider, add a new divider at the end.
var lastItem = this.items[this.items.length - 1];
if (!lastItem || !(lastItem instanceof GaiaGrid.Divider)) {
this.items.push(new GaiaGrid.Divider());
}
},
/**
* Removes placeholders from the grid.
*/
removeAllPlaceholders: function() {
var toSplice = [];
var previousItem;
this.items.forEach(function(item, idx) {
if (item instanceof GaiaGrid.Placeholder) {
// If the previous item is a divider, or there is no previous item,
// and we are in edit mode, we do not remove the placeholder.
// This is so the section will remain even if the user drags the
// icon around. Bug 1014982
if ((!previousItem ||
(previousItem && previousItem instanceof GaiaGrid.Divider)) &&
this.dragdrop && this.dragdrop.inDragAction) {
return;
}
toSplice.push(idx);
}
previousItem = item;
}, this);
toSplice.reverse().forEach(function(idx) {
this.items.splice(idx, 1)[0].remove();
}, this);
},
/**
* Clears the grid view of all items.
*/
clear: function() {
for (var i = 0, iLen = this.items.length; i < iLen; i++) {
var item = this.items[i];
if (item.element) {
this.element.removeChild(item.element);
// We must de-reference element explicitly so we can re-use item
// objects the next time we call render.
item.element = null;
item.lastX = null;
item.lastY = null;
item.lastScale = null;
}
}
this.items = [];
this.icons = {};
},
/**
* Creates placeholders and injects them into the grid.
* @param {Array} coordinates [x,y] coordinates on the grid of the first
* item in grid units.
* @param {Integer} idx The position of the first placeholder.
* @param {Integer} idx The number of placeholders to create.
*/
createPlaceholders: function(coordinates, idx, count) {
var isRTL = (document.documentElement.dir === 'rtl');
for (var i = 0; i < count; i++) {
var item = new GaiaGrid.Placeholder();
this.items.splice(idx + i, 0, item);
item.setPosition(idx + i);
var xPosition = (coordinates[0] + i) * this.layout.gridItemWidth;
if (isRTL) {
xPosition =
(this.layout.constraintSize - this.layout.gridItemWidth) -
xPosition;
}
item.setCoordinates(xPosition, this.layout.offsetY);
item.render();
}
},
/**
* Renders all icons.
* Positions app icons and dividers accoriding to available space
* on the grid.
* @param {Object} options Options to render with including:
* - skipDivider {Boolean} Whether or not to skip the divider
* - rerender {Boolean} Whether we should clean elements and re-render.
*/
render: function(options) {
var self = this;
options = options || {};
this.removeAllPlaceholders();
this.cleanItems(options.skipDivider);
// Reset offset steps
var oldHeight = this.layout.offsetY;
this.layout.offsetY = 0;
// Grid render coordinates
var x = 0;
var y = 0;
/**
* Steps the y-axis.
* @param {Object} item
*/
function step(item) {
var pixelHeight = item.pixelHeight;
self.layout.stepYAxis(pixelHeight);
x = 0;
y++;
}
var pendingCachedIcons = 0;
var onCachedIconRendered = () => {
if (--pendingCachedIcons <= 0) {
this.element.removeEventListener('cached-icon-rendered',
onCachedIconRendered);
this.element.dispatchEvent(new CustomEvent('cached-icons-rendered'));
}
};
this.element.addEventListener('cached-icon-rendered',
onCachedIconRendered);
var nextDivider = null;
var oddDivider = true;
var isRTL = (document.documentElement.dir === 'rtl');
for (var idx = 0; idx < this.items.length; idx++) {
var item = this.items[idx];
// Remove the element if we are re-rendering.
if (options.rerender && item.element) {
this.element.removeChild(item.element);
item.element = null;
}
if (item.detail.type === 'divider') {
nextDivider = null;
} else {
if (!nextDivider) {
for (var i = idx + 1; i < this.items.length; i++) {
if (this.items[i].detail.type === 'divider') {
nextDivider = this.items[i];
oddDivider = !oddDivider;
break;
}
}
// Make sure to leave room for group headers
if (nextDivider &&
!nextDivider.detail.collapsed) {
this.layout.offsetY += nextDivider.headerHeight;
}
}
// If this item is in a collapsed group, we need to skip rendering.
if (nextDivider && nextDivider.detail.collapsed) {
item.setPosition(idx);
continue;
}
}
// If the item would go over the boundary before rendering,
// step the y-axis.
if (x > 0 && item.gridWidth > 1 &&
x + item.gridWidth >= this.layout.cols) {
// Insert placeholders to fill remaining space
var remaining = this.layout.cols - x;
this.createPlaceholders([x, y], idx, remaining);
// Increment the current index due to divider insertion
idx += remaining;
item = this.items[idx];
// Step the y-axis by the size of the last row.
// For now we just check the height of the last item.
var lastItemInRow = this.items[idx - 1];
step(lastItemInRow);
}
item.setPosition(idx);
if (!options.skipItems) {
item.hasCachedIcon && ++pendingCachedIcons;
var xPosition = x * this.layout.gridItemWidth;
if (isRTL) {
xPosition =
(this.layout.constraintSize - this.layout.gridItemWidth) -
xPosition;
}
item.setCoordinates(xPosition, this.layout.offsetY);
if (!item.active) {
item.render();
}
if (item.detail.type === 'divider') {
if (oddDivider) {
item.element.classList.add('odd');
} else {
item.element.classList.remove('odd');
}
}
}
// Increment the x-step by the sizing of the item.
// If we go over the current boundary, reset it, and step the y-axis.
x += item.gridWidth;
if ((x >= this.layout.cols) && (idx < this.items.length - 1)) {
step(item);
}
}
// All the children of this element are absolutely positioned and then
// transformed, so the grid actually has no height. Fire an event that
// embedders can listen to discover the grid height.
if (this.layout.offsetY != oldHeight) {
if (this.dragdrop && this.dragdrop.inDragAction) {
// Delay size changes during drags to avoid jankiness when dragging
// items around due to touch positions changing.
this.layout.offsetY = oldHeight;
} else {
this.element.dispatchEvent(new CustomEvent('gaiagrid-resize',
{ detail: this.layout.offsetY }));
}
}
this.element.setAttribute('cols', this.layout.cols);
pendingCachedIcons === 0 && onCachedIconRendered();
this.loadDragDrop();
},
/**
* Loads dragdrop libraries and instantiates if necessary.
* DragDrop libraries are lazy laoded to save on startup time. They are
* loaded after the initial paint in order to paint the icons as fast
* as possible.
*/
loadDragDrop: function() {
if (!this.dragdrop && this.config.features.dragdrop) {
LazyLoader.load('shared/elements/gaia_grid/js/grid_dragdrop.js', () => {
if (this.dragdrop) {
return;
}
this.dragdrop = new GridDragDrop(this);
});
}
}
};
exports.GridView = GridView;
}(window));

View file

@ -0,0 +1,160 @@
'use strict';
(function(exports) {
const pinchThreshold = Math.round(window.innerWidth / 12);
function GridZoom(gridView) {
this.gridView = gridView;
this.touches = 0;
this.zoomStartTouches = [];
this.zoomStartDistance = 0;
this.container = document.getElementById('zoom');
this.arrows = this.container.querySelector('.arrows');
this.curtain = this.container.querySelector('.curtain');
this.indicator = this.container.querySelector('.indicator');
this.start();
window.addEventListener('gaiagrid-dragdrop-begin', this.stop.bind(this));
window.addEventListener('gaiagrid-dragdrop-finish', this.start.bind(this));
}
GridZoom.prototype = {
/**
* Starts listening for touchstart events.
* This is what starts listening events to start the drag & drop operation.
*/
start: function() {
window.addEventListener('touchstart', this);
},
/**
* Stops listening for touchstart events.
* This will effectively disable pinch-to-zoom.
*/
stop: function() {
window.removeEventListener('touchstart', this);
},
_attachGestureListeners: function() {
window.addEventListener('touchmove', this);
window.addEventListener('touchend', this);
},
_stopGestureListeners: function() {
window.removeEventListener('touchmove', this);
window.removeEventListener('touchend', this);
},
_resetState: function() {
this.zoomInProgress = false;
this.container.hidden = true;
this.indicator.classList.remove('active');
this.arrows.classList.remove('zooming', 'grow', 'shrink');
this.arrows.style.transform = '';
},
/**
* General Event Handler
*/
handleEvent: function(e) {
if (e.type === 'touchend' && this.zoomStartTouches) {
if (!this.zoomInProgress) {
this._resetState();
}
this._stopGestureListeners();
return;
}
if (!e.touches || e.touches.length !== 2) {
return;
}
// Sort touches by ascending pageX position.
var touches = [e.touches[0], e.touches[1]].sort(function(a, b) {
return a.pageX - b.pageX;
});
var touchDistance = Math.sqrt(
(touches[0].pageX - touches[1].pageX) *
(touches[0].pageX - touches[1].pageX) +
(touches[0].pageY - touches[1].pageY) *
(touches[0].pageY - touches[1].pageY));
var layout = this.gridView.layout;
switch(e.type) {
case 'touchstart':
this.container.hidden = false;
this.zoomStartTouches = touches;
this.zoomStartDistance = touchDistance;
if (layout.cols < layout.maxIconsPerRow) {
this.arrows.classList.add('grow');
} else {
this.arrows.classList.add('shrink');
}
this.indicator.dataset.cols = layout.cols;
this._attachGestureListeners();
break;
case 'touchmove':
if (layout.cols < layout.maxIconsPerRow &&
touchDistance < this.zoomStartDistance &&
Math.abs(touchDistance - this.zoomStartDistance) >
pinchThreshold) {
this.gridView.cols = layout.maxIconsPerRow;
this.zoomInProgress = true;
} else if (layout.cols > layout.minIconsPerRow &&
touchDistance > this.zoomStartDistance &&
Math.abs(touchDistance - this.zoomStartDistance) >
pinchThreshold) {
this.gridView.cols = layout.minIconsPerRow;
this.zoomInProgress = true;
} else {
return;
}
// For now just implement a canned animation
this._stopGestureListeners();
var ontransitionend = function() {
this.arrows.removeEventListener('transitionend', ontransitionend);
// Change the indicator color at the end.
this.indicator.classList.add('active');
this.gridView.render();
// Reset zoom element state after a set time.
var zoomHideTime = 400;
setTimeout(this._resetState.bind(this), zoomHideTime);
}.bind(this);
this.arrows.addEventListener('transitionend', ontransitionend);
this.indicator.dataset.cols = layout.cols;
this.arrows.classList.add('zooming');
// Force a sync reflow
document.body.clientHeight;
var scaleTransform = 'scale(1)';
if (this.zoomInProgress && touchDistance < this.zoomStartDistance) {
scaleTransform = 'scale(0.4)';
}
this.arrows.style.transform = scaleTransform;
break;
}
}
};
exports.GridZoom = GridZoom;
}(window));

View file

@ -0,0 +1,127 @@
'use strict';
/* global GaiaGrid */
/* global GridIconRenderer */
/* global MozActivity */
/* jshint nonew: false */
(function(exports) {
const TYPE = 'bookmark';
/**
* Represents a single bookmark icon on the homepage.
*/
function Bookmark(record, features) {
this.detail = record;
this.features = features || {};
this.detail.type = TYPE;
}
Bookmark.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
* Bookmarks use a custom icon renderer because they are likely much
* smaller than a standard icon.
*/
renderer: GridIconRenderer.TYPE.FAVICON,
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
get name() {
return this.detail.name;
},
get icon() {
return this.detail.icon || this.defaultIcon;
},
get identifier() {
return this.detail.id;
},
update: GaiaGrid.GridItem.prototype.updateFromDatastore,
/**
* Bookmarks are always editable unless noted otherwise in features.
*/
isEditable: function() {
return this.features && this.features.isEditable !== false;
},
/**
* Bookmarks are always removable unless noted otherwise in features.
*/
isRemovable: function() {
return this.features && this.features.isRemovable !== false;
},
/**
* Bookmarks are always draggable unless noted otherwise in features.
*/
isDraggable: function() {
return this.features && this.features.isDraggable !== false;
},
/**
* This method overrides the GridItem.render function.
*/
render: function() {
GaiaGrid.GridItem.prototype.render.call(this);
this.element.classList.add('bookmark');
if (this.isEditable()) {
this.element.classList.add('editable');
}
},
/**
* Launches the bookmark in a browser window.
*/
launch: function() {
var features = {
name: this.name,
icon: this.icon,
remote: true
};
var url = this.detail.url;
if (this.features.search) {
features.searchName = this.name;
features.searchUrl = url;
}
window.open(url, '_samescope', Object.keys(features)
.map(function eachFeature(key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(features[key]);
}).join(','));
},
/**
* Opens a web activity to edit the bookmark.
*/
edit: function() {
new MozActivity({
name: 'save-bookmark',
data: {
type: 'url',
url: this.detail.id
}
});
}
};
exports.GaiaGrid.Bookmark = Bookmark;
}(window));

View file

@ -0,0 +1,73 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* Represents a single divider on the homepage.
*/
function Divider() {
this.detail = {
type: 'divider',
index: 0
};
}
Divider.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
x: 0,
y: 0,
headerHeight: 0,
/**
* Height in pixels of each divider.
*/
get pixelHeight() {
return (this.grid.layout.cols > 3) ? 50 : 60;
},
/**
* Width in grid units for each divider.
*/
gridWidth: 4,
scale: 1,
/**
* Renders the icon to the grid component.
*/
render: function() {
// Generate the content if we need to
if (!this.element) {
// Divider is a <section> and the rest of items are <div> containers
// in order to hide the last divider via :last-of-type pseudo-class
var divider = this.element = document.createElement('section');
divider.className = 'divider';
var span = document.createElement('span');
span.className = 'spacer';
divider.appendChild(span);
this.grid.element.appendChild(divider);
}
this.element.style.transform = 'translate(0 ,' + this.y + 'px)';
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
},
isDraggable: function() {
return false;
}
};
exports.GaiaGrid.Divider = Divider;
}(window));

View file

@ -0,0 +1,666 @@
'use strict';
/* global GridIconRenderer */
/* global Promise */
/* global UrlHelper */
(function(exports) {
// event names
const ICON_BLOB_DECORATED_EVENT = 'iconblobdecorated';
const ICON_BLOB_ERROR_EVENT = 'gaiagrid-iconbloberror';
const FETCH_XHR_TIMEOUT = 10000;
/**
* XHR wrapper for fetching blobs with timeout logic.
*
* @param {String} uri to fetch.
* @return {Promise[Blob]}
*/
function fetchBlob(uri) {
return new Promise(function(accept, reject) {
var xhr = new XMLHttpRequest({
mozAnon: true,
mozSystem: true
});
xhr.open('GET', uri, true);
xhr.responseType = 'blob';
xhr.timeout = FETCH_XHR_TIMEOUT;
// remember that send can throw for some non http protocols. The promise
// wrapper here protects us.
xhr.send();
xhr.onload = function() {
var status = xhr.status;
if (status !== 0 && status !== 200) {
reject(
new Error('Got HTTP status ' + status + ' trying to load ' + uri)
);
return;
}
accept(xhr.response);
};
xhr.onerror = xhr.ontimeout = function() {
reject(new Error('Error while HTTP GET: ', uri));
};
});
}
/**
* Represents a generic grid item from which other items can inherit from.
*/
function GridItem() {
this.detail = {};
}
GridItem.prototype = {
x: 0,
y: 0,
/**
* Height in pixels of each item.
*/
pixelHeight: 0,
/**
* Width in grid units for each item.
*/
gridWidth: 1,
scale: 1,
/**
* Whether or not this icon will persist to the database.
*/
persistToDB: true,
/**
* Cached values of the last transform of the element, to avoid redundant
* style changes.
*/
lastX: null,
lastY: null,
lastScale: null,
/**
* Whether or not this item has a cached icon or not.
*/
get hasCachedIcon() {
return this.detail && this.detail.decoratedIconBlob;
},
/**
* The icon renderer to use. Sub-classes may override this, and individual
* objects may as well with a custom detail.renderer property.
* Bookmarks use this for example to render both web results from E.me,
* and favicons from the web with different styles.
*/
renderer: GridIconRenderer.TYPE.STANDARD,
/**
* Every grid item has a desired icon (over the network) if we fail to fetch
* or render it for some reason we display another. This string keeps track
* of the states:
*
*
* none: no action has been taken yet to display or fetch the icon.
* pending: fetching the icon
* success: icon was fetched and displayed
* error: icon could not be fetched or displayed
*
*/
iconState: 'none',
/**
* Default icon url to use when no other icon can be downloaded
*/
get defaultIcon() {
// XXX: ideally this would be relative to the component
return '/shared/elements/gaia_grid/images/default_icon.png';
},
/**
* Returns a reference to the current grid.
* We can currently only have one grid per page.
*/
get grid() {
return document.getElementsByTagName('gaia-grid')[0]._grid;
},
/**
* Returns true if this item is removable.
*/
isRemovable: function() {
return false;
},
/**
* Returns true if this item is editable.
*/
isEditable: function() {
return false;
},
/**
* Returns true if this item is draggable.
*/
isDraggable: function() {
return true;
},
/**
* Returns true if the icon is hosted at an origin.
*/
isIconFromOrigin: function() {
return this.icon.startsWith('http') || this.icon.startsWith('app:');
},
/**
* Sets the item position
*/
setPosition: function(position) {
this.detail.index = position;
},
/**
* Sets the item coordinates
*/
setCoordinates: function(x, y) {
this.x = x;
this.y = y;
},
/**
* Dispatches a gaiagrid-attention signal with the rect of the item.
*/
requestAttention: function() {
var rect = {
x: this.x,
y: this.y,
width: this.gridWidth * this.grid.layout.gridItemWidth,
height: this.pixelHeight
};
this.grid.element.dispatchEvent(
new CustomEvent('gaiagrid-attention', { detail: rect }));
},
closestIconSizeFromList: function(choices) {
if (!choices) {
return 0;
}
var sizes = Object.keys(choices);
if (!sizes.length) {
return 0;
}
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
var filter = (a) => a >= maxSize ? a : 0;
// Ideally, get an icon that is equal to our grid size, or else
// the smallest icon which is larger than our grid size.
var bestSize = Math.min(...sizes.map(filter));
// If no icons are larger than (or equal to ) our grid size, get
// the largest one.
bestSize = bestSize || Math.max(...sizes);
return bestSize || 0;
},
/**
* Given a list of icons that match a size, return the closest icon to
* reduce possible pixelation by picking a wrong size.
* @param {Object} choices An object mapping icon size to icon URL.
*/
closestIconFromList: function(choices) {
var icon;
// Check for W3C web manifest format for icons
if (Array.isArray(choices)) {
var manifest = {
'icons': choices
};
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
icon = window.IconsHelper.getBestIconFromWebManifest(manifest, maxSize);
return icon ? (new URL(icon, this.app.manifestURL)).href : null;
}
var accurateSize = this.closestIconSizeFromList(choices);
if (accurateSize === 0) {
return this.defaultIcon;
}
icon = choices[accurateSize];
// Handle relative URLs
if (!UrlHelper.hasScheme(icon)) {
icon = this.app.origin + icon;
}
return icon;
},
/**
* Renders the given image src as the icon (with processing).
*
* @param {String} url The image url to display.
*/
renderIconFromSrc: function(url) {
var background = new Image();
background.src = url;
background.onload = this._decorateIcon.bind(this, background);
},
/**
* Renders the given blob as the icon (with processing).
*
* @param {Blob} blob to render as icon.
*/
renderIconFromBlob: function(blob) {
var background = new Image();
background.src = URL.createObjectURL(blob);
background.onload = () => {
this._decorateIcon(background);
URL.revokeObjectURL(background.src);
};
background.onerror = () => {
URL.revokeObjectURL(background.src);
// If we already have a cached icon, do not overwrite it on error.
if (this.hasCachedIcon) {
this._stampElementWithIcon('blobcache');
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
return;
}
this.renderIconFromSrc(this.defaultIcon);
this._stampElementWithIcon(this.defaultIcon);
};
},
/**
* Begins decoration process for a given icon.
*
* @param {HTMLImageElement} img An image element to display from.
*/
_decorateIcon: function(img) {
var strategy = this.detail.renderer || this.renderer;
this.rendererInstance = new GridIconRenderer(this);
this.rendererInstance[strategy](img).
then(this._displayDecoratedIcon.bind(this));
},
/**
* Finishes decoration process and displays the augmented icon.
*
* @param {Blob} blob The image blob to display.
*/
_displayDecoratedIcon: function(blob, isCachedIcon) {
if (!this.element) {
// The icon could be removed while it is being decorated
return;
}
var style = this.element.style;
if (!style.backgroundSize) {
style.height = this.grid.layout.gridItemHeight + 'px';
// icon size + padding for shadows implemented in the icon renderer
var backgroundHeight =
((this.grid.layout.gridIconSize * (1 / this.grid.layout.percent)) +
GridIconRenderer.prototype.unscaledCanvasPadding);
if (this.grid.layout.gridItemHeight > backgroundHeight) {
style.backgroundSize = backgroundHeight + 'px';
} else {
style.backgroundSize = style.height;
}
}
if (isCachedIcon) {
var url = URL.createObjectURL(blob);
style.backgroundImage = 'url(' + url + ')';
this.element.dataset.backgroundImage = url;
var img = new Image();
img.onload = img.onerror = () => {
this.grid.element.dispatchEvent(
new CustomEvent('cached-icon-rendered')
);
};
img.src = url;
return;
}
this._compareBlobs(blob, this.detail.decoratedIconBlob).
then((equal) => {
if (equal) {
return;
}
style.backgroundImage = 'url(' + URL.createObjectURL(blob) + ')';
this.detail.decoratedIconBlob = blob;
this.grid.element.dispatchEvent(
new CustomEvent(ICON_BLOB_DECORATED_EVENT, {
detail: this
})
);
var bgImg = this.element.dataset.backgroundImage;
bgImg && URL.revokeObjectURL(bgImg);
});
},
/**
* It compares two blobs.
*/
_compareBlobs: function(blob1, blob2) {
return new Promise(function(resolve) {
if (!blob1 || !blob2 || blob1.type !== blob2.type ||
blob1.size !== blob2.size) {
resolve(false);
return;
}
// We skip the first bytes that typically are headers
var startBytes = 127;
var bytesHash = 16;
var reader = new FileReader();
reader.onloadend = function() {
var result1 = reader.result;
reader = new FileReader();
reader.onloadend = reader.onerror = function() {
resolve(result1 === reader.result);
};
reader.readAsDataURL(blob2.slice(startBytes, startBytes + bytesHash));
};
reader.onerror = function() {
resolve(false);
};
reader.readAsDataURL(blob1.slice(startBytes, startBytes + bytesHash));
});
},
/**
* Fetch an icon blob by a URI. This can be used as an extension hook to
* implement caching logic into the fetching process.
*
* @return {Promise[Null|Blob]}
*/
fetchIconBlob: function() {
return fetchBlob(this.icon);
},
/**
* Helper method to stamp the element with an icon uri. This stamp is for
* debugging/testing only and has no other effect.
*/
_stampElementWithIcon: function(uri) {
// ensure we don't stamp URI
if (!this.element || uri.startsWith('data:')) {
return;
}
// do not allow long strings on the stamp
if (uri.length > 255) {
uri = uri.slice(0, 255);
}
// test prefix used to indicate this has no functional value.
this.element.dataset.testIcon = uri;
},
/**
Fetch and render the desired icon (`.icon`) if possible otherwise fallback
to the default icon.
XXX: This method is not concurrency safe one image may override another
without ordering.
*/
renderIcon: function(renderCachedIcon) {
var icon = this.icon;
this.iconState = 'pending';
if (!icon) {
return;
}
if (renderCachedIcon && this.hasCachedIcon) {
// Display cached icons before trying to get icons again.
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
var resolveIcon = () => {
this.grid.element.removeEventListener('cached-icons-rendered',
resolveIcon);
this.doRenderIcon(icon);
};
this.grid.element.addEventListener('cached-icons-rendered',
resolveIcon);
} else {
this.doRenderIcon(icon);
}
},
doRenderIcon: function(icon) {
// fast path if the icon is not from an origin display it outright
if (!this.isIconFromOrigin()) {
// XXX: Should we convert data uri(s) to blobs?
this._stampElementWithIcon(icon);
this.renderIconFromSrc(icon);
// state set after in case of some exception or whatever
this.iconState = 'success';
// noop
return Promise.resolve();
}
// root element of the web component
var eventTarget = this.grid.element;
// handle any cases where the blob is null or the icon cannot be
// fetched.
var blobNotFound = () => {
this.iconState = 'error';
if (!this.hasCachedIcon) {
this.renderIconFromSrc(this.defaultIcon);
this._stampElementWithIcon(this.defaultIcon);
} else {
this._stampElementWithIcon('blobcache');
}
eventTarget.dispatchEvent(
new CustomEvent(ICON_BLOB_ERROR_EVENT, {
detail: this
})
);
};
return this.fetchIconBlob().
then((blob) => {
// if the fetch was a success but no blob could be found short
// short circuit... note that being offline is _not_ an error.
if (!blob) {
return blobNotFound();
}
this.renderIconFromBlob(blob);
this.iconState = 'success';
this._stampElementWithIcon(icon);
}).
catch((err) => {
blobNotFound();
});
},
/**
Safely remove this item from the grid and DOM.
*/
removeFromGrid: function() {
var idx = this.grid.items.indexOf(this);
// This should never happen but is remotely possible item is not in the
// grid.
if (idx === -1) {
console.error('Attempting to remove self before item has been added!');
return;
}
// update the state of the grid and DOM so this item is no longer
// referenced.
this.grid.items.splice(idx, 1);
delete this.grid.icons[this.identifier];
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
// ensure we don't end up with empty cruft..
this.grid.render({ from: idx - 1 });
},
/**
Removes item from the dom and dispatches a removeitem event.
*/
remove: function() {
this.grid.element.dispatchEvent(new CustomEvent('removeitem', {
detail: this
}));
},
/**
* Renders the icon to the container.
*/
render: function() {
var scale = this.grid.layout.percent * this.scale;
// Generate an element if we need to
if (!this.element) {
var tile = document.createElement('div');
tile.className = 'icon';
tile.dataset.identifier = this.identifier;
tile.dataset.isDraggable = this.isDraggable();
tile.setAttribute('role', 'link');
tile.setAttribute('tabindex', 0);
tile.style.width = (this.grid.layout.constraintSize / 3) + 'px';
// This <p> has been added in order to place the title with respect
// to this container via CSS without touching JS.
var nameContainerEl = document.createElement('p');
nameContainerEl.style.marginTop = ((this.grid.layout.gridIconSize *
(1 / this.grid.layout.percent)) +
(GridIconRenderer.prototype.unscaledCanvasPadding / 2)) + 'px';
tile.appendChild(nameContainerEl);
var nameEl = document.createElement('span');
nameEl.className = 'title';
nameEl.setAttribute('dir', 'auto');
nameContainerEl.appendChild(nameEl);
// Add delete link if this icon is removable
if (this.isRemovable()) {
var removeEl = document.createElement('span');
removeEl.className = 'remove';
tile.appendChild(removeEl);
}
this.element = tile;
this.updateTitle();
this.renderIcon(true);
this.grid.element.appendChild(tile);
}
this.transform(this.x, this.y, scale);
},
/**
* Positions and scales an icon.
*/
transform: function(x, y, scale, element) {
scale = scale || 1;
if (!element) {
if (x === this.lastX && y === this.lastY && scale === this.lastScale) {
return;
}
element = this.element;
this.lastX = x;
this.lastY = y;
this.lastScale = scale;
}
element.style.transform =
'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
},
/**
* Set whether the item is being dragged.
*/
setActive: function(active) {
if (active) {
this.element.classList.add('active');
this.active = true;
} else {
this.element.classList.remove('active');
this.active = false;
}
},
/**
Updates the title of the icon on the grid.
*/
updateTitle: function() {
// it is remotely possible that we have not .rendered yet
if (!this.element) {
return;
}
var nameEl = this.element.querySelector('.title');
if (this.asyncName) {
this.asyncName().then(function(name) {
nameEl.textContent = name;
});
} else {
nameEl.textContent = this.name;
}
},
/**
* Updates an icon on the page from a datastore record.
* Used for bookmarks.
* @param {Object} record The datastore record.
*/
updateFromDatastore: function(record) {
var iconChanged = record.icon !== this.icon;
var nameChanged = record.name !== this.name;
var type = this.detail.type;
var lastIcon = this.icon;
record.type = type;
this.detail = record;
if (nameChanged) {
this.updateTitle();
}
if (iconChanged && record.icon) {
this.renderIcon();
} else if (!record.icon) {
this.detail.icon = lastIcon;
}
}
};
exports.GaiaGrid.GridItem = GridItem;
}(window));

View file

@ -0,0 +1,430 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* The relative size of icons when in a collapsed group.
*/
const COLLAPSE_RATIO = 0.33;
/**
* Maximum number of icons that are visible in a collapsed group.
*/
const COLLAPSED_GROUP_SIZE = 6;
/**
* Space to be reserved at the sides of collapsed group items, in pixels.
*/
const COLLAPSED_GROUP_MARGIN_LEFT = 23;
const COLLAPSED_GROUP_MARGIN_RIGHT = 53;
/**
* Time, in ms, before removing the 'toggling' class from the expand/collapse
* toggle element.
*/
const TOGGLE_TIMEOUT = 250;
/**
* A replacement for the default Divider class that implements group
* collapsing and provides convenience functions for group drag'n'drop.
*/
function Group(detail) {
this.detail = detail || {};
this.detail.type = 'divider';
this.detail.index = 0;
this.pendingCollapse = this.detail.collapsed = !!this.detail.collapsed;
}
Group.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
gridWidth: 4,
/**
* Height in pixels of the header part of the group.
*/
get headerHeight() {
return this.detail.collapsed ? 20 : 30;
},
/**
* The collapsed state. Used to defer the actual state change of
* collapse/expand so that anything re-rendering the grid between setting
* the state and the re-render caused by the state change doesn't cause
* the group to be positioned incorrectly.
*/
pendingCollapse: false,
pendingCollapseTimeout: null,
/**
* Height in pixels of the background of the group.
*/
backgroundHeight: 0,
lastBackgroundHeight: null,
/**
* Height in pixels of the separator at the bottom of the group.
*/
separatorHeight: 0,
/**
* The scale used for collapsed icons, taking into account column size.
*/
collapseRatio: COLLAPSE_RATIO,
/**
* Height in pixels of the group. When collapsed, this includes the
* icons associated with the group.
*/
get pixelHeight() {
return this.detail.collapsed ?
this.backgroundHeight :
this.separatorHeight;
},
/**
* Returns the number of items in the group. Relies on the item index
* being correct.
*/
get size() {
var size = 0;
for (var i = this.detail.index - 1, items = this.grid.items;
i >= 0 && items[i].detail.type !== 'divider'; i--) {
size++;
}
return size;
},
/**
* Creates the element and its children for this group item.
*/
_createElement: function() {
var group = this.element = document.createElement('section');
group.className = 'divider group newly-created';
// Create the background (only seen in edit mode)
var span = document.createElement('span');
span.className = 'background';
group.appendChild(span);
this.backgroundSpanElement = span;
// Create an element for a drop-shadow (only seen when active)
span = document.createElement('span');
span.className = 'shadow';
group.appendChild(span);
this.shadowSpanElement = span;
// Create the header (container for the move gripper and
// expand/collapse toggle)
span = document.createElement('span');
span.className = 'header';
group.appendChild(span);
this.headerSpanElement = span;
// Create the move gripper
span = document.createElement('span');
span.className = 'gripper';
this.headerSpanElement.appendChild(span);
// Create the expand/collapse toggle
var button = document.createElement('button');
button.className = 'toggle';
button.dataset.l10nId = 'gaia-grid-toggle-expanded';
this.headerSpanElement.appendChild(button);
this.toggleElement = button;
// Create the group separator (only seen in non-edit mode)
span = document.createElement('span');
span.className = 'separator';
group.appendChild(span);
this.dividerSpanElement = span;
// Create a child span for the separator to act as the indicator for
// dropping icons/groups during editing.
this.dividerSpanElement.appendChild(document.createElement('span'));
this.grid.element.appendChild(group);
this.separatorHeight = this.dividerSpanElement.clientHeight;
this.lastBackgroundHeight = null;
},
/**
* Renders the apps within the group. If the group is not collapsed, this
* just validates the style class of the group.
*/
_renderChildren: function(nApps) {
var isRTL = (document.documentElement.dir === 'rtl');
if (!this.detail.collapsed) {
this.element.classList.remove('collapsed');
return;
}
// If collapsed, we need to style and position the icons in the group
// into one row.
this.element.classList.add('collapsed');
var y = this.y;
var index = this.detail.index;
var width = Math.round(
(this.grid.layout.constraintSize -
COLLAPSED_GROUP_MARGIN_LEFT - COLLAPSED_GROUP_MARGIN_RIGHT) /
COLLAPSED_GROUP_SIZE);
var x = isRTL ?
(this.grid.layout.constraintSize - COLLAPSED_GROUP_MARGIN_RIGHT) :
COLLAPSED_GROUP_MARGIN_LEFT;
y += this.headerHeight;
var maxGridItemWidth =
this.grid.layout.constraintSize / this.grid.layout.minIconsPerRow;
this.collapseRatio =
(maxGridItemWidth / this.grid.layout.gridItemWidth) * COLLAPSE_RATIO;
for (var i = index - nApps; i < index; i++) {
var item = this.grid.items[i];
if (this.detail.collapsed) {
item.scale = this.collapseRatio;
var itemVisible = (i - (index - nApps)) < COLLAPSED_GROUP_SIZE;
if (!itemVisible) {
item.setCoordinates(isRTL ? x + width : x - width, y);
} else {
item.setCoordinates(x, y);
x += isRTL ? -width: width;
}
item.render();
item.element.classList.add('collapsed');
if (!itemVisible) {
item.element.classList.add('hidden');
}
}
}
},
/**
* Gets the y-position of the group. When the group is expanded, its y
* position is actually the y-position of the separator underneath the
* group. This gets the visible y-position of the group.
*/
getRealYPosition: function(nApps) {
if (this.detail.collapsed) {
return this.y;
}
return this.grid.items[this.detail.index - nApps].y -
this.headerHeight;
},
/**
* Renders the icon to the grid component.
*/
render: function() {
// Generate the content if we need to
var createdElements = false;
if (!this.element) {
createdElements = true;
this._createElement();
}
// Render children
var nApps = this.size;
this._renderChildren(nApps);
// Calculate group position.
// If we're not collapsed, the group's position will be underneath its
// icons, but we want it to display above.
var y = this.getRealYPosition(nApps);
if (y !== this.lastY) {
// Place the header span
this.headerSpanElement.style.transform =
'translate(0px, ' + y + 'px)';
}
if (this.toggleElement) {
var toggleLabel = this.detail.collapsed ? 'collapsed' : 'expanded';
this.toggleElement.dataset.l10nId = 'gaia-grid-toggle-' + toggleLabel;
}
// Calculate the height of the background span
if (this.detail.collapsed) {
this.backgroundHeight =
Math.round(this.collapseRatio * this.grid.layout.gridIconSize * 1.5);
} else {
var height = Math.ceil(nApps / this.grid.layout.cols);
this.backgroundHeight = (height || 1) * this.grid.layout.gridItemHeight;
}
this.backgroundHeight += this.headerHeight;
if (y != this.lastY ||
this.backgroundHeight !== this.lastBackgroundHeight) {
// Place and size the background span element
this.backgroundSpanElement.style.transform =
'translate(0px, ' + y + 'px) scale(1, ' + this.backgroundHeight + ')';
// Place and size the shadow span element
this.shadowSpanElement.style.transform =
'translateY(' + y + 'px)';
this.shadowSpanElement.style.height = this.backgroundHeight + 'px';
// Place the divider after this point
this.dividerSpanElement.style.transform =
'translate(0px, ' + (y + this.backgroundHeight) + 'px)';
}
// Update the cached size values
this.lastBackgroundHeight = this.backgroundHeight;
this.lastY = y;
// Now include the separator in the background height
this.backgroundHeight += this.separatorHeight;
// Fade in newly-created groups
if (createdElements) {
// Force a reflow to make sure the transform transition doesn't play
this.element.clientTop;
this.element.classList.remove('newly-created');
}
},
forEachItem: function(callback) {
var nApps = this.size;
var index = this.detail.index;
for (var i = index - nApps; i < index; i++) {
callback(this.grid.items[i]);
}
},
setActive: function(active) {
GaiaGrid.GridItem.prototype.setActive.call(this, active);
// Make sure we're collapsed
this.collapse();
// Mark our child items as active/inactive with us so they pick up the
// right style when dragged.
var callback = active ?
function(item) { item.element.classList.add('active'); } :
function(item) { item.element.classList.remove('active'); };
this.forEachItem(callback);
},
/*
* As the start of a collapse/expand animation can involve a lot of work,
* the response isn't always instant. To help alleviate this, we show a
* quick response on the toggle element first before initiating the rest
* of the work.
*/
_rerenderOnToggle: function() {
if (this.pendingCollapseTimeout) {
clearTimeout(this.pendingCollapseTimeout);
this.pendingCollapseTimeout = null;
}
if (this.toggleElement) {
this.toggleElement.classList.add('toggling');
setTimeout(() => { this.toggleElement.classList.remove('toggling'); },
TOGGLE_TIMEOUT);
}
// Ideally, this setTimeout would be a requestAnimationFrame, but the
// above class-adding seems to end up coalesced with the code inside
// requestAnimationFrame, rather than it happening on the next frame.
// The same thing happens with any setTimeout lower than about 20ms too.
this.pendingCollapseTimeout = setTimeout(() => {
this.detail.collapsed = this.pendingCollapse;
this.pendingCollapseTimeout = null;
if (!this.detail.collapsed) {
// Remove collapsed styling from all icons
this.forEachItem(function(item) {
item.scale = 1;
if (item.element) {
item.element.classList.remove('collapsed');
item.element.classList.remove('hidden');
}
});
}
// If the item is active, re-render it so that its pixel-height is
// correct when the rest of the grid is rendered.
if (this.active) {
this.render();
}
this.grid.render();
var dragging = this.grid.dragdrop && this.grid.dragdrop.inDragAction;
if (dragging) {
// If we're dragging, make sure to reposition the icon in the correct
// place, as the render call won't redraw us
this.grid.dragdrop.updateIconPosition();
} else {
// If we're not dragging, save the collapsed state
window.dispatchEvent(new CustomEvent('gaiagrid-saveitems'));
// Request attention so that we're as visible as we can be after
// expanding/collapsing
this.requestAttention();
}
}, 20);
},
collapse: function() {
if (this.detail.collapsed) {
return;
}
this.pendingCollapse = true;
this._rerenderOnToggle();
},
expand: function() {
if (!this.detail.collapsed) {
return;
}
this.pendingCollapse = false;
this._rerenderOnToggle();
},
launch: function(target) {
if (target !== this.toggleElement) {
return;
}
if (this.detail.collapsed) {
this.expand();
} else {
this.collapse();
}
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
},
isDraggable: function() {
return true;
},
requestAttention: function() {
var rect = {
x: 0,
y: this.getRealYPosition(this.size),
width: this.gridWidth * this.grid.layout.gridItemWidth,
height: this.backgroundHeight
};
this.grid.element.dispatchEvent(
new CustomEvent('gaiagrid-attention', { detail: rect }));
}
};
exports.GaiaGrid.Divider = Group; // Override the non-grouping divider
}(window));

View file

@ -0,0 +1,51 @@
'use strict';
/* global GaiaGrid */
/* global GridIconRenderer */
/* global MozActivity */
/* jshint nonew: false */
(function(exports) {
const TYPE = 'marketplace-app';
/**
* Represents a single bookmark icon on the homepage.
*/
function MarketPlaceApp(record) {
this.detail = record;
this.detail.type = TYPE;
}
MarketPlaceApp.prototype = {
__proto__: GaiaGrid.Bookmark.prototype,
renderer: GridIconRenderer.TYPE.STANDARD,
/**
* Returns the icon image path.
*/
get icon() {
var icon = this._accurateIcon;
if (!icon) {
icon = this._accurateIcon = this.closestIconFromList(
this.detail.icons);
}
return icon;
},
launch: function() {
new MozActivity({
name: 'marketplace-app',
data: {
slug: this.detail.slug
}
});
},
};
exports.GaiaGrid.MarketPlaceApp = MarketPlaceApp;
}(window));

View file

@ -0,0 +1,369 @@
'use strict';
/* global ConfirmDialogHelper, GaiaGrid */
(function(exports) {
const IDENTIFIER_SEP = '-';
var MOZAPP_EVENTS = [
'downloadsuccess',
'downloaderror',
'downloadavailable',
'downloadapplied',
'progress'
];
var APP_LOADING = 'loading';
var APP_ERROR = 'error';
var APP_UNRECOVERABLE = 'unrecoverable';
var APP_PAUSED = 'paused';
var APP_READY = 'ready';
function localizeString(str) {
var userLang = document.documentElement.lang;
// We want to make sure that we translate only if we're using
// a runtime pseudolocale.
// mozL10n.ctx.qps contains only runtime pseudolocales
if (navigator.mozL10n &&
navigator.mozL10n.ctx.qps.indexOf(userLang) !== -1) {
return navigator.mozL10n.qps[userLang].translate(str);
}
return str;
}
/**
* Represents single app icon on the homepage.
*/
function Mozapp(app, entryPoint, details) {
this.app = app;
this.entryPoint = entryPoint;
this.detail = {
type: 'app',
manifestURL: app.manifestURL,
entryPoint: entryPoint,
index: 0,
// XXX: Somewhat ugly hack around the constructor args
decoratedIconBlob: details && details.decoratedIconBlob
};
// Yes this is clobbering all other listeners to the app MozApp
// should be in charge here otherwise the icon will be incorrect.
MOZAPP_EVENTS.forEach(function(type) {
this.app['on' + type] = this.handleEvent.bind(this);
}, this);
// Determine and set the initial state of the app when it is inserted.
this.setAppState(this._determineState(app));
}
/**
`app.manifest.role`s which should not be displayed on the grid.
*/
Mozapp.HIDDEN_ROLES = [
'system', 'input', 'homescreen', 'search', 'addon', 'langpack'
];
Mozapp.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
Determine if this application is supposed to be hidden.
@return Boolean
*/
_isHiddenRole: function() {
var manifest = this.app.manifest;
// don't know what the role is so it is not hidden
if (!manifest || !manifest.role) {
return null;
}
// if it is in the hidden role list return true
return Mozapp.HIDDEN_ROLES.indexOf(manifest.role) !== -1;
},
/**
Figure out which state the app is in
*/
_determineState: function(app) {
// is there is a pending download but we cannot download then this app is
// in an unrecoverable state due to some fatal error.
if (
// Must be pending (meaning not launchable)
app.installState === 'pending' &&
// Without any option for downloading the app
!app.downloadAvailable &&
// And not be currently applying a download
!app.readyToApplyDownload
) {
return APP_UNRECOVERABLE;
}
// If the app is currently downloading life is good and we should display
// loading.
if (app.downloading) {
return APP_LOADING;
}
// Bug 1027347 - downloadError is always present even if there is no error
// so we check both for the error and that the error has a
// name.
if (app.downloadError && app.downloadError.name) {
// If the app was paused while the homescreen was running the
// downloaderror event will fire with the DOWNLOAD_CANCELED error.
if (app.downloadError.name === 'DOWNLOAD_CANCELED') {
return APP_PAUSED;
}
return APP_ERROR;
}
// Note that this logic is correct because app.downloading is caught above
// separately.
if (app.installState === 'pending') {
return APP_PAUSED;
}
return APP_READY;
},
handleEvent: function(event) {
switch (event.type) {
case 'progress':
case 'downloaderror':
case 'downloadsuccess':
this.setAppState(this._determineState(this.app));
break;
case 'downloadapplied':
// Ensure that a hidden app has not somehow been added to the grid...
if (this._isHiddenRole()) {
console.warn('Removing hidden app from the grid', this.name);
return this.removeFromGrid();
}
// we may have updated icons so recalculate the correct icon.
delete this._accurateIcon;
delete this._accurateIconSize;
// Need to set app state here to correctly handle the pause/resume
// case.
this.setAppState(this._determineState(this.app));
// XXX: This introduces a potential race condition.
// GridItem.renderIcon is not concurrency safe one image may override
// another without ordering.
this.renderIcon();
window.dispatchEvent(new CustomEvent('downloadapplied', {
detail: {
id: this.app.manifestURL
}
}));
break;
}
},
// Overrides GridItem.fetchIconBlob
fetchIconBlob: function() {
if (!this._accurateIconSize) {
this._accurateIconSize =
this.closestIconSizeFromList(this.descriptor.icons);
}
var result = this._accurateIconSize ?
navigator.mozApps.mgmt.getIcon(this.app, this._accurateIconSize,
this.entryPoint || '') :
GaiaGrid.GridItem.prototype.fetchIconBlob.call(this);
return result;
},
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
get name() {
var userLang = document.documentElement.lang;
if (navigator.mozL10n && userLang in navigator.mozL10n.qps) {
return navigator.mozL10n.qps[userLang].
translate(this.descriptor.short_name || this.descriptor.name);
}
var locales = this.descriptor.locales;
var localized =
locales && locales[userLang] &&
(locales[userLang].short_name || locales[userLang].name);
return localized || this.descriptor.short_name || this.descriptor.name;
},
asyncName: function() {
var userLang = document.documentElement.lang;
var ep = this.entryPoint || undefined;
if (!this.app.getLocalizedValue) {
return new Promise((resolve, reject) => { reject(); });
}
return this.app.getLocalizedValue('short_name', userLang, ep).then(
shortName => localizeString(shortName),
this.app.getLocalizedValue.bind(this.app, 'name', userLang, ep)).then(
name => localizeString(name),
() => this.name
);
},
/**
* Returns the icon image path.
*/
get icon() {
var icon = this._accurateIcon;
if (!icon) {
icon = this._accurateIcon = this.closestIconFromList(
this.descriptor.icons);
}
return icon;
},
get descriptor() {
var manifest = this.app.manifest || this.app.updateManifest;
if (this.entryPoint && manifest.entry_points &&
manifest.entry_points[this.entryPoint]) {
return manifest.entry_points[this.entryPoint];
}
return manifest;
},
identifierSeparator: IDENTIFIER_SEP,
get identifier() {
var identifier = [this.app.manifestURL];
if (this.entryPoint) {
identifier.push(this.entryPoint);
}
return identifier.join(IDENTIFIER_SEP);
},
/**
* Returns true if this app is removable.
*/
isRemovable: function() {
return this.app.removable;
},
/**
Show a dialog to handle unrecoverable errors.
*/
unrecoverableError: function() {
navigator.mozApps.mgmt.uninstall(this.app);
},
cancel: function() {
var dialog = new ConfirmDialogHelper({
type: 'pause',
title: {id: 'gaia-grid-stop-download-title', args: { name: this.name }},
body: 'gaia-grid-stop-download-body',
cancel: {
title: 'gaia-grid-cancel'
},
confirm: {
title: 'gaia-grid-stop-download-action',
type: 'danger',
cb: () => this.app.cancelDownload()
}
});
dialog.show(document.body);
},
resume: function() {
var dialog = new ConfirmDialogHelper({
type: 'resume',
title: 'gaia-grid-resume-download-title',
body: {id: 'gaia-grid-resume-download-body', args: { name: this.name }},
cancel: {
title: 'gaia-grid-cancel'
},
confirm: {
title: 'gaia-grid-resume-download-action',
cb: () => {
// enter the loading state optimistically
this.setAppState(APP_LOADING);
this.app.download();
}
}
});
dialog.show(document.body);
},
setAppState: function(state) {
this.appState = state;
if (this.element) {
this.element.dataset.appState = state;
}
},
render: function() {
// If there is no element before we can render we need to update the
// app state. Render may also be called to reposition elements.
var needsStateSet = !this.element;
GaiaGrid.GridItem.prototype.render.apply(this, arguments);
if (needsStateSet) {
// ensure the newly rendered item has an app state
this.setAppState(this.appState);
}
},
/**
* Resolves click action.
*/
launch: function() {
var app = this.app;
switch (this._determineState(app)) {
case APP_UNRECOVERABLE:
return this.unrecoverableError();
case APP_ERROR:
case APP_PAUSED:
return this.resume();
case APP_LOADING:
return this.cancel();
}
window.performance.mark('appLaunch@' + app.origin);
if (this.entryPoint) {
return app.launch(this.entryPoint);
}
// Default action is to launch the app.
return app.launch();
}
};
exports.GaiaGrid.Mozapp = Mozapp;
}(window));

View file

@ -0,0 +1,73 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* The placeholder represents an empty place on the grid.
* This is generally used to detect distance from an empty spot on the grid.
*/
function Placeholder() {
this.detail = {
type: 'placeholder',
index: 0
};
}
Placeholder.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* When the placeholder is rendered in the last row, for group creation
* purposes, there is special handling required for height.
*/
createsGroupOnDrop: false,
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
/**
* Placeholders do not save. They are re-generated on render calls.
*/
persistToDB: false,
get name() {
return '';
},
/**
* Renders a transparent placeholder.
*/
render: function() {
// Generate an element if we need to
if (!this.element) {
var tile = document.createElement('div');
tile.className = 'icon placeholder';
tile.style.height = this.pixelHeight + 'px';
this.element = tile;
this.grid.element.appendChild(tile);
}
this.transform(this.x, this.y, this.grid.layout.percent);
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
}
};
exports.GaiaGrid.Placeholder = Placeholder;
}(window));

View file

@ -0,0 +1,409 @@
.icon[data-app-state="loading"]:after {
content: "";
background: no-repeat top center url('images/app_install_spinner.png');
background-size: contain;
height: 8.4rem;
position: absolute;
top: 0;
left: 0;
width: 100%;
/**
Keyframe must be defined outside of the web component.
See bug 1018269
*/
animation: rotate 2.25s linear infinite;
}
.icon[data-app-state="loading"] {
/**
We must align this value with the value of [data-app-state]:after height the background size
here is overridden at the element level elsewhere which causes the spinner to be aligned too
low.
*/
background-size: 8.4rem !important;
background-image: url('images/app_installing.png') !important;
}
.icon[data-app-state="unrecoverable"] {
background-image: url('images/app_install_unrecoverable.png') !important;
}
.icon[data-app-state="error"] {
background-image: url('images/app_install_failed.png') !important;
}
.icon[data-app-state="paused"] {
background-image: url('images/app_install_canceled.png') !important;
}
.icon {
position: absolute;
transform-origin: 0 0;
display: inline-block;
text-align: center;
background: no-repeat top center;
z-index: 3;
outline: 0;
}
.icon.launching {
opacity: .65;
}
/* XXX: Override the .remove element */
.edit-mode .collection.hovered .remove {
background: url(images/add.png) no-repeat;
background-size: 2rem;
}
.icon p {
pointer-events: none;
height: 3.2rem;
width: 100%;
margin: 0;
padding: 0;
}
.icon .title {
display: inline-block;
margin-top: 0.1rem;
/* We need this rule in order not to hide the drop shadow */
padding: 0 0.2rem 1rem;
text-shadow: 0.1rem 0.1rem 0.3rem rgba(0, 0, 0, 0.3),
0.2rem 0.4rem 1rem rgba(0, 0, 0, 0.4);
font-size: 1.4rem;
color: #fff;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: calc(100% - 0.4rem);
box-sizing: border-box;
vertical-align: top;
transition: opacity var(--transition-duration);
}
/* We allow more space for text in the search app by hooking into the text-rows attribute on gaia-grid. */
[text-rows="3"] .icon .title {
height: 5.8rem;
}
[cols="4"] .icon .title {
/* Font size is the same independent on grid configuration */
font-size: calc(1.2rem * 1.333);
margin-top: 0.5rem;
line-height: 1.94rem;
}
.edit-mode .icon.collapsed {
pointer-events: none;
}
.icon.collapsed .title {
opacity: 0;
}
.icon.hidden {
pointer-events: none;
opacity: 0;
}
.divider {
position: absolute;
width: 100%;
padding-top: 2.3rem;
border-left: 0.6rem solid transparent;
border-right: 0.6rem solid transparent;
box-sizing: border-box;
z-index: 2;
}
[cols="4"] .divider {
padding-top: 1.6rem;
}
.divider span.spacer {
position: absolute;
bottom: 0;
display: block;
background: linear-gradient(to bottom, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.5));
margin: 0 25%;
width: 50%;
height: 0.2rem;
transition: opacity var(--transition-duration);
}
.dragging .icon.active:not(.hidden) {
z-index: 6;
opacity: 0.65;
}
.dragging .divider:not(.group),
.icon:not(.active),
.dropped {
transition: transform var(--transition-duration),
opacity var(--transition-duration);
}
.icon.active {
will-change: transform;
}
.edit-mode .icon .remove {
display: block;
position: absolute;
top: 0.1rem;
left: 0.5rem;
width: 2.6rem;
height: 2.6rem;
background: url(images/remove.png) no-repeat;
background-size: 2rem;
/*
Ensure that remove is on top of the icon and has some extra
padding so it is easy to click.
*/
z-index: 200;
padding: 0.5rem;
}
.icon .remove,
.icon.collapsed .remove,
.edit-mode .icon.active .remove {
display: none;
}
.edit-mode .icon.editable .title {
background-color: rgba(132, 132, 132, 0.7);
border-radius: 2rem;
margin-top: .4rem;
padding: 0 1rem;
width: auto;
max-width: 100%;
}
/*
Disable events for placeholders, so we don't trigger touch events in edit mode.
We only do this in edit-mode, otherwise we might trigger entering edit mode
instead of the context menu.
*/
.edit-mode .placeholder {
pointer-events: none;
}
#confirm-message {
z-index: 100;
}
.dragging .group.active {
z-index: 5;
}
.divider.group {
border: none;
padding: 0;
}
.group > span {
display: block;
position: absolute;
width: 100%;
transition: background-color var(--transition-duration),
opacity var(--transition-duration);
}
.group:not(.active):not(.newly-created) > span {
transition: background-color var(--transition-duration),
opacity var(--transition-duration),
transform var(--transition-duration);
}
.group.active > span {
will-change: transform;
}
.group .header {
height: 4rem;
z-index: 4;
}
.group .header span,
.group .header button {
position: absolute;
height: 4rem;
top: 0;
background-position: center top;
background-repeat: no-repeat;
background-size: 100% 2rem;
transition: transform 0.25s, opacity 0.25s;
}
.group .header .gripper {
left: calc(50% - 4.2rem);
width: 8.4rem;
background-image: url('images/move_gripper.png');
opacity: 0;
transform: translate(0, -100%);
pointer-events: none;
}
.edit-mode .group:not(.collapsed) .header .gripper {
opacity: 1;
transform: translate(0, 0);
pointer-events: auto;
}
.group .header .toggle {
width: 5.34rem;
right: 0;
border: none;
margin: 0;
padding: 0;
background-color: transparent;
background-image: url('images/collapse_arrow.png');
transform-origin: 50% 1.6rem;
pointer-events: auto;
}
.group .header .toggle.toggling {
opacity: 0.5;
transition: transform 0.25s;
}
.group.collapsed .header .toggle {
transform: translateY(2rem) rotateZ(180deg);
}
.group.active .header .toggle {
opacity: 0.5;
}
.group .background {
background-color: rgba(255, 255, 255, 0.15);
opacity: 0;
pointer-events: none;
height: 0.1rem;
transform-origin: 0 0;
}
.group.odd:not(.newly-created) .background,
.edit-mode .group:not(.newly-created) .background {
opacity: 1;
}
.group.active .background {
background-color: rgba(51, 51, 51, 0.9);
}
.group.hovering .background,
.group.drop-target .background {
background-color: rgba(27, 185, 225, 0.5);
}
.group .shadow {
background-color: rgba(255, 255, 255, 0.15);
opacity: 0;
pointer-events: none;
}
.edit-mode .group.active .shadow {
background-color: transparent;
box-shadow: 0 0.9rem 1.2rem rgba(0, 0, 0, 0.63);
opacity: 1;
}
.group .separator {
margin: 0;
height: 0.8rem;
pointer-events: none;
}
.group .separator > span {
display: block;
width: 100%;
height: 100%;
transition: transform 0.25s, opacity 0.25s;
opacity: 0;
}
.group.odd .separator {
opacity: 1;
}
.group.odd .separator > span {
background-color: rgba(255, 255, 255, 0.15);
opacity: 1;
}
.edit-mode .group .separator,
.edit-mode .group .separator > span {
opacity: 0;
}
/* For preventing entering edit mode when long-pressing on a placeholder */
.edit-mode .group .background,
.edit-mode .group .shadow,
.group.collapsed .header,
.group.collapsed .background,
.group.collapsed .shadow {
pointer-events: none;
}
.edit-mode .group.collapsed .background {
pointer-events: auto;
}
.dragging .group:not(.active) .separator {
opacity: 1;
}
.dragging .group:not(.active):not(.invalid-drop) .separator > span {
background-color: #0095dd;
transform: scaleY(0.325);
opacity: 0.4;
}
.dragging .group.hovered:not(.active) .separator > span {
transform: scaleY(1);
opacity: 1;
}
/* A hint element for dragging items to the top of the group */
.edit-mode::after {
content: ' ';
display: block;
width: 100%;
height: 0.8rem;
z-index: 2;
opacity: 0;
background-color: #0095dd;
position: relative;
top: -0.8rem;
transition: opacity 0.25s;
}
.edit-mode.hover-over-top::after {
opacity: 1;
}
/* Shift container downwards when hovering over the top to reveal the group
* creation hint.
*/
.edit-mode {
transition: transform 0.25s;
}
.edit-mode.hover-over-top {
transform: translateY(0.8rem);
}
/* RTL View */
:-moz-dir(rtl) .group .header .toggle {
left: 0;
right: unset;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

View file

@ -0,0 +1,16 @@
;window.GaiaPinCard=(function(win){'use strict';var proto=Object.create(HTMLElement.prototype);var DEFAULT_COLOR=[77,77,77];var baseurl=window.GaiaPinCardBaseurl||'/shared/elements/gaia_pin_card/';proto.createdCallback=function(test){var shadow=this.createShadowRoot();this._template=template.content.cloneNode(true);this.container=this._template.querySelector('.pin-card');this.bgElement=this._template.querySelector('.background');this.descElement=this._template.querySelector('.description');this.titleElement=this._template.querySelector('header');this.iconElement=this._template.querySelector('i');this._background={};shadow.appendChild(this._template);ComponentUtils.style.call(this,baseurl);};Object.defineProperty(proto,'background',{get:function(){return this._background;},set:function(background){var bgSrc=background.src?'url('+background.src+')':'';this._background=background;this.bgElement.style.backgroundImage=bgSrc;this.bgElement.style.backgroundColor=background.themeColor||'#4d4d4d';var opacity=background.themeColor?'0.30':'0.15';var computedStyle=window.getComputedStyle(this.bgElement);var colorCodes;try{colorCodes=getColorCodes(computedStyle.backgroundColor);}catch(error){colorCodes=DEFAULT_COLOR;opacity=0.15;}
var rgbaColor=colorCodes.slice(1).join(',')+', '+opacity;var bgColorRgba='rgba('+rgbaColor+')';var computedWidth=computedStyle.width;var width=computedWidth==='auto'?'140px':computedWidth;var shadow='inset 0 0 0 '+width;this.bgElement.style.boxShadow=shadow+' '+bgColorRgba;}});Object.defineProperty(proto,'meta',{get:function(){return this._meta;},set:function(meta){this._meta=meta;this.renderCard();}});Object.defineProperty(proto,'title',{get:function(){return this.titleElement.textContent;},set:function(title){this.titleElement.textContent=title;}});Object.defineProperty(proto,'icon',{get:function(){return this.iconElement.style.backgroundImage;},set:function(icon){this.iconElement.style.backgroundImage=icon;}});proto.renderCard=function(){var description=getDescription(this._meta);if(description){this.descElement.appendChild(description);this.container.classList.remove('no-content');}
var background=getBackgroundBlob(this._meta);if(background){this.background={src:URL.createObjectURL(background),themeColor:this._meta['theme-color']||null};}};var template=document.createElement('template');template.innerHTML=`<article class="pin-card no-content">
<div class="icon-container">
<i></i>
</div>
<div class="background"></div>
<div class="content">
<header></header>
<section class="description"></section>
</div>
</article>`;function getColorCodes(color){var colorCodes=/rgb\((\d+), (\d+), (\d+)\)/.exec(color);return colorCodes;}
function getDescription(meta){if(!meta['og:description']){return false;}
var desc=document.createElement('p');desc.textContent=meta['og:description'];return desc;}
function getBackgroundBlob(meta){return meta['og:image']||meta.screenshot;}
return document.registerElement('gaia-pin-card',{prototype:proto});})(window);

View file

@ -0,0 +1,99 @@
.pin-card {
position: relative;
box-sizing: border-box;
background-color: #fff;
background-size: cover;
color: #000;
font-weight: 500;
font-family: sans-serif;
line-height: normal;
white-space: normal;
font-size: 1.2rem;
width: 100%;
height: 100%;
overflow: hidden;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
}
.pin-card > * {
pointer-events: none;
}
.pin-card .background {
position: relative;
width: 100%;
height: 47%;
opacity: 0.7;
top: 0;
left: 0;
z-index: 0;
background-size: cover;
}
.pin-card i {
position: absolute;
top: .15rem;
left: .15rem;
width: 3.03rem;
height: 3.03rem;
background: url(images/default_icon.png);
background-size: 3.5rem 3.5rem;
background-position: center center;
border-radius: 50%;
}
.icon-container {
position: absolute;
top: 4.8rem;
display: block;
overflow: hidden;
left: calc(50% - 1.65rem);
width: 3.33rem;
height: 3.33rem;
border-radius: 50%;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
background-color: white;
z-index: 1;
}
.pin-card .content {
position: relative;
height: 53%;
width: 100%;
}
.pin-card header {
position: relative;
height: 1.6rem;
color: #5f5f5f;
font-size: 1.4rem;
font-style: italic;
padding: 0 0.8rem;
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
top: 1.5rem;
}
.pin-card.no-content header {
top: 2.5rem;
}
.pin-card.no-content .description {
display: none;
}
.pin-card .description p {
position: relative;
display: block;
overflow: hidden;
text-align: left;
height: 3rem;
line-height: 1.4rem;
font-size: 1.2rem;
font-weight: 300;
color: #5f5f5f;
padding: 0 0.8rem;
margin: 2rem 0;
}

View file

@ -0,0 +1,2 @@
;(function(exports){'use strict';exports.ComponentUtils={style:function(baseUrl){if(window.VulpesCompat){window.VulpesCompat.componentStyle(this,baseUrl).catch(console.error);return;}var style=document.createElement('style');var url=baseUrl+'style.css';var self=this;style.setAttribute('scoped','');style.innerHTML='@import url('+url+');';this.appendChild(style);this.style.visibility='hidden';style.addEventListener('load',function(){if(self.shadowRoot){self.shadowRoot.appendChild(style.cloneNode(true));}
self.style.visibility='';});}};}(window));

View file

@ -0,0 +1,5 @@
;'use strict';(function(exports){const FETCH_XHR_TIMEOUT=10000;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;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));};});}
function Icon(elem,uri){this.elem=elem;this.uri=uri;}
Icon.prototype={get size(){return this._size||40;},set size(size){this._size=size||this.size;var style=this.elem.style;var sizeInRems=(this._size/10)+'rem';style.backgroundSize=style.width=style.height=sizeInRems;},render:function render(options){options=options||{};var uri=this.uri;this.size=options.size;if(!uri){return;}
fetchBlob(uri).then(function(blob){this.renderBlob(blob,options);}.bind(this));},renderBlob:function renderBlob(blob,options){options=options||{};var style=this.elem.style;this.size=options.size;var img=new Image();img.src=URL.createObjectURL(blob);var size=this.size;img.onload=()=>{var renderer=new GridIconRenderer({grid:{layout:{get gridIconSize(){return size;},get gridMaxIconSize(){return size*devicePixelRatio;}}}});var type=options.type||GridIconRenderer.TYPE.FAVICON;renderer[type](img).then((blob)=>{var url=URL.createObjectURL(blob);var imageUrl='url('+url+')';style.backgroundImage=imageUrl;if(options.onLoad){options.onLoad(blob);}});URL.revokeObjectURL(img.src);};img.onerror=()=>{if(options.onerror){options.onerror();}};}};exports.Icon=Icon;}(window));

View file

@ -0,0 +1,32 @@
;'use strict';(function IconsHelper(exports){const ICON_CACHE_PERIOD=24*60*60*1000;const FETCH_XHR_TIMEOUT=10000;const DEBUG=false;var dataStore=null;function getDefaultIconSize(){var dpr=window.devicePixelRatio;return(dpr&&dpr>1)?142:84;}
function sizeIsNearer(size1,size2,targetSize){var delta1=Math.abs(targetSize-size1);var delta2=Math.abs(targetSize-size2);return(delta1<=delta2);}
function getIcon(uri,iconTargetSize,placeObj={},siteObj={}){var iconUrl=null;iconTargetSize=iconTargetSize*window.devicePixelRatio;if(siteObj.webManifestUrl&&siteObj.webManifest){iconUrl=getBestIconFromWebManifest(siteObj.webManifest,iconTargetSize);if(DEBUG&&iconUrl){console.log('Icon from Web Manifest');}}
if(!iconUrl&&siteObj.manifest&&siteObj.manifest.icons){iconUrl=getBestIconFromWebManifest({icons:_convertToWebManifestIcons(siteObj.manifest,siteObj.origin||siteObj.manifest.origin)},iconTargetSize);if(DEBUG&&iconUrl){console.log('Icon from Firefox App Manifest');}}
if(!iconUrl&&placeObj&&placeObj.icons){iconUrl=getBestIconFromMetaTags(placeObj.icons,iconTargetSize);if(DEBUG&&iconUrl){console.log('Icon from Meta tags');}}
if(!iconUrl){var a=document.createElement('a');a.href=uri;iconUrl=a.origin+'/favicon.ico';if(iconTargetSize){iconUrl+='#-moz-resolution='+iconTargetSize+','+iconTargetSize;}
DEBUG&&console.log('Icon from favicon.ico');}
return new Promise(resolve=>{resolve(iconUrl);});}
function getIconBlob(uri,iconTargetSize,placeObj={},siteObj={}){return new Promise((resolve,reject)=>{getIcon(uri,iconTargetSize,placeObj,siteObj).then(iconUrl=>{getStore().then(iconStore=>{iconStore.get(iconUrl).then(iconObj=>{if(!iconObj||!iconObj.timestamp||Date.now()-iconObj.timestamp>=ICON_CACHE_PERIOD){return fetchIconBlob(iconUrl).then(iconBlob=>{var img=document.createElement('img');var icon=new Icon(img,uri);icon.renderBlob(iconBlob,{size:iconTargetSize,onLoad:function(blob){var iconObj={blob:blob,originalUrl:iconUrl.toString(),timestamp:Date.now()};resolve(iconObj);iconStore.add(iconObj,iconUrl);},onerror:function(e){reject(`Failed to fetch icon ${iconUrl}`);}});}).catch(err=>{reject(`Failed to fetch icon ${iconUrl}: ${err}`);});}
return resolve(iconObj);}).catch(err=>{reject(`Failed to get icon from dataStore: ${err}`);});}).catch(err=>{reject(`Error opening the dataStore: ${err}`);});});});}
function setElementIcon(icon,targetSize){return getIconBlob(icon.bookmark.url,targetSize,icon.bookmark,icon.bookmark).then(iconObj=>{if(iconObj.blob){icon.icon=iconObj.blob;return Promise.resolve();}else if(icon.bookmark.icon){return fetchIconBlob(icon.bookmark.icon).then(iconBlob=>{icon.icon=iconBlob;return Promise.resolve();},Promise.reject.bind(Promise));}
return Promise.reject('No icon data found');},Promise.reject.bind(Promise));}
function getBestIconFromWebManifest(webManifest,iconSize){var icons=webManifest.icons;if(!icons){return null;}
var maxSize=10000;var bestSize=maxSize;var iconURL=null;iconSize=iconSize||getDefaultIconSize();icons.forEach((potentialIcon)=>{if(!iconURL){iconURL=potentialIcon.src;}
var sizes=Array.from(potentialIcon.sizes);var nearestSize=getNearestSize(sizes,iconSize,bestSize);if(nearestSize!==bestSize&&sizeIsNearer(nearestSize,bestSize,iconSize)){iconURL=potentialIcon.src;bestSize=nearestSize;}});return iconURL?iconURL:null;}
function _convertToWebManifestIcons(manifest,origin){return Object.keys(manifest.icons).map(function(size){var url=manifest.icons[size];var sizes=[size+'x'+size];url=url.indexOf('http')>-1?url:origin+url;return{src:new URL(url),sizes:sizes};});}
function getBestIconFromMetaTags(icons,iconSize){if(!icons){return null;}
iconSize=iconSize||getDefaultIconSize();var iconURL=null;var bestSize=10000;Object.keys(icons).forEach((uri)=>{var potentialIcon=icons[uri];if(!iconURL){iconURL=uri;}
var sizes=Array.from(potentialIcon.sizes);var nearestSize=getNearestSize(sizes,iconSize,bestSize);if(nearestSize!==bestSize&&sizeIsNearer(nearestSize,bestSize,iconSize)){iconURL=uri;bestSize=nearestSize;}
if(potentialIcon.rel==='apple-touch-icon'||potentialIcon.rel==='apple-touch-icon-precomposed'){var moreInfoUrl='https://developer.mozilla.org/en-US/'+'Apps/Build/Icon_implementation_for_apps#General_icons_for_web_apps';console.warn('Warning: The apple-touch icons are being used '+'as a fallback only. They will be deprecated in '+'the future. See '+moreInfoUrl);}});return iconURL||null;}
function getNearestSize(sizes,iconSize,bestSize){var bogusSize=10000;if(!bestSize){bestSize=bogusSize;}
var nearestSize=sizes.reduce(function(nearestSize,sizeString,idx){var size=widthFromSizeString(sizeString);if(isNaN(size)){return nearestSize;}
if(sizeIsNearer(size,nearestSize,iconSize)){return size;}else{return nearestSize;}},bestSize);return nearestSize===bogusSize?-1:nearestSize;}
function widthFromSizeString(size){size=size||'';var xIndex=size.indexOf('x');if(!xIndex){return NaN;}
return parseInt(size.substr(0,xIndex));}
function getStore(){return new Promise(resolve=>{if(dataStore){return resolve(dataStore);}
navigator.getDataStores('icons').then(stores=>{dataStore=stores[0];return resolve(dataStore);});});}
function clear(){return getStore().then(iconStore=>{iconStore.clear();});}
function fetchIcon(iconUrl){return new Promise((resolve,reject)=>{fetchIconBlob(iconUrl).then((iconBlob)=>{var img=document.createElement('img');img.src=URL.createObjectURL(iconBlob);img.onload=()=>{var iconSize=Math.max(img.naturalWidth,img.naturalHeight);resolve({blob:iconBlob,url:iconUrl,size:iconSize,timestamp:Date.now()});};img.onerror=()=>{reject(new Error(`Error while loading image.`));};}).catch((e)=>{reject(new Error(`Error while loading image: ${e}`));});});}
function fetchIconBlob(iconUrl){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest({mozAnon:true,mozSystem:true});xhr.open('GET',iconUrl,true);xhr.responseType='blob';xhr.timeout=FETCH_XHR_TIMEOUT;xhr.send();xhr.onload=()=>{if(xhr.readyState===XMLHttpRequest.DONE&&xhr.status===200){var iconBlob=xhr.response;resolve(iconBlob);return;}
reject(new Error(`Got HTTP status ${xhr.status} trying to load ${iconUrl}.`));};xhr.onerror=xhr.ontimeout=()=>{reject(new Error(`Error while getting ${iconUrl}.`));};});}
exports.IconsHelper={getIcon:getIcon,getIconBlob:getIconBlob,setElementIcon:setElementIcon,getBestIconFromWebManifest:getBestIconFromWebManifest,getBestIconFromMetaTags:getBestIconFromMetaTags,fetchIcon:fetchIcon,fetchIconBlob:fetchIconBlob,get defaultIconSize(){return getDefaultIconSize();},clear:clear,getNearestSize:getNearestSize,};})(window);

View file

@ -0,0 +1,245 @@
;(function(window,undefined){'use strict';function L10nError(message,id,loc){this.name='L10nError';this.message=message;this.id=id;this.loc=loc;}
L10nError.prototype=Object.create(Error.prototype);L10nError.prototype.constructor=L10nError;var io={_load:function(type,url,callback,sync){var xhr=new XMLHttpRequest();var needParse;if(xhr.overrideMimeType){xhr.overrideMimeType(type);}
xhr.open('GET',url,!sync);if(type==='application/json'){if(sync){needParse=true;}else{xhr.responseType='json';}}
xhr.addEventListener('load',function io_onload(e){if(e.target.status===200||e.target.status===0){var res=e.target.response||e.target.responseText;callback(null,needParse?JSON.parse(res):res);}else{callback(new L10nError('Not found: '+url));}});xhr.addEventListener('error',callback);xhr.addEventListener('timeout',callback);try{xhr.send(null);}catch(e){if(e.name==='NS_ERROR_FILE_NOT_FOUND'){callback(new L10nError('Not found: '+url));}else{throw e;}}},load:function(url,callback,sync){return io._load('text/plain',url,callback,sync);},loadJSON:function(url,callback,sync){return io._load('application/json',url,callback,sync);}};function EventEmitter(){}
EventEmitter.prototype.emit=function ee_emit(){if(!this._listeners){return;}
var args=Array.prototype.slice.call(arguments);var type=args.shift();if(!this._listeners[type]){return;}
var typeListeners=this._listeners[type].slice();for(var i=0;i<typeListeners.length;i++){typeListeners[i].apply(this,args);}};EventEmitter.prototype.addEventListener=function ee_add(type,listener){if(!this._listeners){this._listeners={};}
if(!(type in this._listeners)){this._listeners[type]=[];}
this._listeners[type].push(listener);};EventEmitter.prototype.removeEventListener=function ee_rm(type,listener){if(!this._listeners){return;}
var typeListeners=this._listeners[type];var pos=typeListeners.indexOf(listener);if(pos===-1){return;}
typeListeners.splice(pos,1);};function getPluralRule(lang){var locales2rules={'af':3,'ak':4,'am':4,'ar':1,'asa':3,'az':0,'be':11,'bem':3,'bez':3,'bg':3,'bh':4,'bm':0,'bn':3,'bo':0,'br':20,'brx':3,'bs':11,'ca':3,'cgg':3,'chr':3,'cs':12,'cy':17,'da':3,'de':3,'dv':3,'dz':0,'ee':3,'el':3,'en':3,'eo':3,'es':3,'et':3,'eu':3,'fa':0,'ff':5,'fi':3,'fil':4,'fo':3,'fr':5,'fur':3,'fy':3,'ga':8,'gd':24,'gl':3,'gsw':3,'gu':3,'guw':4,'gv':23,'ha':3,'haw':3,'he':2,'hi':4,'hr':11,'hu':0,'id':0,'ig':0,'ii':0,'is':3,'it':3,'iu':7,'ja':0,'jmc':3,'jv':0,'ka':0,'kab':5,'kaj':3,'kcg':3,'kde':0,'kea':0,'kk':3,'kl':3,'km':0,'kn':0,'ko':0,'ksb':3,'ksh':21,'ku':3,'kw':7,'lag':18,'lb':3,'lg':3,'ln':4,'lo':0,'lt':10,'lv':6,'mas':3,'mg':4,'mk':16,'ml':3,'mn':3,'mo':9,'mr':3,'ms':0,'mt':15,'my':0,'nah':3,'naq':7,'nb':3,'nd':3,'ne':3,'nl':3,'nn':3,'no':3,'nr':3,'nso':4,'ny':3,'nyn':3,'om':3,'or':3,'pa':3,'pap':3,'pl':13,'ps':3,'pt':3,'rm':3,'ro':9,'rof':3,'ru':11,'rwk':3,'sah':0,'saq':3,'se':7,'seh':3,'ses':0,'sg':0,'sh':11,'shi':19,'sk':12,'sl':14,'sma':7,'smi':7,'smj':7,'smn':7,'sms':7,'sn':3,'so':3,'sq':3,'sr':11,'ss':3,'ssy':3,'st':3,'sv':3,'sw':3,'syr':3,'ta':3,'te':3,'teo':3,'th':0,'ti':4,'tig':3,'tk':3,'tl':4,'tn':3,'to':0,'tr':0,'ts':3,'tzm':22,'uk':11,'ur':3,'ve':3,'vi':0,'vun':3,'wa':4,'wae':3,'wo':0,'xh':3,'xog':3,'yo':0,'zh':0,'zu':3};function isIn(n,list){return list.indexOf(n)!==-1;}
function isBetween(n,start,end){return typeof n===typeof start&&start<=n&&n<=end;}
var pluralRules={'0':function(){return'other';},'1':function(n){if((isBetween((n%100),3,10))){return'few';}
if(n===0){return'zero';}
if((isBetween((n%100),11,99))){return'many';}
if(n===2){return'two';}
if(n===1){return'one';}
return'other';},'2':function(n){if(n!==0&&(n%10)===0){return'many';}
if(n===2){return'two';}
if(n===1){return'one';}
return'other';},'3':function(n){if(n===1){return'one';}
return'other';},'4':function(n){if((isBetween(n,0,1))){return'one';}
return'other';},'5':function(n){if((isBetween(n,0,2))&&n!==2){return'one';}
return'other';},'6':function(n){if(n===0){return'zero';}
if((n%10)===1&&(n%100)!==11){return'one';}
return'other';},'7':function(n){if(n===2){return'two';}
if(n===1){return'one';}
return'other';},'8':function(n){if((isBetween(n,3,6))){return'few';}
if((isBetween(n,7,10))){return'many';}
if(n===2){return'two';}
if(n===1){return'one';}
return'other';},'9':function(n){if(n===0||n!==1&&(isBetween((n%100),1,19))){return'few';}
if(n===1){return'one';}
return'other';},'10':function(n){if((isBetween((n%10),2,9))&&!(isBetween((n%100),11,19))){return'few';}
if((n%10)===1&&!(isBetween((n%100),11,19))){return'one';}
return'other';},'11':function(n){if((isBetween((n%10),2,4))&&!(isBetween((n%100),12,14))){return'few';}
if((n%10)===0||(isBetween((n%10),5,9))||(isBetween((n%100),11,14))){return'many';}
if((n%10)===1&&(n%100)!==11){return'one';}
return'other';},'12':function(n){if((isBetween(n,2,4))){return'few';}
if(n===1){return'one';}
return'other';},'13':function(n){if((isBetween((n%10),2,4))&&!(isBetween((n%100),12,14))){return'few';}
if(n!==1&&(isBetween((n%10),0,1))||(isBetween((n%10),5,9))||(isBetween((n%100),12,14))){return'many';}
if(n===1){return'one';}
return'other';},'14':function(n){if((isBetween((n%100),3,4))){return'few';}
if((n%100)===2){return'two';}
if((n%100)===1){return'one';}
return'other';},'15':function(n){if(n===0||(isBetween((n%100),2,10))){return'few';}
if((isBetween((n%100),11,19))){return'many';}
if(n===1){return'one';}
return'other';},'16':function(n){if((n%10)===1&&n!==11){return'one';}
return'other';},'17':function(n){if(n===3){return'few';}
if(n===0){return'zero';}
if(n===6){return'many';}
if(n===2){return'two';}
if(n===1){return'one';}
return'other';},'18':function(n){if(n===0){return'zero';}
if((isBetween(n,0,2))&&n!==0&&n!==2){return'one';}
return'other';},'19':function(n){if((isBetween(n,2,10))){return'few';}
if((isBetween(n,0,1))){return'one';}
return'other';},'20':function(n){if((isBetween((n%10),3,4)||((n%10)===9))&&!(isBetween((n%100),10,19)||isBetween((n%100),70,79)||isBetween((n%100),90,99))){return'few';}
if((n%1000000)===0&&n!==0){return'many';}
if((n%10)===2&&!isIn((n%100),[12,72,92])){return'two';}
if((n%10)===1&&!isIn((n%100),[11,71,91])){return'one';}
return'other';},'21':function(n){if(n===0){return'zero';}
if(n===1){return'one';}
return'other';},'22':function(n){if((isBetween(n,0,1))||(isBetween(n,11,99))){return'one';}
return'other';},'23':function(n){if((isBetween((n%10),1,2))||(n%20)===0){return'one';}
return'other';},'24':function(n){if((isBetween(n,3,10)||isBetween(n,13,19))){return'few';}
if(isIn(n,[2,12])){return'two';}
if(isIn(n,[1,11])){return'one';}
return'other';}};var index=locales2rules[lang.replace(/-.*$/,'')];if(!(index in pluralRules)){return function(){return'other';};}
return pluralRules[index];}
var MAX_PLACEABLES=100;var PropertiesParser={patterns:null,entryIds:null,init:function(){this.patterns={comment:/^\s*#|^\s*$/,entity:/^([^=\s]+)\s*=\s*(.*)$/,multiline:/[^\\]\\$/,index:/\{\[\s*(\w+)(?:\(([^\)]*)\))?\s*\]\}/i,unicode:/\\u([0-9a-fA-F]{1,4})/g,entries:/[^\r\n]+/g,controlChars:/\\([\\\n\r\t\b\f\{\}\"\'])/g,placeables:/\{\{\s*([^\s]*?)\s*\}\}/,};},parse:function(ctx,source){if(!this.patterns){this.init();}
var ast=[];this.entryIds=Object.create(null);var entries=source.match(this.patterns.entries);if(!entries){return ast;}
for(var i=0;i<entries.length;i++){var line=entries[i];if(this.patterns.comment.test(line)){continue;}
while(this.patterns.multiline.test(line)&&i<entries.length){line=line.slice(0,-1)+entries[++i].trim();}
var entityMatch=line.match(this.patterns.entity);if(entityMatch){try{this.parseEntity(entityMatch[1],entityMatch[2],ast);}catch(e){if(ctx){ctx._emitter.emit('parseerror',e);}else{throw e;}}}}
return ast;},parseEntity:function(id,value,ast){var name,key;var pos=id.indexOf('[');if(pos!==-1){name=id.substr(0,pos);key=id.substring(pos+1,id.length-1);}else{name=id;key=null;}
var nameElements=name.split('.');if(nameElements.length>2){throw new L10nError('Error in ID: "'+name+'".'+' Nested attributes are not supported.');}
var attr;if(nameElements.length>1){name=nameElements[0];attr=nameElements[1];if(attr[0]==='$'){throw new L10nError('Attribute can\'t start with "$"',id);}}else{attr=null;}
this.setEntityValue(name,attr,key,this.unescapeString(value),ast);},setEntityValue:function(id,attr,key,rawValue,ast){var pos,v;var value=rawValue.indexOf('{{')>-1?this.parseString(rawValue):rawValue;if(attr){pos=this.entryIds[id];if(pos===undefined){v={$i:id};if(key){v[attr]={};v[attr][key]=value;}else{v[attr]=value;}
ast.push(v);this.entryIds[id]=ast.length-1;return;}
if(key){if(typeof(ast[pos][attr])==='string'){ast[pos][attr]={$x:this.parseIndex(ast[pos][attr]),$v:{}};}
ast[pos][attr].$v[key]=value;return;}
ast[pos][attr]=value;return;}
if(key){pos=this.entryIds[id];if(pos===undefined){v={};v[key]=value;ast.push({$i:id,$v:v});this.entryIds[id]=ast.length-1;return;}
if(typeof(ast[pos].$v)==='string'){ast[pos].$x=this.parseIndex(ast[pos].$v);ast[pos].$v={};}
ast[pos].$v[key]=value;return;}
ast.push({$i:id,$v:value});this.entryIds[id]=ast.length-1;},parseString:function(str){var chunks=str.split(this.patterns.placeables);var complexStr=[];var len=chunks.length;var placeablesCount=(len-1)/2;if(placeablesCount>=MAX_PLACEABLES){throw new L10nError('Too many placeables ('+placeablesCount+', max allowed is '+MAX_PLACEABLES+')');}
for(var i=0;i<chunks.length;i++){if(chunks[i].length===0){continue;}
if(i%2===1){complexStr.push({t:'idOrVar',v:chunks[i]});}else{complexStr.push(chunks[i]);}}
return complexStr;},unescapeString:function(str){if(str.lastIndexOf('\\')!==-1){str=str.replace(this.patterns.controlChars,'$1');}
return str.replace(this.patterns.unicode,function(match,token){return unescape('%u'+'0000'.slice(token.length)+token);});},parseIndex:function(str){var match=str.match(this.patterns.index);if(!match){throw new L10nError('Malformed index');}
if(match[2]){return[{t:'idOrVar',v:match[1]},match[2]];}else{return[{t:'idOrVar',v:match[1]}];}}};var KNOWN_MACROS=['plural'];var MAX_PLACEABLE_LENGTH=2500;var rePlaceables=/\{\{\s*(.+?)\s*\}\}/g;var nonLatin1=/[^\x01-\xFF]/;var FSI='\u2068';var PDI='\u2069';function createEntry(node,env){var keys=Object.keys(node);if(typeof node.$v==='string'&&keys.length===2){return node.$v;}
var attrs;for(var i=0,key;key=keys[i];i++){if(key[0]==='$'){continue;}
if(!attrs){attrs=Object.create(null);}
attrs[key]=createAttribute(node[key],env,node.$i+'.'+key);}
return{id:node.$i,value:node.$v!==undefined?node.$v:null,index:node.$x||null,attrs:attrs||null,env:env,dirty:false};}
function createAttribute(node,env,id){if(typeof node==='string'){return node;}
return{id:id,value:node.$v||(node!==undefined?node:null),index:node.$x||null,env:env,dirty:false};}
function format(args,entity){if(typeof entity==='string'){return[{},entity];}
if(entity.dirty){throw new L10nError('Cyclic reference detected: '+entity.id);}
entity.dirty=true;var rv;try{rv=resolveValue({},args,entity.env,entity.value,entity.index);}finally{entity.dirty=false;}
return rv;}
function resolveIdentifier(args,env,id){if(KNOWN_MACROS.indexOf(id)>-1){return[{},env['__'+id]];}
if(args&&args.hasOwnProperty(id)){if(typeof args[id]==='string'||(typeof args[id]==='number'&&!isNaN(args[id]))){return[{},args[id]];}else{throw new L10nError('Arg must be a string or a number: '+id);}}
if(id in env&&id!=='__proto__'){return format(args,env[id]);}
throw new L10nError('Unknown reference: '+id);}
function subPlaceable(locals,args,env,id){var res;try{res=resolveIdentifier(args,env,id);}catch(err){return[{error:err},'{{ '+id+' }}'];}
var value=res[1];if(typeof value==='number'){return res;}
if(typeof value==='string'){if(value.length>=MAX_PLACEABLE_LENGTH){throw new L10nError('Too many characters in placeable ('+
value.length+', max allowed is '+
MAX_PLACEABLE_LENGTH+')');}
if(locals.contextIsNonLatin1||value.match(nonLatin1)){res[1]=FSI+value+PDI;}
return res;}
return[{},'{{ '+id+' }}'];}
function interpolate(locals,args,env,arr){return arr.reduce(function(prev,cur){if(typeof cur==='string'){return[prev[0],prev[1]+cur];}else if(cur.t==='idOrVar'){var placeable=subPlaceable(locals,args,env,cur.v);return[prev[0],prev[1]+placeable[1]];}},[locals,'']);}
function resolveSelector(args,env,expr,index){var selectorName=index[0].v;var selector=resolveIdentifier(args,env,selectorName)[1];if(typeof selector!=='function'){return selector;}
var argValue=index[1]?resolveIdentifier(args,env,index[1])[1]:undefined;if(selector===env.__plural){if(argValue===0&&'zero'in expr){return'zero';}
if(argValue===1&&'one'in expr){return'one';}
if(argValue===2&&'two'in expr){return'two';}}
return selector(argValue);}
function resolveValue(locals,args,env,expr,index){if(!expr){return[locals,expr];}
if(typeof expr==='string'||typeof expr==='boolean'||typeof expr==='number'){return[locals,expr];}
if(Array.isArray(expr)){locals.contextIsNonLatin1=expr.some(function($_){return typeof($_)==='string'&&$_.match(nonLatin1);});return interpolate(locals,args,env,expr);}
if(index){var selector=resolveSelector(args,env,expr,index);if(expr.hasOwnProperty(selector)){return resolveValue(locals,args,env,expr[selector]);}}
if('other'in expr){return resolveValue(locals,args,env,expr.other);}
throw new L10nError('Unresolvable value');}
var Resolver={createEntry:createEntry,format:format,rePlaceables:rePlaceables};function walkContent(node,fn){if(typeof node==='string'){return fn(node);}
if(node.t==='idOrVar'){return node;}
var rv=Array.isArray(node)?[]:{};var keys=Object.keys(node);for(var i=0,key;(key=keys[i]);i++){if(key==='$i'||key==='$x'){rv[key]=node[key];}else{rv[key]=walkContent(node[key],fn);}}
return rv;}
var reAlphas=/[a-zA-Z]/g;var reVowels=/[aeiouAEIOU]/g;var ACCENTED_MAP='\u0226\u0181\u0187\u1E12\u1E16\u0191\u0193\u0126\u012A'+'\u0134\u0136\u013F\u1E3E\u0220\u01FE\u01A4\u024A\u0158'+'\u015E\u0166\u016C\u1E7C\u1E86\u1E8A\u1E8E\u1E90'+'[\\]^_`'+'\u0227\u0180\u0188\u1E13\u1E17\u0192\u0260\u0127\u012B'+'\u0135\u0137\u0140\u1E3F\u019E\u01FF\u01A5\u024B\u0159'+'\u015F\u0167\u016D\u1E7D\u1E87\u1E8B\u1E8F\u1E91';var FLIPPED_MAP='\u2200\u0510\u2183p\u018E\u025F\u05E4HI\u017F'+'\u04FC\u02E5WNO\u0500\xD2\u1D1AS\u22A5\u2229\u0245'+'\uFF2DX\u028EZ'+'[\\]\u1D65_,'+'\u0250q\u0254p\u01DD\u025F\u0183\u0265\u0131\u027E'+'\u029E\u0285\u026Fuodb\u0279s\u0287n\u028C\u028Dx\u028Ez';function makeLonger(val){return val.replace(reVowels,function(match){return match+match.toLowerCase();});}
function replaceChars(map,val){return val.replace(reAlphas,function(match){return map.charAt(match.charCodeAt(0)-65);});}
var reWords=/[^\W0-9_]+/g;function makeRTL(val){return val.replace(reWords,function(match){return'\u202e'+match+'\u202c';});}
var reExcluded=/(%[EO]?\w|\{\s*.+?\s*\}|&[#\w]+;|<\s*.+?\s*>)/;function mapContent(fn,val){if(!val){return val;}
var parts=val.split(reExcluded);var modified=parts.map(function(part){if(reExcluded.test(part)){return part;}
return fn(part);});return modified.join('');}
function Pseudo(id,name,charMap,modFn){this.id=id;this.translate=mapContent.bind(null,function(val){return replaceChars(charMap,modFn(val));});this.name=this.translate(name);}
var PSEUDO={'fr-x-psaccent':new Pseudo('fr-x-psaccent','Runtime Accented',ACCENTED_MAP,makeLonger),'ar-x-psbidi':new Pseudo('ar-x-psbidi','Runtime Bidi',FLIPPED_MAP,makeRTL)};function Locale(id,ctx){this.id=id;this.ctx=ctx;this.isReady=false;this.entries=Object.create(null);this.entries.__plural=getPluralRule(this.isPseudo()?this.ctx.defaultLocale:id);}
Locale.prototype.isPseudo=function(){return this.ctx.qps.indexOf(this.id)!==-1;};var bindingsIO={extra:function(id,ver,path,type,callback,errback){if(type==='properties'){type='text';}
navigator.mozApps.getLocalizationResource(id,ver,path,type).then(callback.bind(null,null),errback);},app:function(id,ver,path,type,callback,errback,sync){switch(type){case'properties':io.load(path,callback,sync);break;case'json':io.loadJSON(path,callback,sync);break;}},};Locale.prototype.build=function L_build(callback){var sync=!callback;var ctx=this.ctx;var self=this;var l10nLoads=ctx.resLinks.length;function onL10nLoaded(err){if(err){ctx._emitter.emit('fetcherror',err);}
if(--l10nLoads<=0){self.isReady=true;if(callback){callback();}}}
if(l10nLoads===0){onL10nLoaded();return;}
function onJSONLoaded(err,json){if(!err&&json){self.addAST(json);}
onL10nLoaded(err);}
function onPropLoaded(err,source){if(!err&&source){var ast=PropertiesParser.parse(ctx,source);self.addAST(ast);}
onL10nLoaded(err);}
var idToFetch=this.isPseudo()?ctx.defaultLocale:this.id;var appVersion=null;var source='app';if(typeof(navigator)!=='undefined'){source=navigator.mozL10n._config.localeSources[this.id]||'app';appVersion=navigator.mozL10n._config.appVersion;}
for(var i=0;i<ctx.resLinks.length;i++){var resLink=decodeURI(ctx.resLinks[i]);var path=resLink.replace('{locale}',idToFetch);var type=path.substr(path.lastIndexOf('.')+1);var cb;switch(type){case'json':cb=onJSONLoaded;break;case'properties':cb=onPropLoaded;break;}
bindingsIO[source](this.id,appVersion,path,type,cb,onL10nLoaded,sync);}};function createPseudoEntry(node,entries){return Resolver.createEntry(walkContent(node,PSEUDO[this.id].translate),entries);}
Locale.prototype.addAST=function(ast){var createEntry=this.isPseudo()?createPseudoEntry.bind(this):Resolver.createEntry;for(var i=0;i<ast.length;i++){this.entries[ast[i].$i]=createEntry(ast[i],this.entries);}};function Context(id){this.id=id;this.isReady=false;this.isLoading=false;this.defaultLocale='en-US';this.availableLocales=[];this.supportedLocales=[];this.qps=[];this.resLinks=[];this.locales={};this._emitter=new EventEmitter();this._ready=new Promise(this.once.bind(this));}
function reportMissing(id,err){this._emitter.emit('notfounderror',err);return id;}
function getWithFallback(id){var cur=0;var loc;var locale;while(loc=this.supportedLocales[cur]){locale=this.getLocale(loc);if(!locale.isReady){locale.build(null);}
var entry=locale.entries[id];if(entry===undefined){cur++;reportMissing.call(this,id,new L10nError('"'+id+'"'+' not found in '+loc+' in '+this.id,id,loc));continue;}
return entry;}
throw new L10nError('"'+id+'"'+' missing from all supported locales in '+this.id,id);}
function formatTuple(args,entity){try{return Resolver.format(args,entity);}catch(err){this._emitter.emit('resolveerror',err);var locals={error:err};return[locals,entity.id];}}
function formatValue(args,entity){if(typeof entity==='string'){return entity;}
return formatTuple.call(this,args,entity)[1];}
function formatEntity(args,entity){var entityTuple=formatTuple.call(this,args,entity);var value=entityTuple[1];var formatted={value:value,attrs:null,};if(entity.attrs){formatted.attrs=Object.create(null);}
for(var key in entity.attrs){var attrTuple=formatTuple.call(this,args,entity.attrs[key]);formatted.attrs[key]=attrTuple[1];}
return formatted;}
function formatAsync(fn,id,args){return this._ready.then(getWithFallback.bind(this,id)).then(fn.bind(this,args),reportMissing.bind(this,id));}
Context.prototype.formatValue=function(id,args){return formatAsync.call(this,formatValue,id,args);};Context.prototype.formatEntity=function(id,args){return formatAsync.call(this,formatEntity,id,args);};function legacyGet(fn,id,args){if(!this.isReady){throw new L10nError('Context not ready');}
var entry;try{entry=getWithFallback.call(this,id);}catch(err){if(err.loc){throw err;}
reportMissing.call(this,id,err);return'';}
return fn.call(this,args,entry);}
Context.prototype.get=function(id,args){return legacyGet.call(this,formatValue,id,args);};Context.prototype.getEntity=function(id,args){return legacyGet.call(this,formatEntity,id,args);};Context.prototype.getLocale=function getLocale(code){var locales=this.locales;if(locales[code]){return locales[code];}
return locales[code]=new Locale(code,this);};function negotiate(available,requested,defaultLocale){var supportedLocale;for(var i=0;i<requested.length;i++){var locale=requested[i];if(available.indexOf(locale)!==-1){supportedLocale=locale;break;}}
if(!supportedLocale||supportedLocale===defaultLocale){return[defaultLocale];}
return[supportedLocale,defaultLocale];}
function freeze(supported){var locale=this.getLocale(supported[0]);if(locale.isReady){setReady.call(this,supported);}else{locale.build(setReady.bind(this,supported));}}
function setReady(supported){this.supportedLocales=supported;this.isReady=true;this._emitter.emit('ready');}
Context.prototype.registerLocales=function(defLocale,available){if(defLocale){this.defaultLocale=defLocale;}
this.availableLocales=[this.defaultLocale];this.qps=Object.keys(PSEUDO);if(available){for(var i=0,loc;loc=available[i];i++){if(this.availableLocales.indexOf(loc)===-1){this.availableLocales.push(loc);var pos=this.qps.indexOf(loc);if(pos!==-1){this.qps.splice(pos,1);}}}}};Context.prototype.requestLocales=function requestLocales(){if(this.isLoading&&!this.isReady){throw new L10nError('Context not ready');}
this.isLoading=true;var requested=Array.prototype.slice.call(arguments);if(requested.length===0){throw new L10nError('No locales requested');}
var supported=negotiate(this.availableLocales.concat(this.qps),requested,this.defaultLocale);if(this.supportedLocales[0]!==supported[0]){freeze.call(this,supported);}};Context.prototype.addEventListener=function(type,listener){this._emitter.addEventListener(type,listener);};Context.prototype.removeEventListener=function(type,listener){this._emitter.removeEventListener(type,listener);};Context.prototype.ready=function(callback){if(this.isReady){setTimeout(callback);}
this.addEventListener('ready',callback);};Context.prototype.once=function(callback){if(this.isReady){setTimeout(callback);return;}
var callAndRemove=(function(){this.removeEventListener('ready',callAndRemove);callback();}).bind(this);this.addEventListener('ready',callAndRemove);};var allowed={elements:['a','em','strong','small','s','cite','q','dfn','abbr','data','time','code','var','samp','kbd','sub','sup','i','b','u','mark','ruby','rt','rp','bdi','bdo','span','br','wbr'],attributes:{global:['title','aria-label','aria-valuetext','aria-moz-hint'],a:['download'],area:['download','alt'],input:['alt','placeholder'],menuitem:['label'],menu:['label'],optgroup:['label'],option:['label'],track:['label'],img:['alt'],textarea:['placeholder'],th:['abbr']}};var rtlList=['ar','he','fa','ps','ar-x-psbidi','ur'];var nodeObserver=null;var pendingElements=null;var moConfig={attributes:true,characterData:false,childList:true,subtree:true,attributeFilter:['data-l10n-id','data-l10n-args']};navigator.mozL10n={ctx:null,get:function get(id,ctxdata){return navigator.mozL10n.ctx.get(id,ctxdata);},formatValue:function(id,ctxdata){return navigator.mozL10n.ctx.formatValue(id,ctxdata);},formatEntity:function(id,ctxdata){return navigator.mozL10n.ctx.formatEntity(id,ctxdata);},translateFragment:function(fragment){return translateFragment.call(navigator.mozL10n,fragment);},setAttributes:setL10nAttributes,getAttributes:getL10nAttributes,ready:function ready(callback){return navigator.mozL10n.ctx.ready(callback);},once:function once(callback){return navigator.mozL10n.ctx.once(callback);},get readyState(){return navigator.mozL10n.ctx.isReady?'complete':'loading';},language:{set code(lang){navigator.mozL10n.ctx.requestLocales(lang);},get code(){return navigator.mozL10n.ctx.supportedLocales[0];},get direction(){return getDirection(navigator.mozL10n.ctx.supportedLocales[0]);}},qps:PSEUDO,_config:{appVersion:null,localeSources:Object.create(null),isPretranslated:false,},_getInternalAPI:function(){return{Error:L10nError,Context:Context,Locale:Locale,Resolver:Resolver,getPluralRule:getPluralRule,rePlaceables:rePlaceables,translateDocument:translateDocument,onMetaInjected:onMetaInjected,PropertiesParser:PropertiesParser,walkContent:walkContent,buildLocaleList:buildLocaleList};}};function getDirection(lang){return(rtlList.indexOf(lang)>=0)?'rtl':'ltr';}
var readyStates={loading:0,interactive:1,complete:2};function whenInteractive(callback){if(readyStates[document.readyState]>=readyStates.interactive){callback();return;}
document.addEventListener('readystatechange',function l10n_onrsc(){if(readyStates[document.readyState]>=readyStates.interactive){document.removeEventListener('readystatechange',l10n_onrsc);callback();}});}
function initObserver(){nodeObserver=new MutationObserver(onMutations.bind(navigator.mozL10n));nodeObserver.observe(document,moConfig);}
function init(pretranslate){if(!pretranslate){initObserver();}
initResources.call(navigator.mozL10n);}
function initResources(){var meta={};var nodes=document.head.querySelectorAll('link[rel="localization"],'+'meta[name="availableLanguages"],'+'meta[name="defaultLanguage"],'+'meta[name="appVersion"],'+'script[type="application/l10n"]');for(var i=0,node;node=nodes[i];i++){var type=node.getAttribute('rel')||node.nodeName.toLowerCase();switch(type){case'localization':this.ctx.resLinks.push(node.getAttribute('href'));break;case'meta':onMetaInjected.call(this,node,meta);break;case'script':onScriptInjected.call(this,node);break;}}
var additionalLanguagesPromise;if(navigator.mozApps&&navigator.mozApps.getAdditionalLanguages){additionalLanguagesPromise=navigator.mozApps.getAdditionalLanguages().catch(function(e){console.error('Error while loading getAdditionalLanguages',e);});document.addEventListener('additionallanguageschange',function(evt){registerLocales.call(this,meta,evt.detail);this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);}.bind(this));}else{additionalLanguagesPromise=Promise.resolve();}
additionalLanguagesPromise.then(function(extraLangs){registerLocales.call(this,meta,extraLangs);initLocale.call(this);}.bind(this));}
function registerLocales(meta,extraLangs){var locales=buildLocaleList.call(this,meta,extraLangs);navigator.mozL10n._config.localeSources=locales[1];this.ctx.registerLocales(locales[0],Object.keys(locales[1]));}
function getMatchingLangpack(appVersion,langpacks){for(var i=0,langpack;(langpack=langpacks[i]);i++){if(langpack.target===appVersion){return langpack;}}
return null;}
function buildLocaleList(meta,extraLangs){var loc,lp;var localeSources=Object.create(null);var defaultLocale=meta.defaultLanguage||this.ctx.defaultLocale;if(meta.availableLanguages){for(loc in meta.availableLanguages){localeSources[loc]='app';}}
if(extraLangs){for(loc in extraLangs){lp=getMatchingLangpack(this._config.appVersion,extraLangs[loc]);if(!lp){continue;}
if(!(loc in localeSources)||!meta.availableLanguages[loc]||parseInt(lp.revision)>meta.availableLanguages[loc]){localeSources[loc]='extra';}}}
if(!(defaultLocale in localeSources)){localeSources[defaultLocale]='app';}
return[defaultLocale,localeSources];}
function splitAvailableLanguagesString(str){var langs={};str.split(',').forEach(function(lang){lang=lang.trim().split(':');langs[lang[0]]=parseInt(lang[1]);});return langs;}
function onMetaInjected(node,meta){switch(node.getAttribute('name')){case'availableLanguages':meta.availableLanguages=splitAvailableLanguagesString(node.getAttribute('content'));break;case'defaultLanguage':meta.defaultLanguage=node.getAttribute('content');break;case'appVersion':navigator.mozL10n._config.appVersion=node.getAttribute('content');break;}}
function onScriptInjected(node){var lang=node.getAttribute('lang');var locale=this.ctx.getLocale(lang);locale.addAST(JSON.parse(node.textContent));}
function initLocale(){this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);window.addEventListener('languagechange',function l10n_langchange(){this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);}.bind(this));}
function localizeMutations(mutations){var mutation;var targets=new Set();for(var i=0;i<mutations.length;i++){mutation=mutations[i];if(mutation.type==='childList'){var addedNode;for(var j=0;j<mutation.addedNodes.length;j++){addedNode=mutation.addedNodes[j];if(addedNode.nodeType!==Node.ELEMENT_NODE){continue;}
targets.add(addedNode);}}
if(mutation.type==='attributes'){targets.add(mutation.target);}}
targets.forEach(function(target){if(target.childElementCount){translateFragment.call(this,target);}else if(target.hasAttribute('data-l10n-id')){translateElement.call(this,target);}},this);}
function onMutations(mutations,self){self.disconnect();localizeMutations.call(this,mutations);self.observe(document,moConfig);}
function onReady(){if(!navigator.mozL10n._config.isPretranslated){translateDocument.call(this);}
navigator.mozL10n._config.isPretranslated=false;if(pendingElements){for(var i=0,element;element=pendingElements[i];i++){translateElement.call(this,element);}
pendingElements=null;}
if(!nodeObserver){initObserver();}
fireLocalizedEvent.call(this);}
function fireLocalizedEvent(){var event=new CustomEvent('localized',{'bubbles':false,'cancelable':false,'detail':{'language':this.ctx.supportedLocales[0]}});window.dispatchEvent(event);}
var reOverlay=/<|&#?\w+;/;var reHtml=/[&<>]/g;var htmlEntities={'&':'&amp;','<':'&lt;','>':'&gt;',};function translateDocument(){document.documentElement.lang=this.language.code;document.documentElement.dir=this.language.direction;translateFragment.call(this,document.documentElement);}
function translateFragment(element){if(typeof element.hasAttribute==='function'&&element.hasAttribute('data-l10n-id')){translateElement.call(this,element);}
var nodes=getTranslatableChildren(element);for(var i=0;i<nodes.length;i++){translateElement.call(this,nodes[i]);}}
function setL10nAttributes(element,id,args){element.setAttribute('data-l10n-id',id);if(args){element.setAttribute('data-l10n-args',JSON.stringify(args));}}
function getL10nAttributes(element){return{id:element.getAttribute('data-l10n-id'),args:JSON.parse(element.getAttribute('data-l10n-args'))};}
function getTranslatableChildren(element){return element?element.querySelectorAll('*[data-l10n-id]'):[];}
function camelCaseToDashed(string){if(string==='ariaValueText'){return'aria-valuetext';}
return string.replace(/[A-Z]/g,function(match){return'-'+match.toLowerCase();}).replace(/^-/,'');}
function escapeL10nArgs(match){return htmlEntities[match];}
function translateElement(element){if(!this.ctx.isReady){if(!pendingElements){pendingElements=[];}
pendingElements.push(element);return;}
var l10nId=element.getAttribute('data-l10n-id');if(!l10nId){return false;}
var l10nArgs=element.getAttribute('data-l10n-args');var entity=this.ctx.getEntity(l10nId,l10nArgs?JSON.parse(l10nArgs.replace(reHtml,escapeL10nArgs)):undefined);var value=entity.value;if(typeof value==='string'){if(!reOverlay.test(value)){element.textContent=value;}else{var translation=element.ownerDocument.createElement('template');translation.innerHTML=value;overlayElement(element,translation.content);}}
for(var key in entity.attrs){var attrName=camelCaseToDashed(key);if(isAttrAllowed({name:attrName},element)){element.setAttribute(attrName,entity.attrs[key]);}}}
function overlayElement(sourceElement,translationElement){var result=translationElement.ownerDocument.createDocumentFragment();var k,attr;var childElement;while((childElement=translationElement.childNodes[0])){translationElement.removeChild(childElement);if(childElement.nodeType===Node.TEXT_NODE){result.appendChild(childElement);continue;}
var index=getIndexOfType(childElement);var sourceChild=getNthElementOfType(sourceElement,childElement,index);if(sourceChild){overlayElement(sourceChild,childElement);result.appendChild(sourceChild);continue;}
if(isElementAllowed(childElement)){var sanitizedChild=childElement.ownerDocument.createElement(childElement.nodeName);overlayElement(sanitizedChild,childElement);result.appendChild(sanitizedChild);continue;}
result.appendChild(document.createTextNode(childElement.textContent));}
sourceElement.textContent='';sourceElement.appendChild(result);if(translationElement.attributes){for(k=0,attr;(attr=translationElement.attributes[k]);k++){if(isAttrAllowed(attr,sourceElement)){sourceElement.setAttribute(attr.name,attr.value);}}}}
function isElementAllowed(element){return allowed.elements.indexOf(element.tagName.toLowerCase())!==-1;}
function isAttrAllowed(attr,element){var attrName=attr.name.toLowerCase();var tagName=element.tagName.toLowerCase();if(allowed.attributes.global.indexOf(attrName)!==-1){return true;}
if(!allowed.attributes[tagName]){return false;}
if(allowed.attributes[tagName].indexOf(attrName)!==-1){return true;}
if(tagName==='input'&&attrName==='value'){var type=element.type.toLowerCase();if(type==='submit'||type==='button'||type==='reset'){return true;}}
return false;}
function getNthElementOfType(context,element,index){var nthOfType=0;for(var i=0,child;child=context.children[i];i++){if(child.nodeType===Node.ELEMENT_NODE&&child.tagName===element.tagName){if(nthOfType===index){return child;}
nthOfType++;}}
return null;}
function getIndexOfType(element){var index=0;var child;while((child=element.previousElementSibling)){if(child.tagName===element.tagName){index++;}}
return index;}
var DEBUG=false;navigator.mozL10n.ctx=new Context(window.document?document.URL:null);navigator.mozL10n.ctx.ready(onReady.bind(navigator.mozL10n));navigator.mozL10n.ctx.addEventListener('notfounderror',function reportMissingEntity(e){if(DEBUG||e.loc==='en-US'){console.warn(e.toString());}});if(DEBUG){navigator.mozL10n.ctx.addEventListener('fetcherror',console.error.bind(console));navigator.mozL10n.ctx.addEventListener('parseerror',console.error.bind(console));navigator.mozL10n.ctx.addEventListener('resolveerror',console.error.bind(console));}
if(window.document){navigator.mozL10n._config.isPretranslated=document.documentElement.lang===navigator.language;var forcePretranslate=!navigator.mozL10n._config.isPretranslated;whenInteractive(init.bind(navigator.mozL10n,forcePretranslate));}
document.l10n={setAttributes:navigator.mozL10n.setAttributes,getAttributes:navigator.mozL10n.getAttributes,formatValue:function(id,args){return navigator.mozL10n.formatValue(id,args);},translateFragment:function(frag){return Promise.resolve(navigator.mozL10n.translateFragment(frag));},ready:new Promise(function(resolve){navigator.mozL10n.once(resolve);}),formatValues:function(){var keys=arguments;var resp=keys.map(function(key){if(Array.isArray(key)){return navigator.mozL10n.formatValue(key[0],key[1]);}
return navigator.mozL10n.formatValue(key);});return Promise.all(resp);},requestLanguages:function(langs){navigator.mozL10n.ctx.requestLocales.apply(navigator.mozL10n.ctx,langs);},pseudo:{'fr-x-psaccent':{getName:function(){return Promise.resolve(navigator.mozL10n.qps['fr-x-psaccent'].name);},processString:function(s){return Promise.resolve(navigator.mozL10n.qps['fr-x-psaccent'].translate(s));}},'ar-x-psbidi':{getName:function(){return Promise.resolve(navigator.mozL10n.qps['ar-x-psbidi'].name);},processString:function(s){return Promise.resolve(navigator.mozL10n.qps['ar-x-psbidi'].translate(s));}}},};navigator.mozL10n.ready(function(){document.documentElement.setAttribute('langs',navigator.mozL10n.ctx.supportedLocales.join(' '));});navigator.mozL10n.once(function(){window.addEventListener('localized',function(){document.dispatchEvent(new CustomEvent('DOMRetranslated',{bubbles:false,cancelable:false}));});});})(this);

View file

@ -0,0 +1,11 @@
;'use strict';var LazyLoader=(function(){function LazyLoader(){this._loaded={};this._isLoading={};}
LazyLoader.prototype={_js:function(file,callback){var script=document.createElement('script');script.src=file;script.async=false;script.addEventListener('load',callback);document.head.appendChild(script);this._isLoading[file]=script;},_css:function(file,callback){var style=document.createElement('link');style.type='text/css';style.rel='stylesheet';style.href=file;document.head.appendChild(style);callback();},_html:function(domNode,callback){if(domNode.getAttribute('is')){this.load(['/shared/js/html_imports.js'],function(){HtmlImports.populate(callback);}.bind(this));return;}
for(var i=0;i<domNode.childNodes.length;i++){if(domNode.childNodes[i].nodeType==document.COMMENT_NODE){domNode.innerHTML=domNode.childNodes[i].nodeValue;break;}}
window.dispatchEvent(new CustomEvent('lazyload',{detail:domNode}));callback();},getJSON:function(file,mozSystem){return new Promise(function(resolve,reject){var xhr;if(mozSystem){xhr=new XMLHttpRequest({mozSystem:true});}else{xhr=new XMLHttpRequest();}
xhr.open('GET',file,true);xhr.responseType='json';xhr.onerror=function(error){reject(error);};xhr.onload=function(){if(xhr.response!==null){resolve(xhr.response);}else{reject(new Error('No valid JSON object was found ('+
xhr.status+' '+xhr.statusText+')'));}};xhr.send();});},load:function(files,callback){var deferred={};deferred.promise=new Promise(resolve=>{deferred.resolve=resolve;});if(!Array.isArray(files)){files=[files];}
var loadsRemaining=files.length,self=this;function perFileCallback(file){if(self._isLoading[file]){delete self._isLoading[file];}
self._loaded[file]=true;if(--loadsRemaining===0){deferred.resolve();if(callback){callback();}}}
for(var i=0;i<files.length;i++){var file=files[i];if(this._loaded[file.id||file]){perFileCallback(file);}else if(this._isLoading[file]){this._isLoading[file].addEventListener('load',perFileCallback.bind(null,file));}else{var method,idx;if(typeof file==='string'){method=file.match(/\.([^.]+)$/)[1];idx=file;}else{method='html';idx=file.id;}
this['_'+method](file,perFileCallback.bind(null,idx));}}
return deferred.promise;}};return new LazyLoader();}());