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
5cr.define('options', function() {
6  /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel;
7  /** @const */ var Grid = cr.ui.Grid;
8  /** @const */ var GridItem = cr.ui.GridItem;
9  /** @const */ var GridSelectionController = cr.ui.GridSelectionController;
10  /** @const */ var ListSingleSelectionModel = cr.ui.ListSingleSelectionModel;
11
12   /**
13    * Dimensions for camera capture.
14    * @const
15    */
16  var CAPTURE_SIZE = {
17    height: 480,
18    width: 480
19  };
20
21  /**
22   * Path for internal URLs.
23   * @const
24   */
25  var CHROME_THEME_PATH = 'chrome://theme';
26
27  /**
28   * Creates a new user images grid item.
29   * @param {{url: string, title: (string|undefined),
30   *     decorateFn: (!Function|undefined),
31   *     clickHandler: (!Function|undefined)}} imageInfo User image URL,
32   *     optional title, decorator callback and click handler.
33   * @constructor
34   * @extends {cr.ui.GridItem}
35   */
36  function UserImagesGridItem(imageInfo) {
37    var el = new GridItem(imageInfo);
38    el.__proto__ = UserImagesGridItem.prototype;
39    return el;
40  }
41
42  UserImagesGridItem.prototype = {
43    __proto__: GridItem.prototype,
44
45    /** @override */
46    decorate: function() {
47      GridItem.prototype.decorate.call(this);
48      var imageEl = cr.doc.createElement('img');
49      // Force 1x scale for chrome://theme URLs. Grid elements are much smaller
50      // than actual images so there is no need in full scale on HDPI.
51      var url = this.dataItem.url;
52      if (url.slice(0, CHROME_THEME_PATH.length) == CHROME_THEME_PATH)
53        imageEl.src = this.dataItem.url + '@1x';
54      else
55        imageEl.src = this.dataItem.url;
56      imageEl.title = this.dataItem.title || '';
57      if (typeof this.dataItem.clickHandler == 'function')
58        imageEl.addEventListener('mousedown', this.dataItem.clickHandler);
59      // Remove any garbage added by GridItem and ListItem decorators.
60      this.textContent = '';
61      this.appendChild(imageEl);
62      if (typeof this.dataItem.decorateFn == 'function')
63        this.dataItem.decorateFn(this);
64      this.setAttribute('role', 'option');
65      this.oncontextmenu = function(e) { e.preventDefault(); };
66    }
67  };
68
69  /**
70   * Creates a selection controller that wraps selection on grid ends
71   * and translates Enter presses into 'activate' events.
72   * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
73   *     interact with.
74   * @param {cr.ui.Grid} grid The grid to interact with.
75   * @constructor
76   * @extends {cr.ui.GridSelectionController}
77   */
78  function UserImagesGridSelectionController(selectionModel, grid) {
79    GridSelectionController.call(this, selectionModel, grid);
80  }
81
82  UserImagesGridSelectionController.prototype = {
83    __proto__: GridSelectionController.prototype,
84
85    /** @override */
86    getIndexBefore: function(index) {
87      var result =
88          GridSelectionController.prototype.getIndexBefore.call(this, index);
89      return result == -1 ? this.getLastIndex() : result;
90    },
91
92    /** @override */
93    getIndexAfter: function(index) {
94      var result =
95          GridSelectionController.prototype.getIndexAfter.call(this, index);
96      return result == -1 ? this.getFirstIndex() : result;
97    },
98
99    /** @override */
100    handleKeyDown: function(e) {
101      if (e.keyIdentifier == 'Enter')
102        cr.dispatchSimpleEvent(this.grid_, 'activate');
103      else
104        GridSelectionController.prototype.handleKeyDown.call(this, e);
105    }
106  };
107
108  /**
109   * Creates a new user images grid element.
110   * @param {Object=} opt_propertyBag Optional properties.
111   * @constructor
112   * @extends {cr.ui.Grid}
113   */
114  var UserImagesGrid = cr.ui.define('grid');
115
116  UserImagesGrid.prototype = {
117    __proto__: Grid.prototype,
118
119    /** @override */
120    createSelectionController: function(sm) {
121      return new UserImagesGridSelectionController(sm, this);
122    },
123
124    /** @override */
125    decorate: function() {
126      Grid.prototype.decorate.call(this);
127      this.dataModel = new ArrayDataModel([]);
128      this.itemConstructor =
129          /** @type {function(new:cr.ui.ListItem, Object)} */(
130              UserImagesGridItem);
131      this.selectionModel = new ListSingleSelectionModel();
132      this.inProgramSelection_ = false;
133      this.addEventListener('dblclick', this.handleDblClick_.bind(this));
134      this.addEventListener('change', this.handleChange_.bind(this));
135      this.setAttribute('role', 'listbox');
136      this.autoExpands = true;
137    },
138
139    /**
140     * Handles double click on the image grid.
141     * @param {Event} e Double click Event.
142     * @private
143     */
144    handleDblClick_: function(e) {
145      // If a child element is double-clicked and not the grid itself, handle
146      // this as 'Enter' keypress.
147      if (e.target != this)
148        cr.dispatchSimpleEvent(this, 'activate');
149    },
150
151    /**
152     * Handles selection change.
153     * @param {Event} e Double click Event.
154     * @private
155     */
156    handleChange_: function(e) {
157      if (this.selectedItem === null)
158        return;
159
160      var oldSelectionType = this.selectionType;
161
162      // Update current selection type.
163      this.selectionType = this.selectedItem.type;
164
165      // Show grey silhouette with the same border as stock images.
166      if (/^chrome:\/\/theme\//.test(this.selectedItemUrl))
167        this.previewElement.classList.add('default-image');
168
169      this.updatePreview_();
170
171      var e = new Event('select');
172      e.oldSelectionType = oldSelectionType;
173      this.dispatchEvent(e);
174    },
175
176    /**
177     * Updates the preview image, if present.
178     * @private
179     */
180    updatePreview_: function() {
181      var url = this.selectedItemUrl;
182      if (url && this.previewImage_) {
183        if (url.slice(0, CHROME_THEME_PATH.length) == CHROME_THEME_PATH)
184          this.previewImage_.src = url + '@' + window.devicePixelRatio + 'x';
185        else
186          this.previewImage_.src = url;
187      }
188    },
189
190    /**
191     * Whether a camera is present or not.
192     * @type {boolean}
193     */
194    get cameraPresent() {
195      return this.cameraPresent_;
196    },
197    set cameraPresent(value) {
198      this.cameraPresent_ = value;
199      if (this.cameraLive)
200        this.cameraImage = null;
201    },
202
203    /**
204     * Whether camera is actually streaming video. May be |false| even when
205     * camera is present and shown but still initializing.
206     * @type {boolean}
207     */
208    get cameraOnline() {
209      return this.previewElement.classList.contains('online');
210    },
211    set cameraOnline(value) {
212      this.previewElement.classList.toggle('online', value);
213    },
214
215    /**
216     * Tries to starts camera stream capture.
217     * @param {function(): boolean} onAvailable Callback that is called if
218     *     camera is available. If it returns |true|, capture is started
219     *     immediately.
220     */
221    startCamera: function(onAvailable, onAbsent) {
222      this.stopCamera();
223      this.cameraStartInProgress_ = true;
224      navigator.webkitGetUserMedia(
225          {video: true},
226          this.handleCameraAvailable_.bind(this, onAvailable),
227          this.handleCameraAbsent_.bind(this));
228    },
229
230    /**
231     * Stops camera capture, if it's currently active.
232     */
233    stopCamera: function() {
234      this.cameraOnline = false;
235      if (this.cameraVideo_)
236        this.cameraVideo_.src = '';
237      if (this.cameraStream_)
238        this.cameraStream_.stop();
239      // Cancel any pending getUserMedia() checks.
240      this.cameraStartInProgress_ = false;
241    },
242
243    /**
244     * Handles successful camera check.
245     * @param {function(): boolean} onAvailable Callback to call. If it returns
246     *     |true|, capture is started immediately.
247     * @param {!MediaStream} stream Stream object as returned by getUserMedia.
248     * @private
249     */
250    handleCameraAvailable_: function(onAvailable, stream) {
251      if (this.cameraStartInProgress_ && onAvailable()) {
252        this.cameraVideo_.src = URL.createObjectURL(stream);
253        this.cameraStream_ = stream;
254      } else {
255        stream.stop();
256      }
257      this.cameraStartInProgress_ = false;
258    },
259
260    /**
261     * Handles camera check failure.
262     * @param {NavigatorUserMediaError=} err Error object.
263     * @private
264     */
265    handleCameraAbsent_: function(err) {
266      this.cameraPresent = false;
267      this.cameraOnline = false;
268      this.cameraStartInProgress_ = false;
269    },
270
271    /**
272     * Handles successful camera capture start.
273     * @private
274     */
275    handleVideoStarted_: function() {
276      this.cameraOnline = true;
277      this.handleVideoUpdate_();
278    },
279
280    /**
281     * Handles camera stream update. Called regularly (at rate no greater then
282     * 4/sec) while camera stream is live.
283     * @private
284     */
285    handleVideoUpdate_: function() {
286      this.lastFrameTime_ = new Date().getTime();
287    },
288
289    /**
290     * Type of the selected image (one of 'default', 'profile', 'camera').
291     * Setting it will update class list of |previewElement|.
292     * @type {string}
293     */
294    get selectionType() {
295      return this.selectionType_;
296    },
297    set selectionType(value) {
298      this.selectionType_ = value;
299      var previewClassList = this.previewElement.classList;
300      previewClassList[value == 'default' ? 'add' : 'remove']('default-image');
301      previewClassList[value == 'profile' ? 'add' : 'remove']('profile-image');
302      previewClassList[value == 'camera' ? 'add' : 'remove']('camera');
303
304      var setFocusIfLost = function() {
305        // Set focus to the grid, if focus is not on UI.
306        if (!document.activeElement ||
307            document.activeElement.tagName == 'BODY') {
308          $('user-image-grid').focus();
309        }
310      };
311      // Timeout guarantees processing AFTER style changes display attribute.
312      setTimeout(setFocusIfLost, 0);
313    },
314
315    /**
316     * Current image captured from camera as data URL. Setting to null will
317     * return to the live camera stream.
318     * @type {(string|undefined)}
319     */
320    get cameraImage() {
321      return this.cameraImage_;
322    },
323    set cameraImage(imageUrl) {
324      this.cameraLive = !imageUrl;
325      if (this.cameraPresent && !imageUrl)
326        imageUrl = UserImagesGrid.ButtonImages.TAKE_PHOTO;
327      if (imageUrl) {
328        this.cameraImage_ = this.cameraImage_ ?
329            this.updateItem(this.cameraImage_, imageUrl, this.cameraTitle_) :
330            this.addItem(imageUrl, this.cameraTitle_, undefined, 0);
331        this.cameraImage_.type = 'camera';
332      } else {
333        this.removeItem(this.cameraImage_);
334        this.cameraImage_ = null;
335      }
336    },
337
338    /**
339     * Updates the titles for the camera element.
340     * @param {string} placeholderTitle Title when showing a placeholder.
341     * @param {string} capturedImageTitle Title when showing a captured photo.
342     */
343    setCameraTitles: function(placeholderTitle, capturedImageTitle) {
344      this.placeholderTitle_ = placeholderTitle;
345      this.capturedImageTitle_ = capturedImageTitle;
346      this.cameraTitle_ = this.placeholderTitle_;
347    },
348
349    /**
350     * True when camera is in live mode (i.e. no still photo selected).
351     * @type {boolean}
352     */
353    get cameraLive() {
354      return this.cameraLive_;
355    },
356    set cameraLive(value) {
357      this.cameraLive_ = value;
358      this.previewElement.classList[value ? 'add' : 'remove']('live');
359    },
360
361    /**
362     * Should only be queried from the 'change' event listener, true if the
363     * change event was triggered by a programmatical selection change.
364     * @type {boolean}
365     */
366    get inProgramSelection() {
367      return this.inProgramSelection_;
368    },
369
370    /**
371     * URL of the image selected.
372     * @type {string?}
373     */
374    get selectedItemUrl() {
375      var selectedItem = this.selectedItem;
376      return selectedItem ? selectedItem.url : null;
377    },
378    set selectedItemUrl(url) {
379      for (var i = 0, el; el = this.dataModel.item(i); i++) {
380        if (el.url === url)
381          this.selectedItemIndex = i;
382      }
383    },
384
385    /**
386     * Set index to the image selected.
387     * @type {number} index The index of selected image.
388     */
389    set selectedItemIndex(index) {
390      this.inProgramSelection_ = true;
391      this.selectionModel.selectedIndex = index;
392      this.inProgramSelection_ = false;
393    },
394
395    /** @override */
396    get selectedItem() {
397      var index = this.selectionModel.selectedIndex;
398      return index != -1 ? this.dataModel.item(index) : null;
399    },
400    set selectedItem(selectedItem) {
401      var index = this.indexOf(selectedItem);
402      this.inProgramSelection_ = true;
403      this.selectionModel.selectedIndex = index;
404      this.selectionModel.leadIndex = index;
405      this.inProgramSelection_ = false;
406    },
407
408    /**
409     * Element containing the preview image (the first IMG element) and the
410     * camera live stream (the first VIDEO element).
411     * @type {HTMLElement}
412     */
413    get previewElement() {
414      // TODO(ivankr): temporary hack for non-HTML5 version.
415      return this.previewElement_ || this;
416    },
417    set previewElement(value) {
418      this.previewElement_ = value;
419      this.previewImage_ = value.querySelector('img');
420      this.cameraVideo_ = value.querySelector('video');
421      this.cameraVideo_.addEventListener('canplay',
422                                         this.handleVideoStarted_.bind(this));
423      this.cameraVideo_.addEventListener('timeupdate',
424                                         this.handleVideoUpdate_.bind(this));
425      this.updatePreview_();
426      // Initialize camera state and check for its presence.
427      this.cameraLive = true;
428      this.cameraPresent = false;
429    },
430
431    /**
432     * Whether the camera live stream and photo should be flipped horizontally.
433     * If setting this property results in photo update, 'photoupdated' event
434     * will be fired with 'dataURL' property containing the photo encoded as
435     * a data URL
436     * @type {boolean}
437     */
438    get flipPhoto() {
439      return this.flipPhoto_ || false;
440    },
441    set flipPhoto(value) {
442      if (this.flipPhoto_ == value)
443        return;
444      this.flipPhoto_ = value;
445      this.previewElement.classList.toggle('flip-x', value);
446      /* TODO(merkulova): remove when webkit crbug.com/126479 is fixed. */
447      this.flipPhotoElement.classList.toggle('flip-trick', value);
448      if (!this.cameraLive) {
449        // Flip current still photo.
450        var e = new Event('photoupdated');
451        e.dataURL = this.flipPhoto ?
452            this.flipFrame_(this.previewImage_) : this.previewImage_.src;
453        this.dispatchEvent(e);
454      }
455    },
456
457    /**
458     * Performs photo capture from the live camera stream. 'phototaken' event
459     * will be fired as soon as captured photo is available, with 'dataURL'
460     * property containing the photo encoded as a data URL.
461     * @return {boolean} Whether photo capture was successful.
462     */
463    takePhoto: function() {
464      if (!this.cameraOnline)
465        return false;
466      var canvas = /** @type {HTMLCanvasElement} */(
467          document.createElement('canvas'));
468      canvas.width = CAPTURE_SIZE.width;
469      canvas.height = CAPTURE_SIZE.height;
470      this.captureFrame_(
471          this.cameraVideo_,
472          /** @type {CanvasRenderingContext2D} */(canvas.getContext('2d')),
473          CAPTURE_SIZE);
474      // Preload image before displaying it.
475      var previewImg = new Image();
476      previewImg.addEventListener('load', function(e) {
477        this.cameraTitle_ = this.capturedImageTitle_;
478        this.cameraImage = previewImg.src;
479      }.bind(this));
480      previewImg.src = canvas.toDataURL('image/png');
481      var e = new Event('phototaken');
482      e.dataURL = this.flipPhoto ? this.flipFrame_(canvas) : previewImg.src;
483      this.dispatchEvent(e);
484      return true;
485    },
486
487    /**
488     * Discard current photo and return to the live camera stream.
489     */
490    discardPhoto: function() {
491      this.cameraTitle_ = this.placeholderTitle_;
492      this.cameraImage = null;
493    },
494
495    /**
496     * Capture a single still frame from a <video> element, placing it at the
497     * current drawing origin of a canvas context.
498     * @param {HTMLVideoElement} video Video element to capture from.
499     * @param {CanvasRenderingContext2D} ctx Canvas context to draw onto.
500     * @param {{width: number, height: number}} destSize Capture size.
501     * @private
502     */
503    captureFrame_: function(video, ctx, destSize) {
504      var width = video.videoWidth;
505      var height = video.videoHeight;
506      if (width < destSize.width || height < destSize.height) {
507        console.error('Video capture size too small: ' +
508                      width + 'x' + height + '!');
509      }
510      var src = {};
511      if (width / destSize.width > height / destSize.height) {
512        // Full height, crop left/right.
513        src.height = height;
514        src.width = height * destSize.width / destSize.height;
515      } else {
516        // Full width, crop top/bottom.
517        src.width = width;
518        src.height = width * destSize.height / destSize.width;
519      }
520      src.x = (width - src.width) / 2;
521      src.y = (height - src.height) / 2;
522      ctx.drawImage(video, src.x, src.y, src.width, src.height,
523                    0, 0, destSize.width, destSize.height);
524    },
525
526    /**
527     * Flips frame horizontally.
528     * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} source
529     *     Frame to flip.
530     * @return {string} Flipped frame as data URL.
531     */
532    flipFrame_: function(source) {
533      var canvas = document.createElement('canvas');
534      canvas.width = CAPTURE_SIZE.width;
535      canvas.height = CAPTURE_SIZE.height;
536      var ctx = canvas.getContext('2d');
537      ctx.translate(CAPTURE_SIZE.width, 0);
538      ctx.scale(-1.0, 1.0);
539      ctx.drawImage(source, 0, 0);
540      return canvas.toDataURL('image/png');
541    },
542
543    /**
544     * Adds new image to the user image grid.
545     * @param {string} url Image URL.
546     * @param {string=} opt_title Image tooltip.
547     * @param {Function=} opt_clickHandler Image click handler.
548     * @param {number=} opt_position If given, inserts new image into
549     *     that position (0-based) in image list.
550     * @param {Function=} opt_decorateFn Function called with the list element
551     *     as argument to do any final decoration.
552     * @return {!Object} Image data inserted into the data model.
553     */
554    // TODO(ivankr): this function needs some argument list refactoring.
555    addItem: function(url, opt_title, opt_clickHandler, opt_position,
556                      opt_decorateFn) {
557      var imageInfo = {
558        url: url,
559        title: opt_title,
560        clickHandler: opt_clickHandler,
561        decorateFn: opt_decorateFn
562      };
563      this.inProgramSelection_ = true;
564      if (opt_position !== undefined)
565        this.dataModel.splice(opt_position, 0, imageInfo);
566      else
567        this.dataModel.push(imageInfo);
568      this.inProgramSelection_ = false;
569      return imageInfo;
570    },
571
572    /**
573     * Returns index of an image in grid.
574     * @param {Object} imageInfo Image data returned from addItem() call.
575     * @return {number} Image index (0-based) or -1 if image was not found.
576     */
577    indexOf: function(imageInfo) {
578      return this.dataModel.indexOf(imageInfo);
579    },
580
581    /**
582     * Replaces an image in the grid.
583     * @param {Object} imageInfo Image data returned from addItem() call.
584     * @param {string} imageUrl New image URL.
585     * @param {string=} opt_title New image tooltip (if undefined, tooltip
586     *     is left unchanged).
587     * @return {!Object} Image data of the added or updated image.
588     */
589    updateItem: function(imageInfo, imageUrl, opt_title) {
590      var imageIndex = this.indexOf(imageInfo);
591      var wasSelected = this.selectionModel.selectedIndex == imageIndex;
592      this.removeItem(imageInfo);
593      var newInfo = this.addItem(
594          imageUrl,
595          opt_title === undefined ? imageInfo.title : opt_title,
596          imageInfo.clickHandler,
597          imageIndex,
598          imageInfo.decorateFn);
599      // Update image data with the reset of the keys from the old data.
600      for (var k in imageInfo) {
601        if (!(k in newInfo))
602          newInfo[k] = imageInfo[k];
603      }
604      if (wasSelected)
605        this.selectedItem = newInfo;
606      return newInfo;
607    },
608
609    /**
610     * Removes previously added image from the grid.
611     * @param {Object} imageInfo Image data returned from the addItem() call.
612     */
613    removeItem: function(imageInfo) {
614      var index = this.indexOf(imageInfo);
615      if (index != -1) {
616        var wasSelected = this.selectionModel.selectedIndex == index;
617        this.inProgramSelection_ = true;
618        this.dataModel.splice(index, 1);
619        if (wasSelected) {
620          // If item removed was selected, select the item next to it.
621          this.selectedItem = this.dataModel.item(
622              Math.min(this.dataModel.length - 1, index));
623        }
624        this.inProgramSelection_ = false;
625      }
626    },
627
628    /**
629     * Forces re-display, size re-calculation and focuses grid.
630     */
631    updateAndFocus: function() {
632      // Recalculate the measured item size.
633      this.measured_ = null;
634      this.columns = 0;
635      this.redraw();
636      this.focus();
637    }
638  };
639
640  /**
641   * URLs of special button images.
642   * @enum {string}
643   */
644  UserImagesGrid.ButtonImages = {
645    TAKE_PHOTO: 'chrome://theme/IDR_BUTTON_USER_IMAGE_TAKE_PHOTO',
646    CHOOSE_FILE: 'chrome://theme/IDR_BUTTON_USER_IMAGE_CHOOSE_FILE',
647    PROFILE_PICTURE: 'chrome://theme/IDR_PROFILE_PICTURE_LOADING'
648  };
649
650  return {
651    UserImagesGrid: UserImagesGrid
652  };
653});
654