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