Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(1878)

Side by Side Diff: chrome/browser/resources/file_manager/js/photo/mosaic_mode.js

Issue 39123003: [Files.app] Split the JavaScript files into subdirectories: common, background, and foreground (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: fixed test failure. Created 7 years, 1 month ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
(Empty)
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.
4
5 'use strict';
6
7 /**
8 * @param {Element} container Content container.
9 * @param {cr.ui.ArrayDataModel} dataModel Data model.
10 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
11 * @param {MetadataCache} metadataCache Metadata cache.
12 * @param {function} toggleMode Function to switch to the Slide mode.
13 * @constructor
14 */
15 function MosaicMode(
16 container, dataModel, selectionModel, metadataCache, toggleMode) {
17 this.mosaic_ = new Mosaic(
18 container.ownerDocument, dataModel, selectionModel, metadataCache);
19 container.appendChild(this.mosaic_);
20
21 this.toggleMode_ = toggleMode;
22 this.mosaic_.addEventListener('dblclick', this.toggleMode_);
23 this.showingTimeoutID_ = null;
24 }
25
26 /**
27 * @return {Mosaic} The mosaic control.
28 */
29 MosaicMode.prototype.getMosaic = function() { return this.mosaic_ };
30
31 /**
32 * @return {string} Mode name.
33 */
34 MosaicMode.prototype.getName = function() { return 'mosaic' };
35
36 /**
37 * @return {string} Mode title.
38 */
39 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC' };
40
41 /**
42 * Execute an action (this mode has no busy state).
43 * @param {function} action Action to execute.
44 */
45 MosaicMode.prototype.executeWhenReady = function(action) { action() };
46
47 /**
48 * @return {boolean} Always true (no toolbar fading in this mode).
49 */
50 MosaicMode.prototype.hasActiveTool = function() { return true };
51
52 /**
53 * Keydown handler.
54 *
55 * @param {Event} event Event.
56 * @return {boolean} True if processed.
57 */
58 MosaicMode.prototype.onKeyDown = function(event) {
59 switch (util.getKeyModifiers(event) + event.keyIdentifier) {
60 case 'Enter':
61 this.toggleMode_();
62 return true;
63 }
64 return this.mosaic_.onKeyDown(event);
65 };
66
67 ////////////////////////////////////////////////////////////////////////////////
68
69 /**
70 * Mosaic control.
71 *
72 * @param {Document} document Document.
73 * @param {cr.ui.ArrayDataModel} dataModel Data model.
74 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
75 * @param {MetadataCache} metadataCache Metadata cache.
76 * @return {Element} Mosaic element.
77 * @constructor
78 */
79 function Mosaic(document, dataModel, selectionModel, metadataCache) {
80 var self = document.createElement('div');
81 Mosaic.decorate(self, dataModel, selectionModel, metadataCache);
82 return self;
83 }
84
85 /**
86 * Inherit from HTMLDivElement.
87 */
88 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
89
90 /**
91 * Default layout delay in ms.
92 * @const
93 * @type {number}
94 */
95 Mosaic.LAYOUT_DELAY = 200;
96
97 /**
98 * Smooth scroll animation duration when scrolling using keyboard or
99 * clicking on a partly visible tile. In ms.
100 * @const
101 * @type {number}
102 */
103 Mosaic.ANIMATED_SCROLL_DURATION = 500;
104
105 /**
106 * Decorate a Mosaic instance.
107 *
108 * @param {Mosaic} self Self pointer.
109 * @param {cr.ui.ArrayDataModel} dataModel Data model.
110 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
111 * @param {MetadataCache} metadataCache Metadata cache.
112 */
113 Mosaic.decorate = function(self, dataModel, selectionModel, metadataCache) {
114 self.__proto__ = Mosaic.prototype;
115 self.className = 'mosaic';
116
117 self.dataModel_ = dataModel;
118 self.selectionModel_ = selectionModel;
119 self.metadataCache_ = metadataCache;
120
121 // Initialization is completed lazily on the first call to |init|.
122 };
123
124 /**
125 * Initialize the mosaic element.
126 */
127 Mosaic.prototype.init = function() {
128 if (this.tiles_)
129 return; // Already initialized, nothing to do.
130
131 this.layoutModel_ = new Mosaic.Layout();
132 this.onResize_();
133
134 this.selectionController_ =
135 new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
136
137 this.tiles_ = [];
138 for (var i = 0; i != this.dataModel_.length; i++)
139 this.tiles_.push(new Mosaic.Tile(this, this.dataModel_.item(i)));
140
141 this.selectionModel_.selectedIndexes.forEach(function(index) {
142 this.tiles_[index].select(true);
143 }.bind(this));
144
145 this.initTiles_(this.tiles_);
146
147 // The listeners might be called while some tiles are still loading.
148 this.initListeners_();
149 };
150
151 /**
152 * @return {boolean} Whether mosaic is initialized.
153 */
154 Mosaic.prototype.isInitialized = function() {
155 return !!this.tiles_;
156 };
157
158 /**
159 * Start listening to events.
160 *
161 * We keep listening to events even when the mosaic is hidden in order to
162 * keep the layout up to date.
163 *
164 * @private
165 */
166 Mosaic.prototype.initListeners_ = function() {
167 this.ownerDocument.defaultView.addEventListener(
168 'resize', this.onResize_.bind(this));
169
170 var mouseEventBound = this.onMouseEvent_.bind(this);
171 this.addEventListener('mousemove', mouseEventBound);
172 this.addEventListener('mousedown', mouseEventBound);
173 this.addEventListener('mouseup', mouseEventBound);
174 this.addEventListener('scroll', this.onScroll_.bind(this));
175
176 this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
177 this.selectionModel_.addEventListener('leadIndexChange',
178 this.onLeadChange_.bind(this));
179
180 this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
181 this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
182 };
183
184 /**
185 * Smoothly scrolls the container to the specified position using
186 * f(x) = sqrt(x) speed function normalized to animation duration.
187 * @param {number} targetPosition Horizontal scroll position in pixels.
188 */
189 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
190 if (this.scrollAnimation_) {
191 webkitCancelAnimationFrame(this.scrollAnimation_);
192 this.scrollAnimation_ = null;
193 }
194
195 // Mouse move events are fired without touching the mouse because of scrolling
196 // the container. Therefore, these events have to be suppressed.
197 this.suppressHovering_ = true;
198
199 // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
200 var integral = function(t1, t2) {
201 return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
202 2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
203 };
204
205 var delta = targetPosition - this.scrollLeft;
206 var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
207 var startTime = Date.now();
208 var lastPosition = 0;
209 var scrollOffset = this.scrollLeft;
210
211 var animationFrame = function() {
212 var position = Date.now() - startTime;
213 var step = factor *
214 integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
215 Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
216 scrollOffset += step;
217
218 var oldScrollLeft = this.scrollLeft;
219 var newScrollLeft = Math.round(scrollOffset);
220
221 if (oldScrollLeft != newScrollLeft)
222 this.scrollLeft = newScrollLeft;
223
224 if (step == 0 || this.scrollLeft != newScrollLeft) {
225 this.scrollAnimation_ = null;
226 // Release the hovering lock after a safe delay to avoid hovering
227 // a tile because of altering |this.scrollLeft|.
228 setTimeout(function() {
229 if (!this.scrollAnimation_)
230 this.suppressHovering_ = false;
231 }.bind(this), 100);
232 } else {
233 // Continue the animation.
234 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
235 }
236
237 lastPosition = position;
238 }.bind(this);
239
240 // Start the animation.
241 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
242 };
243
244 /**
245 * @return {Mosaic.Tile} Selected tile or undefined if no selection.
246 */
247 Mosaic.prototype.getSelectedTile = function() {
248 return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
249 };
250
251 /**
252 * @param {number} index Tile index.
253 * @return {Rect} Tile's image rectangle.
254 */
255 Mosaic.prototype.getTileRect = function(index) {
256 var tile = this.tiles_[index];
257 return tile && tile.getImageRect();
258 };
259
260 /**
261 * @param {number} index Tile index.
262 * Scroll the given tile into the viewport.
263 */
264 Mosaic.prototype.scrollIntoView = function(index) {
265 var tile = this.tiles_[index];
266 if (tile) tile.scrollIntoView();
267 };
268
269 /**
270 * Initializes multiple tiles.
271 *
272 * @param {Array.<Mosaic.Tile>} tiles Array of tiles.
273 * @param {function()=} opt_callback Completion callback.
274 * @private
275 */
276 Mosaic.prototype.initTiles_ = function(tiles, opt_callback) {
277 // We do not want to use tile indices in asynchronous operations because they
278 // do not survive data model splices. Copy tile references instead.
279 tiles = tiles.slice();
280
281 // Throttle the metadata access so that we do not overwhelm the file system.
282 var MAX_CHUNK_SIZE = 10;
283
284 var loadChunk = function() {
285 if (!tiles.length) {
286 if (opt_callback) opt_callback();
287 return;
288 }
289 var chunkSize = Math.min(tiles.length, MAX_CHUNK_SIZE);
290 var loaded = 0;
291 for (var i = 0; i != chunkSize; i++) {
292 this.initTile_(tiles.shift(), function() {
293 if (++loaded == chunkSize) {
294 this.layout();
295 loadChunk();
296 }
297 }.bind(this));
298 }
299 }.bind(this);
300
301 loadChunk();
302 };
303
304 /**
305 * Initializes a single tile.
306 *
307 * @param {Mosaic.Tile} tile Tile.
308 * @param {function()} callback Completion callback.
309 * @private
310 */
311 Mosaic.prototype.initTile_ = function(tile, callback) {
312 var url = tile.getItem().getUrl();
313 var onImageMeasured = callback;
314 this.metadataCache_.get(url, Gallery.METADATA_TYPE,
315 function(metadata) {
316 tile.init(metadata, onImageMeasured);
317 });
318 };
319
320 /**
321 * Reload all tiles.
322 */
323 Mosaic.prototype.reload = function() {
324 this.layoutModel_.reset_();
325 this.tiles_.forEach(function(t) { t.markUnloaded() });
326 this.initTiles_(this.tiles_);
327 };
328
329 /**
330 * Layout the tiles in the order of their indices.
331 *
332 * Starts where it last stopped (at #0 the first time).
333 * Stops when all tiles are processed or when the next tile is still loading.
334 */
335 Mosaic.prototype.layout = function() {
336 if (this.layoutTimer_) {
337 clearTimeout(this.layoutTimer_);
338 this.layoutTimer_ = null;
339 }
340 while (true) {
341 var index = this.layoutModel_.getTileCount();
342 if (index == this.tiles_.length)
343 break; // All tiles done.
344 var tile = this.tiles_[index];
345 if (!tile.isInitialized())
346 break; // Next layout will try to restart from here.
347 this.layoutModel_.add(tile, index + 1 == this.tiles_.length);
348 }
349 this.loadVisibleTiles_();
350 };
351
352 /**
353 * Schedule the layout.
354 *
355 * @param {number=} opt_delay Delay in ms.
356 */
357 Mosaic.prototype.scheduleLayout = function(opt_delay) {
358 if (!this.layoutTimer_) {
359 this.layoutTimer_ = setTimeout(function() {
360 this.layoutTimer_ = null;
361 this.layout();
362 }.bind(this), opt_delay || 0);
363 }
364 };
365
366 /**
367 * Resize handler.
368 *
369 * @private
370 */
371 Mosaic.prototype.onResize_ = function() {
372 this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
373 (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
374 this.scheduleLayout();
375 };
376
377 /**
378 * Mouse event handler.
379 *
380 * @param {Event} event Event.
381 * @private
382 */
383 Mosaic.prototype.onMouseEvent_ = function(event) {
384 // Navigating with mouse, enable hover state.
385 if (!this.suppressHovering_)
386 this.classList.add('hover-visible');
387
388 if (event.type == 'mousemove')
389 return;
390
391 var index = -1;
392 for (var target = event.target;
393 target && (target != this);
394 target = target.parentNode) {
395 if (target.classList.contains('mosaic-tile')) {
396 index = this.dataModel_.indexOf(target.getItem());
397 break;
398 }
399 }
400 this.selectionController_.handlePointerDownUp(event, index);
401 };
402
403 /**
404 * Scroll handler.
405 * @private
406 */
407 Mosaic.prototype.onScroll_ = function() {
408 requestAnimationFrame(function() {
409 this.loadVisibleTiles_();
410 }.bind(this));
411 };
412
413 /**
414 * Selection change handler.
415 *
416 * @param {Event} event Event.
417 * @private
418 */
419 Mosaic.prototype.onSelection_ = function(event) {
420 for (var i = 0; i != event.changes.length; i++) {
421 var change = event.changes[i];
422 var tile = this.tiles_[change.index];
423 if (tile) tile.select(change.selected);
424 }
425 };
426
427 /**
428 * Lead item change handler.
429 *
430 * @param {Event} event Event.
431 * @private
432 */
433 Mosaic.prototype.onLeadChange_ = function(event) {
434 var index = event.newValue;
435 if (index >= 0) {
436 var tile = this.tiles_[index];
437 if (tile) tile.scrollIntoView();
438 }
439 };
440
441 /**
442 * Splice event handler.
443 *
444 * @param {Event} event Event.
445 * @private
446 */
447 Mosaic.prototype.onSplice_ = function(event) {
448 var index = event.index;
449 this.layoutModel_.invalidateFromTile_(index);
450
451 if (event.removed.length) {
452 for (var t = 0; t != event.removed.length; t++)
453 this.removeChild(this.tiles_[index + t]);
454
455 this.tiles_.splice(index, event.removed.length);
456 this.scheduleLayout(Mosaic.LAYOUT_DELAY);
457 }
458
459 if (event.added.length) {
460 var newTiles = [];
461 for (var t = 0; t != event.added.length; t++)
462 newTiles.push(new Mosaic.Tile(this, this.dataModel_.item(index + t)));
463
464 this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
465 this.initTiles_(newTiles);
466 }
467
468 if (this.tiles_.length != this.dataModel_.length)
469 console.error('Mosaic is out of sync');
470 };
471
472 /**
473 * Content change handler.
474 *
475 * @param {Event} event Event.
476 * @private
477 */
478 Mosaic.prototype.onContentChange_ = function(event) {
479 if (!this.tiles_)
480 return;
481
482 if (!event.metadata)
483 return; // Thumbnail unchanged, nothing to do.
484
485 var index = this.dataModel_.indexOf(event.item);
486 if (index != this.selectionModel_.selectedIndex)
487 console.error('Content changed for unselected item');
488
489 this.layoutModel_.invalidateFromTile_(index);
490 this.tiles_[index].init(event.metadata, function() {
491 this.tiles_[index].unload();
492 this.tiles_[index].load(
493 Mosaic.Tile.LoadMode.HIGH_DPI,
494 this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
495 }.bind(this));
496 };
497
498 /**
499 * Keydown event handler.
500 *
501 * @param {Event} event Event.
502 * @return {boolean} True if the event has been consumed.
503 */
504 Mosaic.prototype.onKeyDown = function(event) {
505 this.selectionController_.handleKeyDown(event);
506 if (event.defaultPrevented) // Navigating with keyboard, hide hover state.
507 this.classList.remove('hover-visible');
508 return event.defaultPrevented;
509 };
510
511 /**
512 * @return {boolean} True if the mosaic zoom effect can be applied. It is
513 * too slow if there are to many images.
514 * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
515 */
516 Mosaic.prototype.canZoom = function() {
517 return this.tiles_.length < 100;
518 };
519
520 /**
521 * Show the mosaic.
522 */
523 Mosaic.prototype.show = function() {
524 var duration = ImageView.MODE_TRANSITION_DURATION;
525 if (this.canZoom()) {
526 // Fade in in parallel with the zoom effect.
527 this.setAttribute('visible', 'zooming');
528 } else {
529 // Mosaic is not animating but the large image is. Fade in the mosaic
530 // shortly before the large image animation is done.
531 duration -= 100;
532 }
533 this.showingTimeoutID_ = setTimeout(function() {
534 this.showingTimeoutID_ = null;
535 // Make the selection visible.
536 // If the mosaic is not animated it will start fading in now.
537 this.setAttribute('visible', 'normal');
538 this.loadVisibleTiles_();
539 }.bind(this), duration);
540 };
541
542 /**
543 * Hide the mosaic.
544 */
545 Mosaic.prototype.hide = function() {
546 if (this.showingTimeoutID_ != null) {
547 clearTimeout(this.showingTimeoutID_);
548 this.showingTimeoutID_ = null;
549 }
550 this.removeAttribute('visible');
551 };
552
553 /**
554 * Checks if the mosaic view is visible.
555 * @return {boolean} True if visible, false otherwise.
556 * @private
557 */
558 Mosaic.prototype.isVisible_ = function() {
559 return this.hasAttribute('visible');
560 };
561
562 /**
563 * Loads visible tiles. Ignores consecutive calls. Does not reload already
564 * loaded images.
565 * @private
566 */
567 Mosaic.prototype.loadVisibleTiles_ = function() {
568 if (this.loadVisibleTilesSuppressed_) {
569 this.loadVisibleTilesScheduled_ = true;
570 return;
571 }
572
573 this.loadVisibleTilesSuppressed_ = true;
574 this.loadVisibleTilesScheduled_ = false;
575 setTimeout(function() {
576 this.loadVisibleTilesSuppressed_ = false;
577 if (this.loadVisibleTilesScheduled_)
578 this.loadVisibleTiles_();
579 }.bind(this), 100);
580
581 // Tiles only in the viewport (visible).
582 var visibleRect = new Rect(0,
583 0,
584 this.clientWidth,
585 this.clientHeight);
586
587 // Tiles in the viewport and also some distance on the left and right.
588 var renderableRect = new Rect(-this.clientWidth,
589 0,
590 3 * this.clientWidth,
591 this.clientHeight);
592
593 // Unload tiles out of scope.
594 for (var index = 0; index < this.tiles_.length; index++) {
595 var tile = this.tiles_[index];
596 var imageRect = tile.getImageRect();
597 // Unload a thumbnail.
598 if (imageRect && !imageRect.intersects(renderableRect))
599 tile.unload();
600 }
601
602 // Load the visible tiles first.
603 var allVisibleLoaded = true;
604 // Show high-dpi only when the mosaic view is visible.
605 var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
606 Mosaic.Tile.LoadMode.LOW_DPI;
607 for (var index = 0; index < this.tiles_.length; index++) {
608 var tile = this.tiles_[index];
609 var imageRect = tile.getImageRect();
610 // Load a thumbnail.
611 if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
612 imageRect.intersects(visibleRect)) {
613 tile.load(loadMode, function() {});
614 allVisibleLoaded = false;
615 }
616 }
617
618 // Load also another, nearby, if the visible has been already loaded.
619 if (allVisibleLoaded) {
620 for (var index = 0; index < this.tiles_.length; index++) {
621 var tile = this.tiles_[index];
622 var imageRect = tile.getImageRect();
623 // Load a thumbnail.
624 if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
625 imageRect.intersects(renderableRect)) {
626 tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
627 }
628 }
629 }
630 };
631
632 /**
633 * Apply or reset the zoom transform.
634 *
635 * @param {Rect} tileRect Tile rectangle. Reset the transform if null.
636 * @param {Rect} imageRect Large image rectangle. Reset the transform if null.
637 * @param {boolean=} opt_instant True of the transition should be instant.
638 */
639 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
640 if (opt_instant) {
641 this.style.webkitTransitionDuration = '0';
642 } else {
643 this.style.webkitTransitionDuration =
644 ImageView.MODE_TRANSITION_DURATION + 'ms';
645 }
646
647 if (this.canZoom() && tileRect && imageRect) {
648 var scaleX = imageRect.width / tileRect.width;
649 var scaleY = imageRect.height / tileRect.height;
650 var shiftX = (imageRect.left + imageRect.width / 2) -
651 (tileRect.left + tileRect.width / 2);
652 var shiftY = (imageRect.top + imageRect.height / 2) -
653 (tileRect.top + tileRect.height / 2);
654 this.style.webkitTransform =
655 'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
656 'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
657 } else {
658 this.style.webkitTransform = '';
659 }
660 };
661
662 ////////////////////////////////////////////////////////////////////////////////
663
664 /**
665 * Creates a selection controller that is to be used with grid.
666 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
667 * interact with.
668 * @param {Mosaic.Layout} layoutModel The layout model to use.
669 * @constructor
670 * @extends {!cr.ui.ListSelectionController}
671 */
672 Mosaic.SelectionController = function(selectionModel, layoutModel) {
673 cr.ui.ListSelectionController.call(this, selectionModel);
674 this.layoutModel_ = layoutModel;
675 };
676
677 /**
678 * Extends cr.ui.ListSelectionController.
679 */
680 Mosaic.SelectionController.prototype.__proto__ =
681 cr.ui.ListSelectionController.prototype;
682
683 /** @override */
684 Mosaic.SelectionController.prototype.getLastIndex = function() {
685 return this.layoutModel_.getLaidOutTileCount() - 1;
686 };
687
688 /** @override */
689 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
690 return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
691 };
692
693 /** @override */
694 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
695 return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
696 };
697
698 /** @override */
699 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
700 return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
701 };
702
703 /** @override */
704 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
705 return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
706 };
707
708 ////////////////////////////////////////////////////////////////////////////////
709
710 /**
711 * Mosaic layout.
712 *
713 * @param {string=} opt_mode Layout mode.
714 * @param {Mosaic.Density=} opt_maxDensity Layout density.
715 * @constructor
716 */
717 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
718 this.mode_ = opt_mode || Mosaic.Layout.MODE_TENTATIVE;
719 this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
720 this.reset_();
721 };
722
723 /**
724 * Blank space at the top of the mosaic element. We do not do that in CSS
725 * to make transition effects easier.
726 */
727 Mosaic.Layout.PADDING_TOP = 50;
728
729 /**
730 * Blank space at the bottom of the mosaic element.
731 */
732 Mosaic.Layout.PADDING_BOTTOM = 50;
733
734 /**
735 * Horizontal and vertical spacing between images. Should be kept in sync
736 * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
737 */
738 Mosaic.Layout.SPACING = 10;
739
740 /**
741 * Margin for scrolling using keyboard. Distance between a selected tile
742 * and window border.
743 */
744 Mosaic.Layout.SCROLL_MARGIN = 30;
745
746 /**
747 * Layout mode: commit to DOM immediately.
748 */
749 Mosaic.Layout.MODE_FINAL = 'final';
750
751 /**
752 * Layout mode: do not commit layout to DOM until it is complete or the viewport
753 * overflows.
754 */
755 Mosaic.Layout.MODE_TENTATIVE = 'tentative';
756
757 /**
758 * Layout mode: never commit layout to DOM.
759 */
760 Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
761
762 /**
763 * Reset the layout.
764 *
765 * @private
766 */
767 Mosaic.Layout.prototype.reset_ = function() {
768 this.columns_ = [];
769 this.newColumn_ = null;
770 this.density_ = Mosaic.Density.createLowest();
771 if (this.mode_ != Mosaic.Layout.MODE_DRY_RUN) // DRY_RUN is sticky.
772 this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
773 };
774
775 /**
776 * @param {number} width Viewport width.
777 * @param {number} height Viewport height.
778 */
779 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
780 this.viewportWidth_ = width;
781 this.viewportHeight_ = height;
782 this.reset_();
783 };
784
785 /**
786 * @return {number} Total width of the layout.
787 */
788 Mosaic.Layout.prototype.getWidth = function() {
789 var lastColumn = this.getLastColumn_();
790 return lastColumn ? lastColumn.getRight() : 0;
791 };
792
793 /**
794 * @return {number} Total height of the layout.
795 */
796 Mosaic.Layout.prototype.getHeight = function() {
797 var firstColumn = this.columns_[0];
798 return firstColumn ? firstColumn.getHeight() : 0;
799 };
800
801 /**
802 * @return {Array.<Mosaic.Tile>} All tiles in the layout.
803 */
804 Mosaic.Layout.prototype.getTiles = function() {
805 return Array.prototype.concat.apply([],
806 this.columns_.map(function(c) { return c.getTiles() }));
807 };
808
809 /**
810 * @return {number} Total number of tiles added to the layout.
811 */
812 Mosaic.Layout.prototype.getTileCount = function() {
813 return this.getLaidOutTileCount() +
814 (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
815 };
816
817 /**
818 * @return {Mosaic.Column} The last column or null for empty layout.
819 * @private
820 */
821 Mosaic.Layout.prototype.getLastColumn_ = function() {
822 return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
823 };
824
825 /**
826 * @return {number} Total number of tiles in completed columns.
827 */
828 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
829 var lastColumn = this.getLastColumn_();
830 return lastColumn ? lastColumn.getNextTileIndex() : 0;
831 };
832
833 /**
834 * Add a tile to the layout.
835 *
836 * @param {Mosaic.Tile} tile The tile to be added.
837 * @param {boolean} isLast True if this tile is the last.
838 */
839 Mosaic.Layout.prototype.add = function(tile, isLast) {
840 var layoutQueue = [tile];
841
842 // There are two levels of backtracking in the layout algorithm.
843 // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
844 // which aims to use as much of the viewport space as possible.
845 // It starts with the lowest density and increases it until the layout
846 // fits into the viewport. If it does not fit even at the highest density,
847 // the layout continues with the highest density.
848 //
849 // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
850 // which aims to avoid producing unnaturally looking columns.
851 // It starts with the current global density and decreases it until the column
852 // looks nice.
853
854 while (layoutQueue.length) {
855 if (!this.newColumn_) {
856 var lastColumn = this.getLastColumn_();
857 this.newColumn_ = new Mosaic.Column(
858 this.columns_.length,
859 lastColumn ? lastColumn.getNextRowIndex() : 0,
860 lastColumn ? lastColumn.getNextTileIndex() : 0,
861 lastColumn ? lastColumn.getRight() : 0,
862 this.viewportHeight_,
863 this.density_.clone());
864 }
865
866 this.newColumn_.add(layoutQueue.shift());
867
868 var isFinalColumn = isLast && !layoutQueue.length;
869
870 if (!this.newColumn_.prepareLayout(isFinalColumn))
871 continue; // Column is incomplete.
872
873 if (this.newColumn_.isSuboptimal()) {
874 layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
875 this.newColumn_.retryWithLowerDensity();
876 continue;
877 }
878
879 this.columns_.push(this.newColumn_);
880 this.newColumn_ = null;
881
882 if (this.mode_ == Mosaic.Layout.MODE_FINAL) {
883 this.getLastColumn_().layout();
884 continue;
885 }
886
887 if (this.getWidth() > this.viewportWidth_) {
888 // Viewport completely filled.
889 if (this.density_.equals(this.maxDensity_)) {
890 // Max density reached, commit if tentative, just continue if dry run.
891 if (this.mode_ == Mosaic.Layout.MODE_TENTATIVE)
892 this.commit_();
893 continue;
894 }
895
896 // Rollback the entire layout, retry with higher density.
897 layoutQueue = this.getTiles().concat(layoutQueue);
898 this.columns_ = [];
899 this.density_.increase();
900 continue;
901 }
902
903 if (isFinalColumn && this.mode_ == Mosaic.Layout.MODE_TENTATIVE) {
904 // The complete tentative layout fits into the viewport.
905 var stretched = this.findHorizontalLayout_();
906 if (stretched)
907 this.columns_ = stretched.columns_;
908 // Center the layout in the viewport and commit.
909 this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
910 (this.viewportHeight_ - this.getHeight()) / 2);
911 }
912 }
913 };
914
915 /**
916 * Commit the tentative layout.
917 *
918 * @param {number=} opt_offsetX Horizontal offset.
919 * @param {number=} opt_offsetY Vertical offset.
920 * @private
921 */
922 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
923 console.assert(this.mode_ != Mosaic.Layout.MODE_FINAL,
924 'Did not expect final layout');
925 for (var i = 0; i != this.columns_.length; i++) {
926 this.columns_[i].layout(opt_offsetX, opt_offsetY);
927 }
928 this.mode_ = Mosaic.Layout.MODE_FINAL;
929 };
930
931 /**
932 * Find the most horizontally stretched layout built from the same tiles.
933 *
934 * The main layout algorithm fills the entire available viewport height.
935 * If there is too few tiles this results in a layout that is unnaturally
936 * stretched in the vertical direction.
937 *
938 * This method tries a number of smaller heights and returns the most
939 * horizontally stretched layout that still fits into the viewport.
940 *
941 * @return {Mosaic.Layout} A horizontally stretched layout.
942 * @private
943 */
944 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
945 // If the layout aspect ratio is not dramatically different from
946 // the viewport aspect ratio then there is no need to optimize.
947 if (this.getWidth() / this.getHeight() >
948 this.viewportWidth_ / this.viewportHeight_ * 0.9)
949 return null;
950
951 var tiles = this.getTiles();
952 if (tiles.length == 1)
953 return null; // Single tile layout is always the same.
954
955 var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight() });
956 var minTileHeight = Math.min.apply(null, tileHeights);
957
958 for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
959 var layout = new Mosaic.Layout(
960 Mosaic.Layout.MODE_DRY_RUN, this.density_.clone());
961 layout.setViewportSize(this.viewportWidth_, h);
962 for (var t = 0; t != tiles.length; t++)
963 layout.add(tiles[t], t + 1 == tiles.length);
964
965 if (layout.getWidth() <= this.viewportWidth_)
966 return layout;
967 }
968
969 return null;
970 };
971
972 /**
973 * Invalidate the layout after the given tile was modified (added, deleted or
974 * changed dimensions).
975 *
976 * @param {number} index Tile index.
977 * @private
978 */
979 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
980 var columnIndex = this.getColumnIndexByTile_(index);
981 if (columnIndex < 0)
982 return; // Index not in the layout, probably already invalidated.
983
984 if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
985 // The columns to the right cover the entire viewport width, so there is no
986 // chance that the modified layout would fit into the viewport.
987 // No point in restarting the entire layout, keep the columns to the right.
988 console.assert(this.mode_ == Mosaic.Layout.MODE_FINAL,
989 'Expected FINAL layout mode');
990 this.columns_ = this.columns_.slice(0, columnIndex);
991 this.newColumn_ = null;
992 } else {
993 // There is a chance that the modified layout would fit into the viewport.
994 this.reset_();
995 this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
996 }
997 };
998
999 /**
1000 * Get the index of the tile to the left or to the right from the given tile.
1001 *
1002 * @param {number} index Tile index.
1003 * @param {number} direction -1 for left, 1 for right.
1004 * @return {number} Adjacent tile index.
1005 */
1006 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
1007 index, direction) {
1008 var column = this.getColumnIndexByTile_(index);
1009 if (column < 0) {
1010 console.error('Cannot find column for tile #' + index);
1011 return -1;
1012 }
1013
1014 var row = this.columns_[column].getRowByTileIndex(index);
1015 if (!row) {
1016 console.error('Cannot find row for tile #' + index);
1017 return -1;
1018 }
1019
1020 var sameRowNeighbourIndex = index + direction;
1021 if (row.hasTile(sameRowNeighbourIndex))
1022 return sameRowNeighbourIndex;
1023
1024 var adjacentColumn = column + direction;
1025 if (adjacentColumn < 0 || adjacentColumn == this.columns_.length)
1026 return -1;
1027
1028 return this.columns_[adjacentColumn].
1029 getEdgeTileIndex_(row.getCenterY(), -direction);
1030 };
1031
1032 /**
1033 * Get the index of the tile to the top or to the bottom from the given tile.
1034 *
1035 * @param {number} index Tile index.
1036 * @param {number} direction -1 for above, 1 for below.
1037 * @return {number} Adjacent tile index.
1038 */
1039 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1040 index, direction) {
1041 var column = this.getColumnIndexByTile_(index);
1042 if (column < 0) {
1043 console.error('Cannot find column for tile #' + index);
1044 return -1;
1045 }
1046
1047 var row = this.columns_[column].getRowByTileIndex(index);
1048 if (!row) {
1049 console.error('Cannot find row for tile #' + index);
1050 return -1;
1051 }
1052
1053 // Find the first item in the next row, or the last item in the previous row.
1054 var adjacentRowNeighbourIndex =
1055 row.getEdgeTileIndex_(direction) + direction;
1056
1057 if (adjacentRowNeighbourIndex < 0 ||
1058 adjacentRowNeighbourIndex > this.getTileCount() - 1)
1059 return -1;
1060
1061 if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1062 // It is not in the current column, so return it.
1063 return adjacentRowNeighbourIndex;
1064 } else {
1065 // It is in the current column, so we have to find optically the closest
1066 // tile in the adjacent row.
1067 var adjacentRow = this.columns_[column].getRowByTileIndex(
1068 adjacentRowNeighbourIndex);
1069 var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1070
1071 // Find the closest one.
1072 var closestIndex = -1;
1073 var closestDistance;
1074 var adjacentRowTiles = adjacentRow.getTiles();
1075 for (var t = 0; t != adjacentRowTiles.length; t++) {
1076 var distance =
1077 Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1078 if (closestIndex == -1 || distance < closestDistance) {
1079 closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1080 closestDistance = distance;
1081 }
1082 }
1083 return closestIndex;
1084 }
1085 };
1086
1087 /**
1088 * @param {number} index Tile index.
1089 * @return {number} Index of the column containing the given tile.
1090 * @private
1091 */
1092 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1093 for (var c = 0; c != this.columns_.length; c++) {
1094 if (this.columns_[c].hasTile(index))
1095 return c;
1096 }
1097 return -1;
1098 };
1099
1100 /**
1101 * Scale the given array of size values to satisfy 3 conditions:
1102 * 1. The new sizes must be integer.
1103 * 2. The new sizes must sum up to the given |total| value.
1104 * 3. The relative proportions of the sizes should be as close to the original
1105 * as possible.
1106 *
1107 * @param {Array.<number>} sizes Array of sizes.
1108 * @param {number} newTotal New total size.
1109 */
1110 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1111 var total = 0;
1112
1113 var partialTotals = [0];
1114 for (var i = 0; i != sizes.length; i++) {
1115 total += sizes[i];
1116 partialTotals.push(total);
1117 }
1118
1119 var scale = newTotal / total;
1120
1121 for (i = 0; i != sizes.length; i++) {
1122 sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1123 Math.round(partialTotals[i] * scale);
1124 }
1125 };
1126
1127 ////////////////////////////////////////////////////////////////////////////////
1128
1129 /**
1130 * Representation of the layout density.
1131 *
1132 * @param {number} horizontal Horizontal density, number tiles per row.
1133 * @param {number} vertical Vertical density, frequency of rows forced to
1134 * contain a single tile.
1135 * @constructor
1136 */
1137 Mosaic.Density = function(horizontal, vertical) {
1138 this.horizontal = horizontal;
1139 this.vertical = vertical;
1140 };
1141
1142 /**
1143 * Minimal horizontal density (tiles per row).
1144 */
1145 Mosaic.Density.MIN_HORIZONTAL = 1;
1146
1147 /**
1148 * Minimal horizontal density (tiles per row).
1149 */
1150 Mosaic.Density.MAX_HORIZONTAL = 3;
1151
1152 /**
1153 * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1154 */
1155 Mosaic.Density.MIN_VERTICAL = 2;
1156
1157 /**
1158 * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1159 */
1160 Mosaic.Density.MAX_VERTICAL = 3;
1161
1162 /**
1163 * @return {Mosaic.Density} Lowest density.
1164 */
1165 Mosaic.Density.createLowest = function() {
1166 return new Mosaic.Density(
1167 Mosaic.Density.MIN_HORIZONTAL,
1168 Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1169 };
1170
1171 /**
1172 * @return {Mosaic.Density} Highest density.
1173 */
1174 Mosaic.Density.createHighest = function() {
1175 return new Mosaic.Density(
1176 Mosaic.Density.MAX_HORIZONTAL,
1177 Mosaic.Density.MAX_VERTICAL);
1178 };
1179
1180 /**
1181 * @return {Mosaic.Density} A clone of this density object.
1182 */
1183 Mosaic.Density.prototype.clone = function() {
1184 return new Mosaic.Density(this.horizontal, this.vertical);
1185 };
1186
1187 /**
1188 * @param {Mosaic.Density} that The other object.
1189 * @return {boolean} True if equal.
1190 */
1191 Mosaic.Density.prototype.equals = function(that) {
1192 return this.horizontal == that.horizontal &&
1193 this.vertical == that.vertical;
1194 };
1195
1196 /**
1197 * Increase the density to the next level.
1198 */
1199 Mosaic.Density.prototype.increase = function() {
1200 if (this.horizontal == Mosaic.Density.MIN_HORIZONTAL ||
1201 this.vertical == Mosaic.Density.MAX_VERTICAL) {
1202 console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1203 this.horizontal++;
1204 this.vertical = Mosaic.Density.MIN_VERTICAL;
1205 } else {
1206 this.vertical++;
1207 }
1208 };
1209
1210 /**
1211 * Decrease horizontal density.
1212 */
1213 Mosaic.Density.prototype.decreaseHorizontal = function() {
1214 console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1215 this.horizontal--;
1216 };
1217
1218 /**
1219 * @param {number} tileCount Number of tiles in the row.
1220 * @param {number} rowIndex Global row index.
1221 * @return {boolean} True if the row is complete.
1222 */
1223 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1224 return (tileCount == this.horizontal) || (rowIndex % this.vertical) == 0;
1225 };
1226
1227 ////////////////////////////////////////////////////////////////////////////////
1228
1229 /**
1230 * A column in a mosaic layout. Contains rows.
1231 *
1232 * @param {number} index Column index.
1233 * @param {number} firstRowIndex Global row index.
1234 * @param {number} firstTileIndex Index of the first tile in the column.
1235 * @param {number} left Left edge coordinate.
1236 * @param {number} maxHeight Maximum height.
1237 * @param {Mosaic.Density} density Layout density.
1238 * @constructor
1239 */
1240 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1241 density) {
1242 this.index_ = index;
1243 this.firstRowIndex_ = firstRowIndex;
1244 this.firstTileIndex_ = firstTileIndex;
1245 this.left_ = left;
1246 this.maxHeight_ = maxHeight;
1247 this.density_ = density;
1248
1249 this.reset_();
1250 };
1251
1252 /**
1253 * Reset the layout.
1254 * @private
1255 */
1256 Mosaic.Column.prototype.reset_ = function() {
1257 this.tiles_ = [];
1258 this.rows_ = [];
1259 this.newRow_ = null;
1260 };
1261
1262 /**
1263 * @return {number} Number of tiles in the column.
1264 */
1265 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1266
1267 /**
1268 * @return {number} Index of the last tile + 1.
1269 */
1270 Mosaic.Column.prototype.getNextTileIndex = function() {
1271 return this.firstTileIndex_ + this.getTileCount();
1272 };
1273
1274 /**
1275 * @return {number} Global index of the last row + 1.
1276 */
1277 Mosaic.Column.prototype.getNextRowIndex = function() {
1278 return this.firstRowIndex_ + this.rows_.length;
1279 };
1280
1281 /**
1282 * @return {Array.<Mosaic.Tile>} Array of tiles in the column.
1283 */
1284 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1285
1286 /**
1287 * @param {number} index Tile index.
1288 * @return {boolean} True if this column contains the tile with the given index.
1289 */
1290 Mosaic.Column.prototype.hasTile = function(index) {
1291 return this.firstTileIndex_ <= index &&
1292 index < (this.firstTileIndex_ + this.getTileCount());
1293 };
1294
1295 /**
1296 * @param {number} y Y coordinate.
1297 * @param {number} direction -1 for left, 1 for right.
1298 * @return {number} Index of the tile lying on the edge of the column at the
1299 * given y coordinate.
1300 * @private
1301 */
1302 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1303 for (var r = 0; r < this.rows_.length; r++) {
1304 if (this.rows_[r].coversY(y))
1305 return this.rows_[r].getEdgeTileIndex_(direction);
1306 }
1307 return -1;
1308 };
1309
1310 /**
1311 * @param {number} index Tile index.
1312 * @return {Mosaic.Row} The row containing the tile with a given index.
1313 */
1314 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1315 for (var r = 0; r != this.rows_.length; r++)
1316 if (this.rows_[r].hasTile(index))
1317 return this.rows_[r];
1318
1319 return null;
1320 };
1321
1322 /**
1323 * Add a tile to the column.
1324 *
1325 * @param {Mosaic.Tile} tile The tile to add.
1326 */
1327 Mosaic.Column.prototype.add = function(tile) {
1328 var rowIndex = this.getNextRowIndex();
1329
1330 if (!this.newRow_)
1331 this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1332
1333 this.tiles_.push(tile);
1334 this.newRow_.add(tile);
1335
1336 if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1337 this.rows_.push(this.newRow_);
1338 this.newRow_ = null;
1339 }
1340 };
1341
1342 /**
1343 * Prepare the column layout.
1344 *
1345 * @param {boolean=} opt_force True if the layout must be performed even for an
1346 * incomplete column.
1347 * @return {boolean} True if the layout was performed.
1348 */
1349 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1350 if (opt_force && this.newRow_) {
1351 this.rows_.push(this.newRow_);
1352 this.newRow_ = null;
1353 }
1354
1355 if (this.rows_.length == 0)
1356 return false;
1357
1358 this.width_ = Math.min.apply(
1359 null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1360
1361 this.height_ = 0;
1362
1363 this.rowHeights_ = [];
1364 for (var r = 0; r != this.rows_.length; r++) {
1365 var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1366 this.height_ += rowHeight;
1367 this.rowHeights_.push(rowHeight);
1368 }
1369
1370 var overflow = this.height_ / this.maxHeight_;
1371 if (!opt_force && (overflow < 1))
1372 return false;
1373
1374 if (overflow > 1) {
1375 // Scale down the column width and height.
1376 this.width_ = Math.round(this.width_ / overflow);
1377 this.height_ = this.maxHeight_;
1378 Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1379 }
1380
1381 return true;
1382 };
1383
1384 /**
1385 * Retry the column layout with less tiles per row.
1386 */
1387 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1388 this.density_.decreaseHorizontal();
1389 this.reset_();
1390 };
1391
1392 /**
1393 * @return {number} Column left edge coordinate.
1394 */
1395 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1396
1397 /**
1398 * @return {number} Column right edge coordinate after the layout.
1399 */
1400 Mosaic.Column.prototype.getRight = function() {
1401 return this.left_ + this.width_;
1402 };
1403
1404 /**
1405 * @return {number} Column height after the layout.
1406 */
1407 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1408
1409 /**
1410 * Perform the column layout.
1411 * @param {number=} opt_offsetX Horizontal offset.
1412 * @param {number=} opt_offsetY Vertical offset.
1413 */
1414 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1415 opt_offsetX = opt_offsetX || 0;
1416 opt_offsetY = opt_offsetY || 0;
1417 var rowTop = Mosaic.Layout.PADDING_TOP;
1418 for (var r = 0; r != this.rows_.length; r++) {
1419 this.rows_[r].layout(
1420 opt_offsetX + this.left_,
1421 opt_offsetY + rowTop,
1422 this.width_,
1423 this.rowHeights_[r]);
1424 rowTop += this.rowHeights_[r];
1425 }
1426 };
1427
1428 /**
1429 * Check if the column layout is too ugly to be displayed.
1430 *
1431 * @return {boolean} True if the layout is suboptimal.
1432 */
1433 Mosaic.Column.prototype.isSuboptimal = function() {
1434 var tileCounts =
1435 this.rows_.map(function(row) { return row.getTileCount() });
1436
1437 var maxTileCount = Math.max.apply(null, tileCounts);
1438 if (maxTileCount == 1)
1439 return false; // Every row has exactly 1 tile, as optimal as it gets.
1440
1441 var sizes =
1442 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1443
1444 // Ugly layout #1: all images are small and some are one the same row.
1445 var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1446 if (allSmall)
1447 return true;
1448
1449 // Ugly layout #2: all images are large and none occupies an entire row.
1450 var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1451 var allCombined = Math.min.apply(null, tileCounts) != 1;
1452 if (allLarge && allCombined)
1453 return true;
1454
1455 // Ugly layout #3: some rows have too many tiles for the resulting width.
1456 if (this.width_ / maxTileCount < 100)
1457 return true;
1458
1459 return false;
1460 };
1461
1462 ////////////////////////////////////////////////////////////////////////////////
1463
1464 /**
1465 * A row in a mosaic layout. Contains tiles.
1466 *
1467 * @param {number} firstTileIndex Index of the first tile in the row.
1468 * @constructor
1469 */
1470 Mosaic.Row = function(firstTileIndex) {
1471 this.firstTileIndex_ = firstTileIndex;
1472 this.tiles_ = [];
1473 };
1474
1475 /**
1476 * @param {Mosaic.Tile} tile The tile to add.
1477 */
1478 Mosaic.Row.prototype.add = function(tile) {
1479 console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1480 this.tiles_.push(tile);
1481 };
1482
1483 /**
1484 * @return {Array.<Mosaic.Tile>} Array of tiles in the row.
1485 */
1486 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1487
1488 /**
1489 * Get a tile by index.
1490 * @param {number} index Tile index.
1491 * @return {Mosaic.Tile} Requested tile or null if not found.
1492 */
1493 Mosaic.Row.prototype.getTileByIndex = function(index) {
1494 if (!this.hasTile(index))
1495 return null;
1496 return this.tiles_[index - this.firstTileIndex_];
1497 };
1498
1499 /**
1500 *
1501 * @return {number} Number of tiles in the row.
1502 */
1503 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1504
1505 /**
1506 * @param {number} index Tile index.
1507 * @return {boolean} True if this row contains the tile with the given index.
1508 */
1509 Mosaic.Row.prototype.hasTile = function(index) {
1510 return this.firstTileIndex_ <= index &&
1511 index < (this.firstTileIndex_ + this.tiles_.length);
1512 };
1513
1514 /**
1515 * @param {number} y Y coordinate.
1516 * @return {boolean} True if this row covers the given Y coordinate.
1517 */
1518 Mosaic.Row.prototype.coversY = function(y) {
1519 return this.top_ <= y && y < (this.top_ + this.height_);
1520 };
1521
1522 /**
1523 * @return {number} Y coordinate of the tile center.
1524 */
1525 Mosaic.Row.prototype.getCenterY = function() {
1526 return this.top_ + Math.round(this.height_ / 2);
1527 };
1528
1529 /**
1530 * Get the first or the last tile.
1531 *
1532 * @param {number} direction -1 for the first tile, 1 for the last tile.
1533 * @return {number} Tile index.
1534 * @private
1535 */
1536 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1537 if (direction < 0)
1538 return this.firstTileIndex_;
1539 else
1540 return this.firstTileIndex_ + this.getTileCount() - 1;
1541 };
1542
1543 /**
1544 * @return {number} Aspect ration of the combined content box of this row.
1545 * @private
1546 */
1547 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1548 var sum = 0;
1549 for (var t = 0; t != this.tiles_.length; t++)
1550 sum += this.tiles_[t].getAspectRatio();
1551 return sum;
1552 };
1553
1554 /**
1555 * @return {number} Total horizontal spacing in this row. This includes
1556 * the spacing between the tiles and both left and right margins.
1557 *
1558 * @private
1559 */
1560 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1561 return Mosaic.Layout.SPACING * this.getTileCount();
1562 };
1563
1564 /**
1565 * @return {number} Maximum width that this row may have without overscaling
1566 * any of the tiles.
1567 */
1568 Mosaic.Row.prototype.getMaxWidth = function() {
1569 var contentHeight = Math.min.apply(null,
1570 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1571
1572 var contentWidth =
1573 Math.round(contentHeight * this.getTotalContentAspectRatio_());
1574 return contentWidth + this.getTotalHorizontalSpacing_();
1575 };
1576
1577 /**
1578 * Compute the height that best fits the supplied row width given
1579 * aspect ratios of the tiles in this row.
1580 *
1581 * @param {number} width Row width.
1582 * @return {number} Height.
1583 */
1584 Mosaic.Row.prototype.getHeightForWidth = function(width) {
1585 var contentWidth = width - this.getTotalHorizontalSpacing_();
1586 var contentHeight =
1587 Math.round(contentWidth / this.getTotalContentAspectRatio_());
1588 return contentHeight + Mosaic.Layout.SPACING;
1589 };
1590
1591 /**
1592 * Position the row in the mosaic.
1593 *
1594 * @param {number} left Left position.
1595 * @param {number} top Top position.
1596 * @param {number} width Width.
1597 * @param {number} height Height.
1598 */
1599 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1600 this.top_ = top;
1601 this.height_ = height;
1602
1603 var contentWidth = width - this.getTotalHorizontalSpacing_();
1604 var contentHeight = height - Mosaic.Layout.SPACING;
1605
1606 var tileContentWidth = this.tiles_.map(
1607 function(tile) { return tile.getAspectRatio() });
1608
1609 Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1610
1611 var tileLeft = left;
1612 for (var t = 0; t != this.tiles_.length; t++) {
1613 var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1614 this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1615 tileLeft += tileWidth;
1616 }
1617 };
1618
1619 ////////////////////////////////////////////////////////////////////////////////
1620
1621 /**
1622 * A single tile of the image mosaic.
1623 *
1624 * @param {Element} container Container element.
1625 * @param {Gallery.Item} item Gallery item associated with this tile.
1626 * @return {Element} The new tile element.
1627 * @constructor
1628 */
1629 Mosaic.Tile = function(container, item) {
1630 var self = container.ownerDocument.createElement('div');
1631 Mosaic.Tile.decorate(self, container, item);
1632 return self;
1633 };
1634
1635 /**
1636 * @param {Element} self Self pointer.
1637 * @param {Element} container Container element.
1638 * @param {Gallery.Item} item Gallery item associated with this tile.
1639 */
1640 Mosaic.Tile.decorate = function(self, container, item) {
1641 self.__proto__ = Mosaic.Tile.prototype;
1642 self.className = 'mosaic-tile';
1643
1644 self.container_ = container;
1645 self.item_ = item;
1646 self.left_ = null; // Mark as not laid out.
1647 };
1648
1649 /**
1650 * Load mode for the tile's image.
1651 * @enum {number}
1652 */
1653 Mosaic.Tile.LoadMode = {
1654 LOW_DPI: 0,
1655 HIGH_DPI: 1
1656 };
1657
1658 /**
1659 * Inherit from HTMLDivElement.
1660 */
1661 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1662
1663 /**
1664 * Minimum tile content size.
1665 */
1666 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1667
1668 /**
1669 * Maximum tile content size.
1670 */
1671 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1672
1673 /**
1674 * Default size for a tile with no thumbnail image.
1675 */
1676 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1677
1678 /**
1679 * Max size of an image considered to be 'small'.
1680 * Small images are laid out slightly differently.
1681 */
1682 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1683
1684 /**
1685 * @return {Gallery.Item} The Gallery item.
1686 */
1687 Mosaic.Tile.prototype.getItem = function() { return this.item_ };
1688
1689 /**
1690 * @return {number} Maximum content height that this tile can have.
1691 */
1692 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1693 return this.maxContentHeight_;
1694 };
1695
1696 /**
1697 * @return {number} The aspect ratio of the tile image.
1698 */
1699 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_ };
1700
1701 /**
1702 * @return {boolean} True if the tile is initialized.
1703 */
1704 Mosaic.Tile.prototype.isInitialized = function() {
1705 return !!this.maxContentHeight_;
1706 };
1707
1708 /**
1709 * Checks whether the image of specified (or better resolution) has been loaded.
1710 *
1711 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1712 * @return {boolean} True if the tile is loaded with the specified dpi or
1713 * better.
1714 */
1715 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
1716 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1717 switch (loadMode) {
1718 case Mosaic.Tile.LoadMode.LOW_DPI:
1719 if (this.imagePreloaded_ || this.imageLoaded_)
1720 return true;
1721 break;
1722 case Mosaic.Tile.LoadMode.HIGH_DPI:
1723 if (this.imageLoaded_)
1724 return true;
1725 break;
1726 }
1727 return false;
1728 };
1729
1730 /**
1731 * Checks whether the image of specified (or better resolution) is being loaded.
1732 *
1733 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1734 * @return {boolean} True if the tile is being loaded with the specified dpi or
1735 * better.
1736 */
1737 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
1738 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1739 switch (loadMode) {
1740 case Mosaic.Tile.LoadMode.LOW_DPI:
1741 if (this.imagePreloading_ || this.imageLoading_)
1742 return true;
1743 break;
1744 case Mosaic.Tile.LoadMode.HIGH_DPI:
1745 if (this.imageLoading_)
1746 return true;
1747 break;
1748 }
1749 return false;
1750 };
1751
1752 /**
1753 * Mark the tile as not loaded to prevent it from participating in the layout.
1754 */
1755 Mosaic.Tile.prototype.markUnloaded = function() {
1756 this.maxContentHeight_ = 0;
1757 if (this.thumbnailLoader_) {
1758 this.thumbnailLoader_.cancel();
1759 this.imagePreloaded_ = false;
1760 this.imagePreloading_ = false;
1761 this.imageLoaded_ = false;
1762 this.imageLoading_ = false;
1763 }
1764 };
1765
1766 /**
1767 * Initializes the thumbnail in the tile. Does not load an image, but sets
1768 * target dimensions using metadata.
1769 *
1770 * @param {Object} metadata Metadata object.
1771 * @param {function()} onImageMeasured Image measured callback.
1772 */
1773 Mosaic.Tile.prototype.init = function(metadata, onImageMeasured) {
1774 this.markUnloaded();
1775 this.left_ = null; // Mark as not laid out.
1776
1777 // Set higher priority for the selected elements to load them first.
1778 var priority = this.getAttribute('selected') ? 2 : 3;
1779
1780 // Use embedded thumbnails on Drive, since they have higher resolution.
1781 var hidpiEmbedded = FileType.isOnDrive(this.getItem().getUrl());
1782 this.thumbnailLoader_ = new ThumbnailLoader(
1783 this.getItem().getUrl(),
1784 ThumbnailLoader.LoaderType.CANVAS,
1785 metadata,
1786 undefined, // Media type.
1787 hidpiEmbedded ? ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
1788 ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
1789 priority);
1790
1791 // If no hidpi embedded thumbnail available, then use the low resolution
1792 // for preloading.
1793 if (!hidpiEmbedded) {
1794 this.thumbnailPreloader_ = new ThumbnailLoader(
1795 this.getItem().getUrl(),
1796 ThumbnailLoader.LoaderType.CANVAS,
1797 metadata,
1798 undefined, // Media type.
1799 ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
1800 2); // Preloaders have always higher priotity, so the preload images
1801 // are loaded as soon as possible.
1802 }
1803
1804 var setDimensions = function(width, height) {
1805 if (width > height) {
1806 if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
1807 height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
1808 width = Mosaic.Tile.MAX_CONTENT_SIZE;
1809 }
1810 } else {
1811 if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
1812 width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
1813 height = Mosaic.Tile.MAX_CONTENT_SIZE;
1814 }
1815 }
1816 this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
1817 this.aspectRatio_ = width / height;
1818 onImageMeasured();
1819 }.bind(this);
1820
1821 // Dimensions are always acquired from the metadata. For local files, it is
1822 // extracted from headers. For Drive files, it is received via the Drive API.
1823 // If the dimensions are not available, then the fallback dimensions will be
1824 // used (same as for the generic icon).
1825 if (metadata.media && metadata.media.width) {
1826 setDimensions(metadata.media.width, metadata.media.height);
1827 } else if (metadata.drive && metadata.drive.imageWidth &&
1828 metadata.drive.imageHeight) {
1829 setDimensions(metadata.drive.imageWidth, metadata.drive.imageHeight);
1830 } else {
1831 // No dimensions in metadata, then use the generic dimensions.
1832 setDimensions(Mosaic.Tile.GENERIC_ICON_SIZE,
1833 Mosaic.Tile.GENERIC_ICON_SIZE);
1834 }
1835 };
1836
1837 /**
1838 * Loads an image into the tile.
1839 *
1840 * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
1841 * for better output, but possibly affecting performance.
1842 *
1843 * If the mode is high-dpi, then a the high-dpi image is loaded, but also
1844 * low-dpi image is loaded for preloading (if available).
1845 * For the low-dpi mode, only low-dpi image is loaded. If not available, then
1846 * the high-dpi image is loaded as a fallback.
1847 *
1848 * @param {Mosaic.Tile.LoadMode} loadMode Loading mode.
1849 * @param {function(boolean)} onImageLoaded Callback when image is loaded.
1850 * The argument is true for success, false for failure.
1851 */
1852 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
1853 // Attaches the image to the tile and finalizes loading process for the
1854 // specified loader.
1855 var finalizeLoader = function(mode, success, loader) {
1856 if (success && this.wrapper_) {
1857 // Show the fade-in animation only when previously there was no image
1858 // attached in this tile.
1859 if (!this.imageLoaded_ && !this.imagePreloaded_)
1860 this.wrapper_.classList.add('animated');
1861 else
1862 this.wrapper_.classList.remove('animated');
1863 }
1864 loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
1865 onImageLoaded(success);
1866 switch (mode) {
1867 case Mosaic.Tile.LoadMode.LOW_DPI:
1868 this.imagePreloading_ = false;
1869 this.imagePreloaded_ = true;
1870 break;
1871 case Mosaic.Tile.LoadMode.HIGH_DPI:
1872 this.imageLoading_ = false;
1873 this.imageLoaded_ = true;
1874 break;
1875 }
1876 }.bind(this);
1877
1878 // Always load the low-dpi image first if it is available for the fastest
1879 // feedback.
1880 if (!this.imagePreloading_ && this.thumbnailPreloader_) {
1881 this.imagePreloading_ = true;
1882 this.thumbnailPreloader_.loadDetachedImage(function(success) {
1883 // Hi-dpi loaded first, ignore this call then.
1884 if (this.imageLoaded_)
1885 return;
1886 finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
1887 success,
1888 this.thumbnailPreloader_);
1889 }.bind(this));
1890 }
1891
1892 // Load the high-dpi image only when it is requested, or the low-dpi is not
1893 // available.
1894 if (!this.imageLoading_ &&
1895 (loadMode == Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
1896 this.imageLoading_ = true;
1897 this.thumbnailLoader_.loadDetachedImage(function(success) {
1898 // Cancel preloading, since the hi-dpi image is ready.
1899 if (this.thumbnailPreloader_)
1900 this.thumbnailPreloader_.cancel();
1901 finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
1902 success,
1903 this.thumbnailLoader_);
1904 }.bind(this));
1905 }
1906 };
1907
1908 /**
1909 * Unloads an image from the tile.
1910 */
1911 Mosaic.Tile.prototype.unload = function() {
1912 this.thumbnailLoader_.cancel();
1913 if (this.thumbnailPreloader_)
1914 this.thumbnailPreloader_.cancel();
1915 this.imagePreloaded_ = false;
1916 this.imageLoaded_ = false;
1917 this.imagePreloading_ = false;
1918 this.imageLoading_ = false;
1919 this.wrapper_.innerText = '';
1920 };
1921
1922 /**
1923 * Select/unselect the tile.
1924 *
1925 * @param {boolean} on True if selected.
1926 */
1927 Mosaic.Tile.prototype.select = function(on) {
1928 if (on)
1929 this.setAttribute('selected', true);
1930 else
1931 this.removeAttribute('selected');
1932 };
1933
1934 /**
1935 * Position the tile in the mosaic.
1936 *
1937 * @param {number} left Left position.
1938 * @param {number} top Top position.
1939 * @param {number} width Width.
1940 * @param {number} height Height.
1941 */
1942 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
1943 this.left_ = left;
1944 this.top_ = top;
1945 this.width_ = width;
1946 this.height_ = height;
1947
1948 this.style.left = left + 'px';
1949 this.style.top = top + 'px';
1950 this.style.width = width + 'px';
1951 this.style.height = height + 'px';
1952
1953 if (!this.wrapper_) { // First time, create DOM.
1954 this.container_.appendChild(this);
1955 var border = util.createChild(this, 'img-border');
1956 this.wrapper_ = util.createChild(border, 'img-wrapper');
1957 }
1958 if (this.hasAttribute('selected'))
1959 this.scrollIntoView(false);
1960
1961 if (this.imageLoaded_) {
1962 this.thumbnailLoader_.attachImage(this.wrapper_,
1963 ThumbnailLoader.FillMode.FILL);
1964 }
1965 };
1966
1967 /**
1968 * If the tile is not fully visible scroll the parent to make it fully visible.
1969 * @param {boolean=} opt_animated True, if scroll should be animated,
1970 * default: true.
1971 */
1972 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
1973 if (this.left_ == null) // Not laid out.
1974 return;
1975
1976 var targetPosition;
1977 var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
1978 if (tileLeft < this.container_.scrollLeft) {
1979 targetPosition = tileLeft;
1980 } else {
1981 var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
1982 var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
1983 if (tileRight > scrollRight)
1984 targetPosition = tileRight - this.container_.clientWidth;
1985 }
1986
1987 if (targetPosition) {
1988 if (opt_animated === false)
1989 this.container_.scrollLeft = targetPosition;
1990 else
1991 this.container_.animatedScrollTo(targetPosition);
1992 }
1993 };
1994
1995 /**
1996 * @return {Rect} Rectangle occupied by the tile's image,
1997 * relative to the viewport.
1998 */
1999 Mosaic.Tile.prototype.getImageRect = function() {
2000 if (this.left_ == null) // Not laid out.
2001 return null;
2002
2003 var margin = Mosaic.Layout.SPACING / 2;
2004 return new Rect(this.left_ - this.container_.scrollLeft, this.top_,
2005 this.width_, this.height_).inflate(-margin, -margin);
2006 };
2007
2008 /**
2009 * @return {number} X coordinate of the tile center.
2010 */
2011 Mosaic.Tile.prototype.getCenterX = function() {
2012 return this.left_ + Math.round(this.width_ / 2);
2013 };
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698