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