browser/metro/base/content/bindings/grid.xml
author Sam Foster <sfoster@mozilla.com>
Fri, 31 Jan 2014 13:56:42 -0800
changeset 166377 b670b20cfcc7469605e66567ce5f03e29863876d
parent 166374 a09caf2b867f19e77ba0b4d5246819e6527acc93
permissions -rw-r--r--
Bug 964149 - Tighten up bend/unbend event handling in richgrid. r=rsilveira

<?xml version="1.0"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
   - License, v. 2.0. If a copy of the MPL was not distributed with this
   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->

<bindings
    xmlns="http://www.mozilla.org/xbl"
    xmlns:xbl="http://www.mozilla.org/xbl"
    xmlns:html="http://www.w3.org/1999/xhtml"
    xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">

  <binding id="richgrid"
           extends="chrome://global/content/bindings/general.xml#basecontrol">

    <content>
      <html:div id="grid-div" anonid="grid" class="richgrid-grid" xbl:inherits="compact">
        <children/>
      </html:div>
    </content>

    <implementation implements="nsIDOMXULSelectControlElement">
      <property name="_grid" readonly="true" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'grid');"/>

      <property name="isBound" readonly="true" onget="return !!this._grid"/>
      <property name="isArranging" readonly="true" onget="return !!this._scheduledArrangeItemsTimerId"/>

      <field name="controller">null</field>

      <!-- collection of child items excluding empty tiles -->
      <property name="items" readonly="true" onget="return this.querySelectorAll('richgriditem[value]');"/>
      <property name="itemCount" readonly="true" onget="return this.items.length;"/>

      <method name="isItem">
        <parameter name="anItem"/>
        <body>
          <![CDATA[
            // only non-empty child nodes are considered items
            return anItem && anItem.hasAttribute("value") &&
                   anItem.parentNode == this;
          ]]>
        </body>
      </method>

      <!-- nsIDOMXULMultiSelectControlElement (not fully implemented) -->

      <method name="clearSelection">
        <body>
          <![CDATA[
            // 'selection' and 'selected' are confusingly overloaded here
            // as richgrid is adopting multi-select behavior, but select/selected are already being
            // used to describe triggering the default action of a tile
            if (this._selectedItem){
              this._selectedItem.removeAttribute("selected");
              this._selectedItem = null;
            }

            for (let childItem of this.selectedItems) {
              childItem.removeAttribute("selected");
            }
          ]]>
        </body>
      </method>

      <method name="toggleItemSelection">
        <parameter name="anItem"/>
        <body>
          <![CDATA[
            if (!this.isItem(anItem))
              return;

            let wasSelected = anItem.selected;
            if ("single" == this.getAttribute("seltype")) {
              this.clearSelection();
            }
            this._selectedItem = wasSelected ? null : anItem;
            if (wasSelected)
              anItem.removeAttribute("selected");
            else
              anItem.setAttribute("selected", true);
            this._fireEvent("selectionchange");
          ]]>
        </body>
      </method>

      <method name="selectItem">
        <parameter name="anItem"/>
        <body>
          <![CDATA[
            if (!this.isItem(anItem))
              return;
            let wasSelected = anItem.selected,
                isSingleMode = ("single" == this.getAttribute("seltype"));
            if (isSingleMode) {
              this.clearSelection();
            }
            this._selectedItem = anItem;
            if (wasSelected) {
              return;
            }
            anItem.setAttribute("selected", true);
            if (isSingleMode) {
              this._fireEvent("select");
            } else {
              this._fireEvent("selectionchange");
            }
          ]]>
        </body>
      </method>

      <method name="selectNone">
        <body>
          <![CDATA[
            let selectedCount = this.selectedItems.length;
            this.clearSelection();
            if (selectedCount && "single" != this.getAttribute("seltype")) {
              this._fireEvent("selectionchange");
            }
          ]]>
        </body>
      </method>

      <method name="handleItemClick">
        <parameter name="aItem"/>
        <parameter name="aEvent"/>
        <body>
          <![CDATA[
            if (!(this.isBound && this.isItem(aItem)))
              return;

            if ("single" == this.getAttribute("seltype")) {
              // we'll republish this as a selectionchange event on the grid
              aEvent.stopPropagation();
              this.selectItem(aItem);
            }

            if (this.controller && this.controller.handleItemClick)
              this.controller.handleItemClick(aItem, aEvent);
          ]]>
        </body>
      </method>

      <method name="handleItemContextMenu">
        <parameter name="aItem"/>
        <parameter name="aEvent"/>
        <body>
          <![CDATA[
            if (!this.isBound || this.noContext || !this.isItem(aItem))
              return;
            // we'll republish this as a selectionchange event on the grid
            aEvent.stopPropagation();
            this.toggleItemSelection(aItem);
          ]]>
        </body>
      </method>

      <property name="contextSetName" readonly="true"
                onget="return this.getAttribute('set-name');"/>

      <property name="contextActions">
        <getter>
          <![CDATA[
            // return the subset of verbs that apply to all selected tiles
            let tileNodes = this.selectedItems;
            if (!tileNodes.length) {
              return new Set();
            }

            // given one or more sets of values,
            // return a set with only those values present in each
            let initialItem = tileNodes[0];

            let verbSet = new Set(initialItem.contextActions);
            for (let i=1; i<tileNodes.length; i++){
              let set = tileNodes[i].contextActions;
              for (let item of verbSet) {
                if (!set.has(item)){
                  verbSet.delete(item);
                }
              }
            }
            // add the clear-selection button if more than one tiles are selected
            if (tileNodes.length > 1) {
              verbSet.add('clear');
            }
            // returns Set
            return verbSet;
          ]]>
        </getter>
      </property>

    <!-- nsIDOMXULSelectControlElement -->

      <field name="_selectedItem">null</field>
      <property name="selectedItem" onget="return this._selectedItem;">
        <setter>
          <![CDATA[
            this.selectItem(val);
          ]]>
        </setter>
      </property>

      <!-- partial implementation of multiple selection interface -->
      <property name="selectedItems">
        <getter>
          <![CDATA[
            return this.querySelectorAll("richgriditem[value][selected]");
          ]]>
        </getter>
      </property>

      <property name="selectedIndex">
        <getter>
          <![CDATA[
            return this.getIndexOfItem(this._selectedItem);
          ]]>
        </getter>
        <setter>
          <![CDATA[
            if (val >= 0) {
              let selected = this.getItemAtIndex(val);
              this.selectItem(selected);
            } else {
              this.selectNone();
            }
          ]]>
        </setter>
      </property>

      <method name="appendItem">
        <parameter name="aLabel"/>
        <parameter name="aValue"/>
        <parameter name="aSkipArrange"/>
        <body>
          <![CDATA[
            let item = this.nextSlot();
            item.setAttribute("value", aValue);
            item.setAttribute("label", aLabel);

            if (!aSkipArrange)
              this.arrangeItems();
            return item;
          ]]>
        </body>
      </method>

      <method name="_slotValues">
        <body><![CDATA[
          return Array.map(this.children, (cnode) => cnode.getAttribute("value"));
        ]]></body>
      </method>

      <property name="minSlots" readonly="true"
                onget="return this.getAttribute('minSlots') || 3;"/>

      <method name="clearAll">
        <parameter name="aSkipArrange"/>
        <body>
          <![CDATA[
            const ELEMENT_NODE_TYPE = Components.interfaces.nsIDOMNode.ELEMENT_NODE;
            let slotCount = this.minSlots;
            let childIndex = 0;
            let child = this.firstChild;
            while (child) {
              // remove excess elements and non-element nodes
              if (child.nodeType !== ELEMENT_NODE_TYPE || childIndex+1 > slotCount) {
                let orphanNode = child;
                child = orphanNode.nextSibling;
                this.removeChild(orphanNode);
                continue;
              }
              if (child.hasAttribute("value")) {
                this._releaseSlot(child);
              }
              child = child.nextSibling;
              childIndex++;
            }
            // create our quota of item slots
            for (let count = this.childElementCount; count < slotCount; count++) {
              this.appendChild( this._createItemElement() );
            }

            if (!aSkipArrange)
              this.arrangeItems();
          ]]>
        </body>
      </method>

      <method name="_slotAt">
        <parameter name="anIndex"/>
        <body>
          <![CDATA[
            // backfill with new slots as necessary
            let count = Math.max(1+anIndex, this.minSlots) - this.childElementCount;
            for (; count > 0; count--) {
              this.appendChild( this._createItemElement() );
            }
            return this.children[anIndex];
          ]]>
        </body>
      </method>

      <method name="nextSlot">
        <body>
          <![CDATA[
            if (!this.itemCount) {
              return this._slotAt(0);
            }
            let lastItem = this.items[this.itemCount-1];
            let nextIndex = 1 + Array.indexOf(this.children, lastItem);
            return this._slotAt(nextIndex);
          ]]>
        </body>
      </method>

      <method name="_releaseSlot">
        <parameter name="anItem"/>
        <body>
          <![CDATA[
            // Flush out data and state attributes so we can recycle this slot/element
            let exclude = { value: 1, tiletype: 1 };
            let attrNames = [attr.name for (attr of anItem.attributes)];
            for (let attrName of attrNames) {
              if (!(attrName in exclude))
                anItem.removeAttribute(attrName);
            }
            // clear out inline styles
            anItem.removeAttribute("style");
            // finally clear the value, which should apply the richgrid-empty-item binding
            anItem.removeAttribute("value");
          ]]>
        </body>
      </method>

      <method name="insertItemAt">
        <parameter name="anIndex"/>
        <parameter name="aLabel"/>
        <parameter name="aValue"/>
        <parameter name="aSkipArrange"/>
        <body>
          <![CDATA[
            anIndex = Math.min(this.itemCount, anIndex);
            let insertedItem;
            let existing = this.getItemAtIndex(anIndex);
            if (existing) {
              // use an empty slot if we have one, otherwise insert it
              let childIndex = Array.indexOf(this.children, existing);
              if (childIndex > 0 && !this.children[childIndex-1].hasAttribute("value")) {
                insertedItem = this.children[childIndex-1];
              } else {
                insertedItem = this.insertBefore(this._createItemElement(),existing);
              }
            }
            if (!insertedItem) {
              insertedItem = this._slotAt(anIndex);
            }
            insertedItem.setAttribute("value", aValue);
            insertedItem.setAttribute("label", aLabel);
            if (!aSkipArrange)
              this.arrangeItems();
            return insertedItem;
          ]]>
        </body>
      </method>

      <method name="removeItemAt">
        <parameter name="anIndex"/>
        <parameter name="aSkipArrange"/>
        <body>
          <![CDATA[
            let item = this.getItemAtIndex(anIndex);
            if (!item)
              return null;
            return this.removeItem(item, aSkipArrange);
          ]]>
        </body>
      </method>

      <method name="removeItem">
        <parameter name="aItem"/>
        <parameter name="aSkipArrange"/>
        <body>
          <![CDATA[
            if (!this.isItem(aItem))
              return null;

            let removal = this.removeChild(aItem);
            // replace the slot if necessary
            if (this.childElementCount < this.minSlots) {
              this.nextSlot();
            }

            if (removal && !aSkipArrange)
                this.arrangeItems();

            // note that after removal the node is unbound
            // so none of the richgriditem binding methods & properties are available
            return removal;
          ]]>
        </body>
      </method>

      <method name="getIndexOfItem">
        <parameter name="anItem"/>
        <body>
          <![CDATA[
            if (!this.isItem(anItem))
              return -1;

            return Array.indexOf(this.items, anItem);
          ]]>
        </body>
      </method>

      <method name="getItemAtIndex">
        <parameter name="anIndex"/>
        <body>
          <![CDATA[
            if (!this._isIndexInBounds(anIndex))
              return null;
            return this.items.item(anIndex);
          ]]>
        </body>
      </method>

      <method name="getItemsByUrl">
        <parameter name="aUrl"/>
        <body>
          <![CDATA[
            return this.querySelectorAll('richgriditem[value="'+aUrl+'"]');
          ]]>
        </body>
      </method>

    <!-- Interface for offsetting selection and checking bounds -->

      <property name="isSelectionAtStart" readonly="true"
                onget="return this.selectedIndex == 0;"/>

      <property name="isSelectionAtEnd" readonly="true"
                onget="return this.selectedIndex == (this.itemCount - 1);"/>

      <property name="isSelectionInStartRow" readonly="true">
        <getter>
          <![CDATA[
            return this.selectedIndex < this.columnCount;
          ]]>
        </getter>
      </property>

      <property name="isSelectionInEndRow" readonly="true">
        <getter>
          <![CDATA[
            let lowerBound = (this.rowCount - 1) * this.columnCount;
            let higherBound = this.rowCount * this.columnCount;

            return this.selectedIndex >= lowerBound &&
                   this.selectedIndex < higherBound;
          ]]>
        </getter>
      </property>

      <method name="offsetSelection">
        <parameter name="aOffset"/>
        <body>
          <![CDATA[
            let newIndex = this.selectedIndex + aOffset;
            if (this._isIndexInBounds(newIndex))
              this.selectedIndex = newIndex;
          ]]>
        </body>
      </method>

      <method name="offsetSelectionByRow">
        <parameter name="aRowOffset"/>
        <body>
          <![CDATA[
            let newIndex = this.selectedIndex + (this.columnCount * aRowOffset);
            if (this._isIndexInBounds(newIndex))
              this.selectedIndex -= this.columnCount;
          ]]>
        </body>
      </method>

      <!-- Interface for grid layout management -->

      <field name="_rowCount">0</field>
      <property name="rowCount" readonly="true" onget="return this._rowCount;"/>
      <field name="_columnCount">0</field>
      <property name="columnCount" readonly="true" onget="return this._columnCount;"/>
      <property name="_containerSize">
        <getter><![CDATA[
            // return the rect that represents our bounding box
            let containerNode = this.hasAttribute("flex") ? this : this.parentNode;
            let rect = containerNode.getBoundingClientRect();
            // return falsy if the container has no height
            return rect.height ? {
              width: rect.width,
              height: rect.height
            } : null;
        ]]></getter>
      </property>

      <property name="_itemSize">
        <getter><![CDATA[
            // return the dimensions that represent an item in the grid

            // grab tile/item dimensions
            this._tileSizes = this._getTileSizes();

            let type = this.getAttribute("tiletype") || "default";
            let dims = this._tileSizes && this._tileSizes[type];
            if (!dims) {
              throw new Error("Missing tile sizes for '" + type + "' type");
            }
            return dims;
        ]]></getter>
      </property>

      <!-- do conditions allow layout/arrange of the grid? -->
      <property name="_canLayout" readonly="true">
        <getter>
          <![CDATA[
            if (!(this._grid && this._grid.style)) {
               return false;
            }

            let gridItemSize = this._itemSize;

            // If we don't have valid item dimensions we can't arrange yet
            if (!(gridItemSize && gridItemSize.height)) {
              return false;
            }

            let container = this._containerSize;
            // If we don't have valid container dimensions we can't arrange yet
            if (!(container && container.height)) {
              return false;
            }
            return true;
          ]]>
        </getter>
      </property>

      <field name="_scheduledArrangeItemsTimerId">null</field>
      <field name="_scheduledArrangeItemsTries">0</field>
      <field name="_maxArrangeItemsRetries">5</field>

      <method name="_scheduleArrangeItems">
        <parameter name="aTime"/>
        <body>
          <![CDATA[
              // cap the number of times we reschedule calling arrangeItems
              if (
                  !this._scheduledArrangeItemsTimerId &&
                  this._maxArrangeItemsRetries > this._scheduledArrangeItemsTries
              ) {
                this._scheduledArrangeItemsTimerId = setTimeout(this.arrangeItems.bind(this), aTime || 0);
                // track how many times we've attempted arrangeItems
                this._scheduledArrangeItemsTries++;
              }
          ]]>
        </body>
      </method>

      <method name="arrangeItems">
        <body>
          <![CDATA[
            if (this.hasAttribute("deferlayout")) {
              return;
            }
            if (!this._canLayout) {
              // try again later
              this._scheduleArrangeItems();
              return;
            }

            let itemDims = this._itemSize;
            let containerDims = this._containerSize;
            let slotsCount = this.childElementCount;

            // reset the flags
            if (this._scheduledArrangeItemsTimerId) {
              clearTimeout(this._scheduledArrangeItemsTimerId);
              delete this._scheduledArrangeItemsTimerId;
            }
            this._scheduledArrangeItemsTries = 0;

            // clear explicit width and columns before calculating from avail. height again
            let gridStyle = this._grid.style;
            gridStyle.removeProperty("min-width");
            gridStyle.removeProperty("-moz-column-count");

            if (this.hasAttribute("vertical")) {
              this._columnCount = Math.floor(containerDims.width / itemDims.width) || 1;
              this._rowCount = Math.floor(slotsCount / this._columnCount);
            } else {
              // rows attribute is fixed number of rows
              let maxRows = Math.floor(containerDims.height / itemDims.height);
              this._rowCount = this.getAttribute("rows") ?
                                  // fit indicated rows when possible
                                  Math.min(maxRows, this.getAttribute("rows")) :
                                  // at least 1 row
                                  Math.min(maxRows, slotsCount) || 1;

              // columns attribute is min number of cols
              this._columnCount = Math.ceil(slotsCount / this._rowCount) || 1;
              if (this.getAttribute("columns")) {
                this._columnCount = Math.max(this._columnCount, this.getAttribute("columns"));
              }
            }

            // width is typically auto, cap max columns by truncating items collection
            // or, setting max-width style property with overflow hidden
            if (this._columnCount) {
              gridStyle.MozColumnCount = this._columnCount;
            }
            this._fireEvent("arranged");
          ]]>
        </body>
      </method>
      <method name="arrangeItemsNow">
        <body>
          <![CDATA[
            this.removeAttribute("deferlayout");
            // cancel any scheduled arrangeItems and reset flags
            if (this._scheduledArrangeItemsTimerId) {
              clearTimeout(this._scheduledArrangeItemsTimerId);
              delete this._scheduledArrangeItemsTimerId;
            }
            this._scheduledArrangeItemsTries = 0;
            // pass over any params
            return this.arrangeItems.apply(this, arguments);
          ]]>
        </body>
      </method>

      <!-- Inteface to suppress selection events -->
      <property name="suppressOnSelect"
                  onget="return this.getAttribute('suppressonselect') == 'true';"
                  onset="this.setAttribute('suppressonselect', val);"/>
      <property name="noContext"
                  onget="return this.hasAttribute('nocontext');"
                  onset="if (val) this.setAttribute('nocontext', true); else this.removeAttribute('nocontext');"/>
      <property name="crossSlideBoundary"
          onget="return this.hasAttribute('crossslideboundary')? this.getAttribute('crossslideboundary') : Infinity;"/>

    <!-- Internal methods -->
      <field name="_xslideHandler"/>
      <constructor>
        <![CDATA[
          // create our quota of item slots
          for (let count = this.childElementCount, slotCount = this.minSlots;
              count < slotCount; count++) {
            this.appendChild( this._createItemElement() );
          }
          if (this.controller && this.controller.gridBoundCallback != undefined)
            this.controller.gridBoundCallback();
          // XXX This event was never actually implemented (bug 223411).
          let event = document.createEvent("Events");
          event.initEvent("contentgenerated", true, true);
          this.dispatchEvent(event);
        ]]>
      </constructor>

      <destructor>
        <![CDATA[
          this.disableCrossSlide();
        ]]>
      </destructor>
      <method name="enableCrossSlide">
        <body>
          <![CDATA[
            // set up cross-slide gesture handling for multiple-selection grids
            if (!this._xslideHandler &&
                "undefined" !== typeof CrossSlide && !this.noContext) {
              this._xslideHandler = new CrossSlide.Handler(this, {
                    REARRANGESTART: this.crossSlideBoundary
              });
            }
          ]]>
        </body>
      </method>

      <method name="disableCrossSlide">
        <body>
          <![CDATA[
            if (this._xslideHandler) {
              this.removeEventListener("touchstart", this._xslideHandler);
              this.removeEventListener("touchmove", this._xslideHandler);
              this.removeEventListener("touchend", this._xslideHandler);
              this._xslideHandler = null;
            }
          ]]>
        </body>
      </method>

      <property name="tileWidth" readonly="true" onget="return this._itemSize.width"/>
      <property name="tileHeight" readonly="true" onget="return this._itemSize.height"/>
      <field name="_tileStyleSheetName">"tiles.css"</field>
      <method name="_getTileSizes">
        <body>
          <![CDATA[
            // Tile sizes are constants, this avoids the need to measure a rendered item before grid layout
            // The defines.inc used by the theme CSS is the single source of truth for these values
            // This method locates and parses out (just) those dimensions from the stylesheet

            let typeSizes = this.ownerDocument.defaultView._richgridTileSizes;
            if (typeSizes && typeSizes["default"]) {
              return typeSizes;
            }

            // cache sizes on the global window object, for reuse between bound nodes
            typeSizes = this.ownerDocument.defaultView._richgridTileSizes = {};

            let sheets = this.ownerDocument.styleSheets;
            // The (first matching) rules that will give us tile type => width/height values
            // The keys in this object are string-matched against the selectorText
            // of rules in our stylesheet. Quoted values in a selector will always use " not '
            let typeSelectors = {
              'richgriditem' : "default",
              'richgriditem[tiletype="thumbnail"]': "thumbnail",
              'richgriditem[search]': "search",
              'richgriditem[compact]': "compact"
            };
            let rules, sheet;
            for (let i=0; (sheet=sheets[i]); i++) {
              if (sheet.href && sheet.href.endsWith( this._tileStyleSheetName )) {
                rules = sheet.cssRules;
                break;
              }
            }
            if (rules) {
              // walk the stylesheet rules until we've matched all our selectors
              for (let i=0, rule;(rule=rules[i]); i++) {
                let type = rule.selectorText && typeSelectors[rule.selectorText];
                if (type) {
                  let sizes = typeSizes[type] = {};
                  typeSelectors[type] = null;
                  delete typeSelectors[type];
                  // we assume px unit for tile dimension values
                  sizes.width =  parseInt(rule.style.getPropertyValue("width"));
                  sizes.height = parseInt(rule.style.getPropertyValue("height"));
                }
                if (!Object.keys(typeSelectors).length)
                  break;
              }
            } else {
              throw new Error("Failed to find stylesheet to parse out richgriditem dimensions\n");
            }
            return typeSizes;
          ]]>
        </body>
      </method>

      <method name="_isIndexInBounds">
        <parameter name="anIndex"/>
        <body>
          <![CDATA[
            return anIndex >= 0 && anIndex < this.itemCount;
          ]]>
        </body>
      </method>

      <method name="_createItemElement">
        <parameter name="aLabel"/>
        <parameter name="aValue"/>
        <body>
          <![CDATA[
            let item = this.ownerDocument.createElement("richgriditem");
            if (aValue) {
              item.setAttribute("value", aValue);
            }
            if (aLabel) {
              item.setAttribute("label", aLabel);
            }
            if (this.hasAttribute("tiletype")) {
              item.setAttribute("tiletype", this.getAttribute("tiletype"));
            }
            return item;
          ]]>
        </body>
      </method>

      <method name="_fireEvent">
        <parameter name="aType"/>
        <body>
          <![CDATA[
            switch (aType) {
              case "select" :
              case "selectionchange" :
                if (this.suppressOnSelect)
                  return;
                break;
              case "arranged" :
                break;
            }

            let event = document.createEvent("Events");
            event.initEvent(aType, true, true);
            this.dispatchEvent(event);
          ]]>
        </body>
      </method>

      <method name="bendItem">
        <parameter name="aItem"/>
        <parameter name="aEvent"/>
        <body><![CDATA[
          // apply the transform to the contentBox element of the item
          let bendNode = this.isItem(aItem) ? aItem._contentBox : null;
          if (!bendNode || aItem.hasAttribute("bending"))
            return;

          let event = aEvent;
          let rect = bendNode.getBoundingClientRect();
          let angle;
          let x = (event.clientX - rect.left) / rect.width;
          let y = (event.clientY - rect.top) / rect.height;
          let perspective = '450px';
          // scaling factors for the angle of deflection,
          //   based on the aspect-ratio of the tile
          let aspectRatio = rect.width/rect.height;
          let deflectX = 10 * Math.ceil(1/aspectRatio);
          let deflectY = 10 * Math.ceil(aspectRatio);

          if (Math.abs(x - .5) < .1 && Math.abs(y - .5) < .1) {
            bendNode.style.transform = "perspective("+perspective+") translateZ(-10px)";
          }
          else if (x > y) {
            if (1 - y > x) {
              angle = Math.ceil((.5 - y) * deflectY);
              bendNode.style.transform = "perspective("+perspective+") rotateX(" + angle + "deg)";
              bendNode.style.transformOrigin = "center bottom";
            } else {
              angle = Math.ceil((x - .5) * deflectX);
              bendNode.style.transform = "perspective("+perspective+") rotateY(" + angle + "deg)";
              bendNode.style.transformOrigin = "left center";
            }
          } else {
            if (1 - y < x) {
              angle = -Math.ceil((y - .5) * deflectY);
              bendNode.style.transform = "perspective("+perspective+") rotateX(" + angle + "deg)";
              bendNode.style.transformOrigin = "center top";
            } else {
              angle = -Math.ceil((.5 - x) * deflectX);
              bendNode.style.transform = "perspective("+perspective+") rotateY(" + angle + "deg)";
              bendNode.style.transformOrigin = "right center";
            }
          }
          // mark when bend effect is applied
          aItem.setAttribute("bending", true);
        ]]></body>
      </method>

      <method name="unbendItem">
        <parameter name="aItem"/>
        <body><![CDATA[
          // clear the 'bend' transform on the contentBox element of the item
          let bendNode = 'richgriditem' == aItem.nodeName && aItem._contentBox;
          if (bendNode && aItem.hasAttribute("bending")) {
            bendNode.style.removeProperty('transform');
            bendNode.style.removeProperty('transformOrigin');
            aItem.removeAttribute("bending");
          }
        ]]></body>
      </method>
    </implementation>
    <handlers>
      <!--  item bend effect handlers -->
      <handler event="mousedown" button="0" phase="capturing" action="this.bendItem(event.target, event)"/>
      <handler event="touchstart" action="this.bendItem(event.target, event.touches[0])"/>
      <handler event="mouseup" button="0" action="this.unbendItem(event.target)"/>
      <handler event="mouseout" button="0" action="this.unbendItem(event.target)"/>
      <handler event="touchend" action="this.unbendItem(event.target)"/>
      <handler event="touchcancel" action="this.unbendItem(event.target)"/>
      <!--  /item bend effect handler -->

      <handler event="context-action">
        <![CDATA[
          // context-action is an event fired by the appbar typically
          // which directs us to do something to the selected tiles
          switch (event.action) {
            case "clear":
              this.selectNone();
              break;
            default:
              if (this.controller && this.controller.doActionOnSelectedTiles) {
                this.controller.doActionOnSelectedTiles(event.action, event);
              }
          }
        ]]>
      </handler>
      <handler event="MozCrossSliding">
        <![CDATA[
          // MozCrossSliding is swipe gesture across a tile
          // The tile should follow the drag to reinforce the gesture
          // (with inertia/speedbump behavior)
          let state = event.crossSlidingState;
          let thresholds = this._xslideHandler.thresholds;
          let transformValue;
          switch (state) {
            case "cancelled":
              this.unbendItem(event.target);
              event.target.removeAttribute('crosssliding');
              // hopefully nothing else is transform-ing the tile
              event.target.style.removeProperty('transform');
              break;
            case "dragging":
            case "selecting":
              // remove bend/depress effect when a cross-slide begins
              this.unbendItem(event.target);

              event.target.setAttribute("crosssliding", true);
              // just track the mouse in the initial phases of the drag gesture
              transformValue = (event.direction=='x') ?
                                      'translateX('+event.delta+'px)' :
                                      'translateY('+event.delta+'px)';
              event.target.style.transform = transformValue;
              break;
            case "selectSpeedBumping":
            case "speedBumping":
              event.target.setAttribute('crosssliding', true);
              // in speed-bump phase, we add inertia to the drag
              let offset = CrossSlide.speedbump(
                event.delta,
                thresholds.SPEEDBUMPSTART,
                thresholds.SPEEDBUMPEND
              );
              transformValue = (event.direction=='x') ?
                                      'translateX('+offset+'px)' :
                                      'translateY('+offset+'px)';
              event.target.style.transform = transformValue;
              break;
            // "rearranging" case not used or implemented here
            case "completed":
              event.target.removeAttribute('crosssliding');
              event.target.style.removeProperty('transform');
              break;
          }
        ]]>
      </handler>
      <handler event="MozCrossSlideSelect">
        <![CDATA[
          if (this.noContext)
            return;
          this.toggleItemSelection(event.target);
        ]]>
      </handler>
    </handlers>
  </binding>

  <binding id="richgrid-item">
    <content>
      <html:div anonid="anon-tile" class="tile-content" xbl:inherits="customImage">
        <html:div class="tile-start-container" xbl:inherits="customImage">
          <html:div class="tile-icon-box" anonid="anon-tile-icon-box"><xul:image anonid="anon-tile-icon" xbl:inherits="src=iconURI"/></html:div>
        </html:div>
        <html:div anonid="anon-tile-label" class="tile-desc" xbl:inherits="xbl:text=label"/>
      </html:div>
    </content>

    <implementation>
      <property name="isBound" readonly="true" onget="return !!this._icon"/>
      <constructor>
        <![CDATA[
            this.refresh();
        ]]>
      </constructor>
      <property name="_contentBox" onget="return document.getAnonymousElementByAttribute(this, 'class', 'tile-content');"/>
      <property name="_textbox" onget="return document.getAnonymousElementByAttribute(this, 'class', 'tile-desc');"/>
      <property name="_top" onget="return document.getAnonymousElementByAttribute(this, 'class', 'tile-start-container');"/>
      <property name="_icon" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-tile-icon');"/>
      <property name="_iconBox" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-tile-icon-box');"/>
      <property name="_label" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-tile-label');"/>
      <property name="iconSrc"
                onset="this._icon.src = val; this.setAttribute('iconURI', val);"
                onget="return this._icon.src;" />

      <property name="selected"
                onget="return this.hasAttribute('selected');"
                onset="if (val) this.setAttribute('selected', val); else this.removeAttribute('selected');" />
      <property name="url"
                onget="return this.getAttribute('value')"
                onset="this.setAttribute('value', val);"/>
      <property name="label"
                onget="return this._label.getAttribute('value')"
                onset="this.setAttribute('label', val); this._label.setAttribute('value', val);"/>
      <property name="pinned"
                onget="return this.hasAttribute('pinned')"
                onset="if (val) { this.setAttribute('pinned', val) } else this.removeAttribute('pinned');"/>

      <method name="refresh">
        <body>
          <![CDATA[
            // Prevent an exception in case binding is not done yet.
            if (!this.isBound)
              return;

            // Seed the binding properties from bound-node attribute values
            // Usage: node.refresh()
            //        - reinitializes all binding properties from their associated attributes

            this.iconSrc = this.getAttribute('iconURI');
            this.color = this.getAttribute("customColor");
            this.label = this.getAttribute('label');
            // url getter just looks directly at attribute
            // selected getter just looks directly at attribute
            // pinned getter just looks directly at attribute
            // value getter just looks directly at attribute
            this._contextActions = null;
            this.refreshBackgroundImage();
          ]]>
        </body>
      </method>

      <property name="control">
        <getter><![CDATA[
          let parent = this.parentNode;
          while (parent && parent != this.ownerDocument.documentElement) {
            if (parent instanceof Components.interfaces.nsIDOMXULSelectControlElement)
              return parent;
            parent = parent.parentNode;
          }
          return null;
        ]]></getter>
      </property>

      <property name="color" onget="return this.getAttribute('customColor');">
        <setter><![CDATA[
          if (val) {
            this.setAttribute("customColor", val);
            this._contentBox.style.backgroundColor = val;

            // overridden in tiles.css for non-thumbnail types
            this._label.style.backgroundColor = val.replace(/rgb\(([^\)]+)\)/, 'rgba($1, 0.8)');

            // Small icons get a border+background-color treatment.
            // See tiles.css for large icon overrides
            this._iconBox.style.borderColor = val.replace(/rgb\(([^\)]+)\)/, 'rgba($1, 0.6)');
            this._iconBox.style.backgroundColor = this.hasAttribute("tintColor") ?
                                                    this.getAttribute("tintColor") : "#fff";
          } else {
            this.removeAttribute("customColor");
            this._contentBox.style.removeProperty("background-color");
            this._label.style.removeProperty("background-color");
            this._iconBox.style.removeProperty("border-color");
            this._iconBox.style.removeProperty("background-color");
          }
        ]]></setter>
      </property>

      <property name="backgroundImage" onget="return this.getAttribute('customImage');">
        <setter><![CDATA[
          if (val) {
            this.setAttribute("customImage", val);
            this._top.style.backgroundImage = val;
          } else {
            this.removeAttribute("customImage");
            this._top.style.removeProperty("background-image");
          }
        ]]></setter>
      </property>

      <method name="refreshBackgroundImage">
        <body><![CDATA[
          if (!this.isBound)
            return;
          if (this.backgroundImage) {
            this._top.style.removeProperty("background-image");
            this._top.style.setProperty("background-image", this.backgroundImage);
          }
        ]]></body>
      </method>

      <field name="_contextActions">null</field>
      <property name="contextActions">
        <getter>
          <![CDATA[
            if (!this._contextActions) {
              this._contextActions = new Set();
              let actionSet = this._contextActions;
              let actions = this.getAttribute("data-contextactions");
              if (actions) {
                actions.split(/[,\s]+/).forEach(function(verb){
                  actionSet.add(verb);
                });
              }
            }
            return this._contextActions;
          ]]>
        </getter>
      </property>
    </implementation>

    <handlers>
      <handler event="click" button="0">
        <![CDATA[
          // left-click/touch handler
          this.control.handleItemClick(this, event);
          // Stop this from bubbling, when the richgrid container
          // receives click events, we blur the nav bar.
          event.stopPropagation();
        ]]>
      </handler>

      <handler event="contextmenu">
        <![CDATA[
          // fires for right-click, long-click and (keyboard) contextmenu input
          // toggle the selected state of tiles in a grid
          let gridParent = this.control;
          if (!this.isBound || !gridParent)
            return;
          gridParent.handleItemContextMenu(this, event);
        ]]>
      </handler>
    </handlers>
  </binding>

  <binding id="richgrid-empty-item">
    <content>
      <html:div anonid="anon-tile" class="tile-content"></html:div>
    </content>
  </binding>

</bindings>