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