1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 cr.define('ntp', function() {
8 // We can't pass the currently dragging tile via dataTransfer because of
9 // http://crbug.com/31037
10 var currentlyDraggingTile = null;
11 function getCurrentlyDraggingTile() {
12 return currentlyDraggingTile;
14 function setCurrentlyDraggingTile(tile) {
15 currentlyDraggingTile = tile;
17 ntp.enterRearrangeMode();
19 ntp.leaveRearrangeMode();
23 * Changes the current dropEffect of a drag. This modifies the native cursor
24 * and serves as an indicator of what we should do at the end of the drag as
25 * well as give indication to the user if a drop would succeed if they let go.
26 * @param {DataTransfer} dataTransfer A dataTransfer object from a drag event.
27 * @param {string} effect A drop effect to change to (i.e. copy, move, none).
29 function setCurrentDropEffect(dataTransfer, effect) {
30 dataTransfer.dropEffect = effect;
31 if (currentlyDraggingTile)
32 currentlyDraggingTile.lastDropEffect = dataTransfer.dropEffect;
36 * Creates a new Tile object. Tiles wrap content on a TilePage, providing
37 * some styling and drag functionality.
39 * @extends {HTMLDivElement}
41 function Tile(contents) {
42 var tile = cr.doc.createElement('div');
43 tile.__proto__ = Tile.prototype;
44 tile.initialize(contents);
50 __proto__: HTMLDivElement.prototype,
52 initialize: function(contents) {
53 // 'real' as opposed to doppleganger.
54 this.className = 'tile real';
55 this.appendChild(contents);
58 this.addEventListener('dragstart', this.onDragStart_);
59 this.addEventListener('drag', this.onDragMove_);
60 this.addEventListener('dragend', this.onDragEnd_);
62 this.firstChild.addEventListener(
63 'webkitAnimationEnd', this.onContentsAnimationEnd_.bind(this));
65 this.eventTracker = new EventTracker();
69 return Array.prototype.indexOf.call(this.tilePage.tileElements_, this);
73 return findAncestorByClass(this, 'tile-page');
77 * Position the tile at |x, y|, and store this as the grid location, i.e.
78 * where the tile 'belongs' when it's not being dragged.
79 * @param {number} x The x coordinate, in pixels.
80 * @param {number} y The y coordinate, in pixels.
82 setGridPosition: function(x, y) {
89 * Position the tile at |x, y|.
90 * @param {number} x The x coordinate, in pixels.
91 * @param {number} y The y coordinate, in pixels.
93 moveTo: function(x, y) {
94 // left overrides right in LTR, and right takes precedence in RTL.
95 this.style.left = toCssPx(x);
96 this.style.right = toCssPx(x);
97 this.style.top = toCssPx(y);
101 * The handler for dragstart events fired on |this|.
102 * @param {Event} e The event for the drag.
105 onDragStart_: function(e) {
106 // The user may start dragging again during a previous drag's finishing
108 if (this.classList.contains('dragging'))
109 this.finalizeDrag_();
111 setCurrentlyDraggingTile(this);
113 e.dataTransfer.effectAllowed = 'copyMove';
114 this.firstChild.setDragData(e.dataTransfer);
116 // The drag clone is the node we use as a representation during the drag.
117 // It's attached to the top level document element so that it floats above
119 this.dragClone = this.cloneNode(true);
120 this.dragClone.style.right = '';
121 this.dragClone.classList.add('drag-representation');
122 $('card-slider-frame').appendChild(this.dragClone);
123 this.eventTracker.add(this.dragClone, 'webkitTransitionEnd',
124 this.onDragCloneTransitionEnd_.bind(this));
126 this.classList.add('dragging');
127 // offsetLeft is mirrored in RTL. Un-mirror it.
128 var offsetLeft = isRTL() ?
129 this.parentNode.clientWidth - this.offsetLeft :
131 this.dragOffsetX = e.x - offsetLeft - this.parentNode.offsetLeft;
132 this.dragOffsetY = e.y - this.offsetTop -
133 // Unlike offsetTop, this value takes scroll position into account.
134 this.parentNode.getBoundingClientRect().top;
140 * The handler for drag events fired on |this|.
141 * @param {Event} e The event for the drag.
144 onDragMove_: function(e) {
145 if (e.view != window || (e.x == 0 && e.y == 0)) {
146 this.dragClone.hidden = true;
150 this.dragClone.hidden = false;
151 this.dragClone.style.left = toCssPx(e.x - this.dragOffsetX);
152 this.dragClone.style.top = toCssPx(e.y - this.dragOffsetY);
156 * The handler for dragend events fired on |this|.
157 * @param {Event} e The event for the drag.
160 onDragEnd_: function(e) {
161 this.dragClone.hidden = false;
162 this.dragClone.classList.add('placing');
164 setCurrentlyDraggingTile(null);
166 // tilePage will be null if we've already been removed.
167 var tilePage = this.tilePage;
169 tilePage.positionTile_(this.index);
171 // Take an appropriate action with the drag clone.
172 if (this.landedOnTrash) {
173 this.dragClone.classList.add('deleting');
174 } else if (tilePage) {
175 // TODO(dbeam): Until we fix dropEffect to the correct behavior it will
176 // differ on windows - crbug.com/39399. That's why we use the custom
177 // this.lastDropEffect instead of e.dataTransfer.dropEffect.
178 if (tilePage.selected && this.lastDropEffect != 'copy') {
179 // The drag clone can still be hidden from the last drag move event.
180 this.dragClone.hidden = false;
181 // The tile's contents may have moved following the respositioning;
183 var contentDiffX = this.dragClone.firstChild.offsetLeft -
184 this.firstChild.offsetLeft;
185 var contentDiffY = this.dragClone.firstChild.offsetTop -
186 this.firstChild.offsetTop;
187 this.dragClone.style.left =
188 toCssPx(this.gridX + this.parentNode.offsetLeft -
190 this.dragClone.style.top =
192 this.parentNode.getBoundingClientRect().top -
194 } else if (this.dragClone.hidden) {
195 this.finalizeDrag_();
197 // The CSS3 transitions spec intentionally leaves it up to individual
198 // user agents to determine when styles should be applied. On some
199 // platforms (at the moment, Windows), when you apply both classes
200 // immediately a transition may not occur correctly. That's why we're
201 // using a setTimeout here to queue adding the class until the
202 // previous class (currently: .placing) sets up a transition.
203 // http://dev.w3.org/csswg/css3-transitions/#starting
204 window.setTimeout(function() {
206 this.dragClone.classList.add('dropped-on-other-page');
211 delete this.lastDropEffect;
212 this.landedOnTrash = false;
216 * Creates a clone of this node offset by the coordinates. Used for the
217 * dragging effect where a tile appears to float off one side of the grid
218 * and re-appear on the other.
219 * @param {number} x x-axis offset, in pixels.
220 * @param {number} y y-axis offset, in pixels.
222 showDoppleganger: function(x, y) {
223 // We always have to clear the previous doppleganger to make sure we get
224 // style updates for the contents of this tile.
225 this.clearDoppleganger();
227 var clone = this.cloneNode(true);
228 clone.classList.remove('real');
229 clone.classList.add('doppleganger');
230 var clonelets = clone.querySelectorAll('.real');
231 for (var i = 0; i < clonelets.length; i++) {
232 clonelets[i].classList.remove('real');
235 this.appendChild(clone);
236 this.doppleganger_ = clone;
241 this.doppleganger_.style.WebkitTransform = 'translate(' + x + 'px, ' +
246 * Destroys the current doppleganger.
248 clearDoppleganger: function() {
249 if (this.doppleganger_) {
250 this.removeChild(this.doppleganger_);
251 this.doppleganger_ = null;
256 * Returns status of doppleganger.
257 * @return {boolean} True if there is a doppleganger showing for |this|.
259 hasDoppleganger: function() {
260 return !!this.doppleganger_;
264 * Cleans up after the drag is over. This is either called when the
265 * drag representation finishes animating to the final position, or when
266 * the next drag starts (if the user starts a 2nd drag very quickly).
269 finalizeDrag_: function() {
270 assert(this.classList.contains('dragging'));
272 var clone = this.dragClone;
273 this.dragClone = null;
275 clone.parentNode.removeChild(clone);
276 this.eventTracker.remove(clone, 'webkitTransitionEnd');
277 this.classList.remove('dragging');
278 if (this.firstChild.finalizeDrag)
279 this.firstChild.finalizeDrag();
283 * Called when the drag representation node is done migrating to its final
285 * @param {Event} e The transition end event.
287 onDragCloneTransitionEnd_: function(e) {
288 if (this.classList.contains('dragging') &&
289 (e.propertyName == 'left' || e.propertyName == 'top' ||
290 e.propertyName == '-webkit-transform')) {
291 this.finalizeDrag_();
296 * Called when an app is removed from Chrome. Animates its disappearance.
297 * @param {boolean=} opt_animate Whether the animation should be animated.
299 doRemove: function(opt_animate) {
301 this.firstChild.classList.add('removing-tile-contents');
303 this.tilePage.removeTile(this, false);
307 * Callback for the webkitAnimationEnd event on the tile's contents.
308 * @param {Event} e The event object.
310 onContentsAnimationEnd_: function(e) {
311 if (this.firstChild.classList.contains('new-tile-contents'))
312 this.firstChild.classList.remove('new-tile-contents');
313 if (this.firstChild.classList.contains('removing-tile-contents'))
314 this.tilePage.removeTile(this, true);
319 * Gives the proportion of the row width that is devoted to a single icon.
320 * @param {number} rowTileCount The number of tiles in a row.
321 * @param {number} tileSpacingFraction The proportion of the tile width which
322 * will be used as spacing between tiles.
323 * @return {number} The ratio between icon width and row width.
325 function tileWidthFraction(rowTileCount, tileSpacingFraction) {
326 return rowTileCount + (rowTileCount - 1) * tileSpacingFraction;
330 * Calculates an assortment of tile-related values for a grid with the
332 * @param {number} width The pixel width of the grid.
333 * @param {number} numRowTiles The number of tiles in a row.
334 * @param {number} tileSpacingFraction The proportion of the tile width which
335 * will be used as spacing between tiles.
336 * @return {Object} A mapping of pixel values.
338 function tileValuesForGrid(width, numRowTiles, tileSpacingFraction) {
339 var tileWidth = width / tileWidthFraction(numRowTiles, tileSpacingFraction);
340 var offsetX = tileWidth * (1 + tileSpacingFraction);
341 var interTileSpacing = offsetX - tileWidth;
344 tileWidth: tileWidth,
346 interTileSpacing: interTileSpacing,
350 // The smallest amount of horizontal blank space to display on the sides when
351 // displaying a wide arrangement. There is an additional 26px of margin from
352 // the tile page padding.
353 var MIN_WIDE_MARGIN = 18;
356 * Creates a new TilePage object. This object contains tiles and controls
358 * @param {Object} gridValues Pixel values that define the size and layout
361 * @extends {HTMLDivElement}
363 function TilePage(gridValues) {
364 var el = cr.doc.createElement('div');
365 el.gridValues_ = gridValues;
366 el.__proto__ = TilePage.prototype;
373 * Takes a collection of grid layout pixel values and updates them with
374 * additional tiling values that are calculated from TilePage constants.
375 * @param {Object} grid The grid layout pixel values to update.
377 TilePage.initGridValues = function(grid) {
378 // The amount of space we need to display a narrow grid (all narrow grids
381 grid.minTileWidth * tileWidthFraction(grid.minColCount,
382 grid.tileSpacingFraction);
383 // The minimum amount of space we need to display a wide grid.
385 grid.minTileWidth * tileWidthFraction(grid.maxColCount,
386 grid.tileSpacingFraction);
387 // The largest we will ever display a wide grid.
389 grid.maxTileWidth * tileWidthFraction(grid.maxColCount,
390 grid.tileSpacingFraction);
391 // Tile-related pixel values for the narrow display.
392 grid.narrowTileValues = tileValuesForGrid(grid.narrowWidth,
394 grid.tileSpacingFraction);
395 // Tile-related pixel values for the minimum narrow display.
396 grid.wideTileValues = tileValuesForGrid(grid.minWideWidth,
398 grid.tileSpacingFraction);
401 TilePage.prototype = {
402 __proto__: HTMLDivElement.prototype,
404 initialize: function() {
405 this.className = 'tile-page';
407 // Div that acts as a custom scrollbar. The scrollbar has to live
408 // outside the content div so it doesn't flicker when scrolling (due to
409 // repainting after the scroll, then repainting again when moved in the
410 // onScroll handler). |scrollbar_| is only aesthetic, and it only
411 // represents the thumb. Actual events are still handled by the invisible
412 // native scrollbars. This div gives us more flexibility with the visuals.
413 this.scrollbar_ = this.ownerDocument.createElement('div');
414 this.scrollbar_.className = 'tile-page-scrollbar';
415 this.scrollbar_.hidden = true;
416 this.appendChild(this.scrollbar_);
418 // This contains everything but the scrollbar.
419 this.content_ = this.ownerDocument.createElement('div');
420 this.content_.className = 'tile-page-content';
421 this.appendChild(this.content_);
423 // Div that sets the vertical position of the tile grid.
424 this.topMargin_ = this.ownerDocument.createElement('div');
425 this.topMargin_.className = 'top-margin';
426 this.content_.appendChild(this.topMargin_);
428 // Div that holds the tiles.
429 this.tileGrid_ = this.ownerDocument.createElement('div');
430 this.tileGrid_.className = 'tile-grid';
431 this.tileGrid_.style.minWidth = this.gridValues_.narrowWidth + 'px';
432 this.tileGrid_.setAttribute('role', 'menu');
433 this.tileGrid_.setAttribute('aria-label',
434 loadTimeData.getString(
435 'tile_grid_screenreader_accessible_description'));
437 this.content_.appendChild(this.tileGrid_);
439 // Ordered list of our tiles.
440 this.tileElements_ = this.tileGrid_.getElementsByClassName('tile real');
441 // Ordered list of the elements which want to accept keyboard focus. These
442 // elements will not be a part of the normal tab order; the tile grid
443 // initially gets focused and then these elements can be focused via the
445 this.focusableElements_ =
446 this.tileGrid_.getElementsByClassName('focusable');
448 // These are properties used in updateTopMargin.
449 this.animatedTopMarginPx_ = 0;
450 this.topMarginPx_ = 0;
452 this.eventTracker = new EventTracker();
453 this.eventTracker.add(window, 'resize', this.onResize_.bind(this));
455 this.addEventListener('DOMNodeInsertedIntoDocument',
456 this.onNodeInsertedIntoDocument_);
458 this.content_.addEventListener('scroll', this.onScroll_.bind(this));
460 this.dragWrapper_ = new cr.ui.DragWrapper(this.tileGrid_, this);
462 this.addEventListener('cardselected', this.handleCardSelection_);
463 this.addEventListener('carddeselected', this.handleCardDeselection_);
464 this.addEventListener('focus', this.handleFocus_);
465 this.addEventListener('keydown', this.handleKeyDown_);
466 this.addEventListener('mousedown', this.handleMouseDown_);
468 this.focusElementIndex_ = -1;
472 return this.tileElements_;
476 return this.tileElements_.length;
480 return Array.prototype.indexOf.call(this.parentNode.children, this) ==
481 ntp.getCardSlider().currentCard;
485 * The size of the margin (unused space) on the sides of the tile grid, in
490 return this.layoutValues_.leftMargin;
494 * Returns the width of the scrollbar, in pixels, if it is active, or 0
498 get scrollbarWidth() {
499 return this.scrollbar_.hidden ? 0 : 13;
503 * Returns any extra padding to insert to the bottom of a tile page. By
504 * default there is none, but subclasses can override.
507 get extraBottomPadding() {
512 * The notification content of this tile (if any, otherwise null).
513 * @type {!HTMLElement}
516 return this.topMargin_.nextElementSibling.id == 'notification-container' ?
517 this.topMargin_.nextElementSibling : null;
520 * The notification content of this tile (if any, otherwise null).
521 * @type {!HTMLElement}
523 set notification(node) {
524 assert(node instanceof HTMLElement, '|node| isn\'t an HTMLElement!');
525 // NOTE: Implicitly removes from DOM if |node| is inside it.
526 this.content_.insertBefore(node, this.topMargin_.nextElementSibling);
527 this.positionNotification_();
531 * Fetches the size, in pixels, of the padding-top of the tile contents.
534 get contentPadding() {
535 if (typeof this.contentPadding_ == 'undefined') {
536 this.contentPadding_ =
537 parseInt(getComputedStyle(this.content_).paddingTop, 10);
539 return this.contentPadding_;
543 * Removes the tilePage from the DOM and cleans up event handlers.
546 // This checks arguments.length as most remove functions have a boolean
547 // |opt_animate| argument, but that's not necesarilly applicable to
548 // removing a tilePage. Selecting a different card in an animated way and
549 // deleting the card afterward is probably a better choice.
550 assert(typeof arguments[0] != 'boolean',
551 'This function takes no |opt_animate| argument.');
553 this.parentNode.removeChild(this);
557 * Cleans up resources that are no longer needed after this TilePage
558 * instance is removed from the DOM.
561 tearDown_: function() {
562 this.eventTracker.removeAll();
566 * Appends a tile to the end of the tile grid.
567 * @param {HTMLElement} tileElement The contents of the tile.
568 * @param {boolean} animate If true, the append will be animated.
571 appendTile: function(tileElement, animate) {
572 this.addTileAt(tileElement, this.tileElements_.length, animate);
576 * Adds the given element to the tile grid.
577 * @param {Node} tileElement The tile object/node to insert.
578 * @param {number} index The location in the tile grid to insert it at.
579 * @param {boolean} animate If true, the tile in question will be
580 * animated (other tiles, if they must reposition, do not animate).
583 addTileAt: function(tileElement, index, animate) {
584 this.classList.remove('animating-tile-page');
586 tileElement.classList.add('new-tile-contents');
588 // Make sure the index is positive and either in the the bounds of
589 // this.tileElements_ or at the end (meaning append).
590 assert(index >= 0 && index <= this.tileElements_.length);
592 var wrapperDiv = new Tile(tileElement);
593 // If is out of the bounds of the tile element list, .insertBefore() will
594 // act just like appendChild().
595 this.tileGrid_.insertBefore(wrapperDiv, this.tileElements_[index]);
596 this.calculateLayoutValues_();
597 this.heightChanged_();
599 this.repositionTiles_();
601 // If this is the first tile being added, make it focusable after add.
602 if (this.focusableElements_.length == 1)
603 this.updateFocusableElement();
604 this.fireAddedEvent(wrapperDiv, index, animate);
608 * Notify interested subscribers that a tile has been removed from this
610 * @param {Tile} tile The newly added tile.
611 * @param {number} index The index of the tile that was added.
612 * @param {boolean} wasAnimated Whether the removal was animated.
614 fireAddedEvent: function(tile, index, wasAnimated) {
615 var e = document.createEvent('Event');
616 e.initEvent('tilePage:tile_added', true, true);
617 e.addedIndex = index;
619 e.wasAnimated = wasAnimated;
620 this.dispatchEvent(e);
624 * Removes the given tile and animates the repositioning of the other tiles.
625 * @param {boolean=} opt_animate Whether the removal should be animated.
626 * @param {boolean=} opt_dontNotify Whether a page should be removed if the
627 * last tile is removed from it.
629 removeTile: function(tile, opt_animate, opt_dontNotify) {
631 this.classList.add('animating-tile-page');
633 var index = tile.index;
634 tile.parentNode.removeChild(tile);
635 this.calculateLayoutValues_();
637 this.updateFocusableElement();
640 this.fireRemovedEvent(tile, index, !!opt_animate);
644 * Notify interested subscribers that a tile has been removed from this
646 * @param {Tile} tile The tile that was removed.
647 * @param {number} oldIndex Where the tile was positioned before removal.
648 * @param {boolean} wasAnimated Whether the removal was animated.
650 fireRemovedEvent: function(tile, oldIndex, wasAnimated) {
651 var e = document.createEvent('Event');
652 e.initEvent('tilePage:tile_removed', true, true);
653 e.removedIndex = oldIndex;
654 e.removedTile = tile;
655 e.wasAnimated = wasAnimated;
656 this.dispatchEvent(e);
660 * Removes all tiles from the page.
662 removeAllTiles: function() {
663 this.tileGrid_.innerHTML = '';
667 * Called when the page is selected (in the card selector).
668 * @param {Event} e A custom cardselected event.
671 handleCardSelection_: function(e) {
672 this.updateFocusableElement();
674 // When we are selected, we re-calculate the layout values. (See comment
676 this.calculateLayoutValues_();
680 * Called when the page loses selection (in the card selector).
681 * @param {Event} e A custom carddeselected event.
684 handleCardDeselection_: function(e) {
685 if (this.currentFocusElement_)
686 this.currentFocusElement_.tabIndex = -1;
690 * When we get focus, pass it on to the focus element.
691 * @param {Event} e The focus event.
694 handleFocus_: function(e) {
695 if (this.focusableElements_.length == 0)
698 this.updateFocusElement_();
702 * Since we are doing custom focus handling, we have to manually
703 * set focusability on click (as well as keyboard nav above).
704 * @param {Event} e The focus event.
707 handleMouseDown_: function(e) {
708 var focusable = findAncestorByClass(e.target, 'focusable');
710 this.focusElementIndex_ =
711 Array.prototype.indexOf.call(this.focusableElements_,
713 this.updateFocusElement_();
715 // This prevents the tile page from getting focus when the user clicks
716 // inside the grid but outside of any tile.
722 * Handle arrow key focus nav.
723 * @param {Event} e The focus event.
726 handleKeyDown_: function(e) {
727 // We only handle up, down, left, right without control keys.
728 if (e.metaKey || e.shiftKey || e.altKey || e.ctrlKey)
731 // Wrap the given index to |this.focusableElements_|.
732 var wrap = function(idx) {
733 return (idx + this.focusableElements_.length) %
734 this.focusableElements_.length;
737 switch (e.keyIdentifier) {
740 var direction = e.keyIdentifier == 'Right' ? 1 : -1;
741 this.focusElementIndex_ = wrap(this.focusElementIndex_ + direction);
745 // Look through all focusable elements. Find the first one that is
746 // in the same column.
747 var direction = e.keyIdentifier == 'Up' ? -1 : 1;
749 Array.prototype.indexOf.call(this.focusableElements_,
750 this.currentFocusElement_);
751 var newFocusIdx = wrap(currentIndex + direction);
752 var tile = this.currentFocusElement_.parentNode;
753 for (;; newFocusIdx = wrap(newFocusIdx + direction)) {
754 var newTile = this.focusableElements_[newFocusIdx].parentNode;
755 var rowTiles = this.layoutValues_.numRowTiles;
756 if ((newTile.index - tile.index) % rowTiles == 0)
760 this.focusElementIndex_ = newFocusIdx;
767 this.updateFocusElement_();
774 * Ensure 0 <= this.focusElementIndex_ < this.focusableElements_.length,
775 * make the focusable element at this.focusElementIndex_ (if any) eligible
776 * for tab focus, and the previously-focused element not eligible.
779 updateFocusableElement: function() {
780 if (this.focusableElements_.length == 0 || !this.selected) {
781 this.focusElementIndex_ = -1;
785 this.focusElementIndex_ = Math.min(this.focusableElements_.length - 1,
786 this.focusElementIndex_);
787 this.focusElementIndex_ = Math.max(0, this.focusElementIndex_);
789 var newFocusElement = this.focusableElements_[this.focusElementIndex_];
790 var lastFocusElement = this.currentFocusElement_;
791 if (lastFocusElement && lastFocusElement != newFocusElement)
792 lastFocusElement.tabIndex = -1;
794 newFocusElement.tabIndex = 1;
798 * Focuses the element at |this.focusElementIndex_|. Makes the previous
799 * focus element, if any, no longer eligible for tab focus.
802 updateFocusElement_: function() {
803 this.updateFocusableElement();
804 if (this.focusElementIndex_ >= 0)
805 this.focusableElements_[this.focusElementIndex_].focus();
809 * The current focus element is that element which is eligible for focus.
810 * @type {HTMLElement} The node.
813 get currentFocusElement_() {
814 return this.querySelector('.focusable[tabindex="1"]');
818 * Makes some calculations for tile layout. These change depending on
819 * height, width, and the number of tiles.
820 * TODO(estade): optimize calls to this function. Do nothing if the page is
821 * hidden, but call before being shown.
824 calculateLayoutValues_: function() {
825 var grid = this.gridValues_;
826 var availableSpace = this.tileGrid_.clientWidth - 2 * MIN_WIDE_MARGIN;
827 var wide = availableSpace >= grid.minWideWidth;
828 var numRowTiles = wide ? grid.maxColCount : grid.minColCount;
830 var effectiveGridWidth = wide ?
831 Math.min(Math.max(availableSpace, grid.minWideWidth),
834 var realTileValues = tileValuesForGrid(effectiveGridWidth, numRowTiles,
835 grid.tileSpacingFraction);
837 // leftMargin centers the grid within the avaiable space.
838 var minMargin = wide ? MIN_WIDE_MARGIN : 0;
841 (this.tileGrid_.clientWidth - effectiveGridWidth) / 2);
843 var rowHeight = this.heightForWidth(realTileValues.tileWidth) +
844 realTileValues.interTileSpacing;
846 this.layoutValues_ = {
847 colWidth: realTileValues.offsetX,
848 gridWidth: effectiveGridWidth,
849 leftMargin: leftMargin,
850 numRowTiles: numRowTiles,
851 rowHeight: rowHeight,
852 tileWidth: realTileValues.tileWidth,
856 // We need to update the top margin as well.
857 this.updateTopMargin_();
859 this.firePageLayoutEvent_();
863 * Dispatches the custom pagelayout event.
866 firePageLayoutEvent_: function() {
867 cr.dispatchSimpleEvent(this, 'pagelayout', true, true);
871 * @return {number} The amount of margin that should be animated (in pixels)
872 * for the current grid layout.
874 getAnimatedLeftMargin_: function() {
875 if (this.layoutValues_.wide)
878 var grid = this.gridValues_;
879 return (grid.minWideWidth - MIN_WIDE_MARGIN - grid.narrowWidth) / 2;
883 * Calculates the x/y coordinates for an element and moves it there.
884 * @param {number} index The index of the element to be positioned.
885 * @param {number} indexOffset If provided, this is added to |index| when
886 * positioning the tile. The effect is that the tile will be positioned
887 * in a non-default location.
890 positionTile_: function(index, indexOffset) {
891 var grid = this.gridValues_;
892 var layout = this.layoutValues_;
894 indexOffset = typeof indexOffset != 'undefined' ? indexOffset : 0;
895 // Add the offset _after_ the modulus division. We might want to show the
896 // tile off the side of the grid.
897 var col = index % layout.numRowTiles + indexOffset;
898 var row = Math.floor(index / layout.numRowTiles);
899 // Calculate the final on-screen position for the tile.
900 var realX = col * layout.colWidth + layout.leftMargin;
901 var realY = row * layout.rowHeight;
903 // Calculate the portion of the tile's position that should be animated.
904 var animatedTileValues = layout.wide ?
905 grid.wideTileValues : grid.narrowTileValues;
906 // Animate the difference between three-wide and six-wide.
907 var animatedLeftMargin = this.getAnimatedLeftMargin_();
908 var animatedX = col * animatedTileValues.offsetX + animatedLeftMargin;
909 var animatedY = row * (this.heightForWidth(animatedTileValues.tileWidth) +
910 animatedTileValues.interTileSpacing);
912 var tile = this.tileElements_[index];
913 tile.setGridPosition(animatedX, animatedY);
914 tile.firstChild.setBounds(layout.tileWidth,
918 // This code calculates whether the tile needs to show a clone of itself
919 // wrapped around the other side of the tile grid.
920 var offTheRight = col == layout.numRowTiles ||
921 (col == layout.numRowTiles - 1 && tile.hasDoppleganger());
922 var offTheLeft = col == -1 || (col == 0 && tile.hasDoppleganger());
923 if (this.isCurrentDragTarget && (offTheRight || offTheLeft)) {
924 var sign = offTheRight ? 1 : -1;
925 tile.showDoppleganger(-layout.numRowTiles * layout.colWidth * sign,
926 layout.rowHeight * sign);
928 tile.clearDoppleganger();
931 if (index == this.tileElements_.length - 1) {
932 this.tileGrid_.style.height = (realY + layout.rowHeight) + 'px';
933 this.queueUpdateScrollbars_();
938 * Gets the index of the tile that should occupy coordinate (x, y). Note
939 * that this function doesn't care where the tiles actually are, and will
940 * return an index even for the space between two tiles. This function is
941 * effectively the inverse of |positionTile_|.
942 * @param {number} x The x coordinate, in pixels, relative to the left of
944 * @param {number} y The y coordinate, in pixels, relative to the top of
948 getWouldBeIndexForPoint_: function(x, y) {
949 var grid = this.gridValues_;
950 var layout = this.layoutValues_;
952 var gridClientRect = this.tileGrid_.getBoundingClientRect();
953 var col = Math.floor((x - gridClientRect.left - layout.leftMargin) /
955 if (col < 0 || col >= layout.numRowTiles)
959 col = layout.numRowTiles - 1 - col;
961 var row = Math.floor((y - gridClientRect.top) / layout.rowHeight);
962 return row * layout.numRowTiles + col;
966 * Window resize event handler. Window resizes may trigger re-layouts.
967 * @param {Object} e The resize event.
969 onResize_: function(e) {
970 if (this.lastWidth_ == this.clientWidth &&
971 this.lastHeight_ == this.clientHeight) {
975 this.calculateLayoutValues_();
977 this.lastWidth_ = this.clientWidth;
978 this.lastHeight_ = this.clientHeight;
979 this.classList.add('animating-tile-page');
980 this.heightChanged_();
982 this.positionNotification_();
983 this.repositionTiles_();
987 * The tile grid has an image mask which fades at the edges. We only show
988 * the mask when there is an active drag; it obscures doppleganger tiles
989 * as they enter or exit the grid.
992 updateMask_: function() {
993 if (!this.isCurrentDragTarget) {
994 this.tileGrid_.style.WebkitMaskBoxImage = '';
998 var leftMargin = this.layoutValues_.leftMargin;
999 // The fade distance is the space between tiles.
1000 var fadeDistance = (this.gridValues_.tileSpacingFraction *
1001 this.layoutValues_.tileWidth);
1002 fadeDistance = Math.min(leftMargin, fadeDistance);
1003 // On Skia we don't use any fade because it works very poorly. See
1004 // http://crbug.com/99373
1008 '-webkit-linear-gradient(left,' +
1010 'transparent ' + (leftMargin - fadeDistance) + 'px, ' +
1011 'black ' + leftMargin + 'px, ' +
1012 'black ' + (this.tileGrid_.clientWidth - leftMargin) + 'px, ' +
1013 'transparent ' + (this.tileGrid_.clientWidth - leftMargin +
1014 fadeDistance) + 'px, ' +
1016 this.tileGrid_.style.WebkitMaskBoxImage = gradient;
1019 updateTopMargin_: function() {
1020 var layout = this.layoutValues_;
1022 // The top margin is set so that the vertical midpoint of the grid will
1023 // be 1/3 down the page.
1024 var numTiles = this.tileCount +
1025 (this.isCurrentDragTarget && !this.withinPageDrag_ ? 1 : 0);
1026 var numRows = Math.max(1, Math.ceil(numTiles / layout.numRowTiles));
1027 var usedHeight = layout.rowHeight * numRows;
1028 var newMargin = document.documentElement.clientHeight / 3 -
1029 usedHeight / 3 - this.contentPadding;
1030 // The 'height' style attribute of topMargin is non-zero to work around
1031 // webkit's collapsing margin behavior, so we have to factor that into
1032 // our calculations here.
1033 newMargin = Math.max(newMargin, 0) - this.topMargin_.offsetHeight;
1035 // |newMargin| is the final margin we actually want to show. However,
1036 // part of that should be animated and part should not (for the same
1037 // reason as with leftMargin). The approach is to consider differences
1038 // when the layout changes from wide to narrow or vice versa as
1039 // 'animatable'. These differences accumulate in animatedTopMarginPx_,
1040 // while topMarginPx_ caches the real (total) margin. Either of these
1041 // calculations may come out to be negative, so we use margins as the
1044 if (typeof this.topMarginIsForWide_ == 'undefined')
1045 this.topMarginIsForWide_ = layout.wide;
1046 if (this.topMarginIsForWide_ != layout.wide) {
1047 this.animatedTopMarginPx_ += newMargin - this.topMarginPx_;
1048 this.topMargin_.style.marginBottom = toCssPx(this.animatedTopMarginPx_);
1051 this.topMarginIsForWide_ = layout.wide;
1052 this.topMarginPx_ = newMargin;
1053 this.topMargin_.style.marginTop =
1054 toCssPx(this.topMarginPx_ - this.animatedTopMarginPx_);
1058 * Position the notification if there's one showing.
1060 positionNotification_: function() {
1061 var notification = this.notification;
1062 if (!notification || notification.hidden)
1065 // Update the horizontal position.
1066 var animatedLeftMargin = this.getAnimatedLeftMargin_();
1067 notification.style.WebkitMarginStart = animatedLeftMargin + 'px';
1068 var leftOffset = (this.layoutValues_.leftMargin - animatedLeftMargin) *
1070 notification.style.WebkitTransform = 'translateX(' + leftOffset + 'px)';
1072 // Update the allowable widths of the text.
1073 var buttonWidth = notification.querySelector('button').offsetWidth + 8;
1074 notification.querySelector('span').style.maxWidth =
1075 this.layoutValues_.gridWidth - buttonWidth + 'px';
1077 // This makes sure the text doesn't condense smaller than the narrow size
1078 // of the grid (e.g. when a user makes the window really small).
1079 notification.style.minWidth =
1080 this.gridValues_.narrowWidth - buttonWidth + 'px';
1082 // Update the top position.
1083 notification.style.marginTop = -notification.offsetHeight + 'px';
1087 * Handles final setup that can only happen after |this| is inserted into
1091 onNodeInsertedIntoDocument_: function(e) {
1092 this.calculateLayoutValues_();
1093 this.heightChanged_();
1097 * Called when the height of |this| has changed: update the size of
1101 heightChanged_: function() {
1102 // The tile grid will expand to the bottom footer, or enough to hold all
1103 // the tiles, whichever is greater. It would be nicer if tilePage were
1104 // a flex box, and the tile grid could be box-flex: 1, but this exposes a
1105 // bug where repositioning tiles will cause the scroll position to reset.
1106 this.tileGrid_.style.minHeight = (this.clientHeight -
1107 this.tileGrid_.offsetTop - this.content_.offsetTop -
1108 this.extraBottomPadding -
1109 (this.footerNode_ ? this.footerNode_.clientHeight : 0)) + 'px';
1113 * Places an element at the bottom of the content div. Used in bare-minimum
1114 * mode to hold #footer.
1115 * @param {HTMLElement} footerNode The node to append to content.
1117 appendFooter: function(footerNode) {
1118 this.footerNode_ = footerNode;
1119 this.content_.appendChild(footerNode);
1123 * Scrolls the page in response to an mousewheel event, although the event
1124 * may have been triggered on a different element. Return true if the
1125 * event triggered scrolling, and false otherwise.
1126 * This is called explicitly, which allows a consistent experience whether
1127 * the user scrolls on the page or on the page switcher, because this
1128 * function provides a common conversion factor between wheel delta and
1130 * @param {Event} e The mousewheel event.
1132 handleMouseWheel: function(e) {
1133 if (e.wheelDeltaY == 0)
1136 this.content_.scrollTop -= e.wheelDeltaY / 3;
1141 * Handler for the 'scroll' event on |content_|.
1142 * @param {Event} e The scroll event.
1145 onScroll_: function(e) {
1146 this.queueUpdateScrollbars_();
1150 * ID of scrollbar update timer. If 0, there's no scrollbar re-calc queued.
1153 scrollbarUpdate_: 0,
1156 * Queues an update on the custom scrollbar. Used for two reasons: first,
1157 * coalescing of multiple updates, and second, because action like
1158 * repositioning a tile can require a delay before they affect values
1159 * like clientHeight.
1162 queueUpdateScrollbars_: function() {
1163 if (this.scrollbarUpdate_)
1166 this.scrollbarUpdate_ = window.setTimeout(
1167 this.doUpdateScrollbars_.bind(this), 0);
1171 * Does the work of calculating the visibility, height and position of the
1172 * scrollbar thumb (there is no track or buttons).
1175 doUpdateScrollbars_: function() {
1176 this.scrollbarUpdate_ = 0;
1178 var content = this.content_;
1180 // Adjust scroll-height to account for possible header-bar.
1181 var adjustedScrollHeight = content.scrollHeight - content.offsetTop;
1183 if (adjustedScrollHeight <= content.clientHeight) {
1184 this.scrollbar_.hidden = true;
1187 this.scrollbar_.hidden = false;
1190 var thumbTop = content.offsetTop +
1191 content.scrollTop / adjustedScrollHeight * content.clientHeight;
1192 var thumbHeight = content.clientHeight / adjustedScrollHeight *
1195 this.scrollbar_.style.top = thumbTop + 'px';
1196 this.scrollbar_.style.height = thumbHeight + 'px';
1197 this.firePageLayoutEvent_();
1201 * Get the height for a tile of a certain width. Override this function to
1202 * get non-square tiles.
1203 * @param {number} width The pixel width of a tile.
1204 * @return {number} The height for |width|.
1206 heightForWidth: function(width) {
1212 get isCurrentDragTarget() {
1213 return this.dragWrapper_.isCurrentDragTarget;
1217 * Thunk for dragleave events fired on |tileGrid_|.
1218 * @param {Event} e A MouseEvent for the drag.
1220 doDragLeave: function(e) {
1225 * Performs all actions necessary when a drag enters the tile page.
1226 * @param {Event} e A mouseover event for the drag enter.
1228 doDragEnter: function(e) {
1229 // Applies the mask so doppleganger tiles disappear into the fog.
1232 this.classList.add('animating-tile-page');
1233 this.withinPageDrag_ = this.contains(currentlyDraggingTile);
1234 this.dragItemIndex_ = this.withinPageDrag_ ?
1235 currentlyDraggingTile.index : this.tileElements_.length;
1236 this.currentDropIndex_ = this.dragItemIndex_;
1238 // The new tile may change the number of rows, hence the top margin
1240 if (!this.withinPageDrag_)
1241 this.updateTopMargin_();
1247 * Performs all actions necessary when the user moves the cursor during
1248 * a drag over the tile page.
1249 * @param {Event} e A mouseover event for the drag over.
1251 doDragOver: function(e) {
1254 this.setDropEffect(e.dataTransfer);
1255 var newDragIndex = this.getWouldBeIndexForPoint_(e.pageX, e.pageY);
1256 if (newDragIndex < 0 || newDragIndex >= this.tileElements_.length)
1257 newDragIndex = this.dragItemIndex_;
1258 this.updateDropIndicator_(newDragIndex);
1262 * Performs all actions necessary when the user completes a drop.
1263 * @param {Event} e A mouseover event for the drag drop.
1265 doDrop: function(e) {
1266 e.stopPropagation();
1269 var index = this.currentDropIndex_;
1270 // Only change data if this was not a 'null drag'.
1271 if (!((index == this.dragItemIndex_) && this.withinPageDrag_)) {
1272 var adjustedIndex = this.currentDropIndex_ +
1273 (index > this.dragItemIndex_ ? 1 : 0);
1274 if (this.withinPageDrag_) {
1275 this.tileGrid_.insertBefore(
1276 currentlyDraggingTile,
1277 this.tileElements_[adjustedIndex]);
1278 this.tileMoved(currentlyDraggingTile, this.dragItemIndex_);
1280 var originalPage = currentlyDraggingTile ?
1281 currentlyDraggingTile.tilePage : null;
1282 this.addDragData(e.dataTransfer, adjustedIndex);
1284 originalPage.cleanupDrag();
1287 // Dropping the icon may cause topMargin to change, but changing it
1288 // now would cause everything to move (annoying), so we leave it
1289 // alone. The top margin will be re-calculated next time the window is
1290 // resized or the page is selected.
1293 this.classList.remove('animating-tile-page');
1298 * Appends the currently dragged tile to the end of the page. Called
1299 * from outside the page, e.g. when dropping on a nav dot.
1301 appendDraggingTile: function() {
1302 var originalPage = currentlyDraggingTile.tilePage;
1303 if (originalPage == this)
1306 this.addDragData(null, this.tileElements_.length);
1308 originalPage.cleanupDrag();
1312 * Makes sure all the tiles are in the right place after a drag is over.
1314 cleanupDrag: function() {
1315 this.repositionTiles_(currentlyDraggingTile);
1316 // Remove the drag mask.
1321 * Reposition all the tiles (possibly ignoring one).
1322 * @param {?Node} ignoreNode An optional node to ignore.
1325 repositionTiles_: function(ignoreNode) {
1326 for (var i = 0; i < this.tileElements_.length; i++) {
1327 if (!ignoreNode || ignoreNode !== this.tileElements_[i])
1328 this.positionTile_(i);
1333 * Updates the visual indicator for the drop location for the active drag.
1334 * @param {Event} e A MouseEvent for the drag.
1337 updateDropIndicator_: function(newDragIndex) {
1338 var oldDragIndex = this.currentDropIndex_;
1339 if (newDragIndex == oldDragIndex)
1342 var repositionStart = Math.min(newDragIndex, oldDragIndex);
1343 var repositionEnd = Math.max(newDragIndex, oldDragIndex);
1345 for (var i = repositionStart; i <= repositionEnd; i++) {
1346 if (i == this.dragItemIndex_)
1348 else if (i > this.dragItemIndex_)
1349 var adjustment = i <= newDragIndex ? -1 : 0;
1351 var adjustment = i >= newDragIndex ? 1 : 0;
1353 this.positionTile_(i, adjustment);
1355 this.currentDropIndex_ = newDragIndex;
1359 * Checks if a page can accept a drag with the given data.
1360 * @param {Event} e The drag event if the drag object. Implementations will
1361 * likely want to check |e.dataTransfer|.
1362 * @return {boolean} True if this page can handle the drag.
1364 shouldAcceptDrag: function(e) {
1369 * Called to accept a drag drop. Will not be called for in-page drops.
1370 * @param {Object} dataTransfer The data transfer object that holds the drop
1371 * data. This should only be used if currentlyDraggingTile is null.
1372 * @param {number} index The tile index at which the drop occurred.
1374 addDragData: function(dataTransfer, index) {
1379 * Called when a tile has been moved (via dragging). Override this to make
1381 * @param {Node} draggedTile The tile that was dropped.
1382 * @param {number} prevIndex The previous index of the tile.
1384 tileMoved: function(draggedTile, prevIndex) {
1388 * Sets the drop effect on |dataTransfer| to the desired value (e.g.
1390 * @param {Object} dataTransfer The drag event dataTransfer object.
1392 setDropEffect: function(dataTransfer) {
1398 getCurrentlyDraggingTile: getCurrentlyDraggingTile,
1399 setCurrentDropEffect: setCurrentDropEffect,