Home | History | Annotate | Download | only in chromeos
      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 cr.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