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.
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;
13 * Dimensions for camera capture.
22 * Path for internal URLs.
25 var CHROME_THEME_PATH = 'chrome://theme';
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.
34 * @extends {cr.ui.GridItem}
36 function UserImagesGridItem(imageInfo) {
37 var el = new GridItem(imageInfo);
38 el.__proto__ = UserImagesGridItem.prototype;
42 UserImagesGridItem.prototype = {
43 __proto__: GridItem.prototype,
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';
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(); };
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
74 * @param {cr.ui.Grid} grid The grid to interact with.
76 * @extends {cr.ui.GridSelectionController}
78 function UserImagesGridSelectionController(selectionModel, grid) {
79 GridSelectionController.call(this, selectionModel, grid);
82 UserImagesGridSelectionController.prototype = {
83 __proto__: GridSelectionController.prototype,
86 getIndexBefore: function(index) {
88 GridSelectionController.prototype.getIndexBefore.call(this, index);
89 return result == -1 ? this.getLastIndex() : result;
93 getIndexAfter: function(index) {
95 GridSelectionController.prototype.getIndexAfter.call(this, index);
96 return result == -1 ? this.getFirstIndex() : result;
100 handleKeyDown: function(e) {
101 if (e.keyIdentifier == 'Enter')
102 cr.dispatchSimpleEvent(this.grid_, 'activate');
104 GridSelectionController.prototype.handleKeyDown.call(this, e);
109 * Creates a new user images grid element.
110 * @param {Object=} opt_propertyBag Optional properties.
112 * @extends {cr.ui.Grid}
114 var UserImagesGrid = cr.ui.define('grid');
116 UserImagesGrid.prototype = {
117 __proto__: Grid.prototype,
120 createSelectionController: function(sm) {
121 return new UserImagesGridSelectionController(sm, this);
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)} */(
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;
140 * Handles double click on the image grid.
141 * @param {Event} e Double click Event.
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');
152 * Handles selection change.
153 * @param {Event} e Double click Event.
156 handleChange_: function(e) {
157 if (this.selectedItem === null)
160 var oldSelectionType = this.selectionType;
162 // Update current selection type.
163 this.selectionType = this.selectedItem.type;
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');
169 this.updatePreview_();
171 var e = new Event('select');
172 e.oldSelectionType = oldSelectionType;
173 this.dispatchEvent(e);
177 * Updates the preview image, if present.
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';
186 this.previewImage_.src = url;
191 * Whether a camera is present or not.
194 get cameraPresent() {
195 return this.cameraPresent_;
197 set cameraPresent(value) {
198 this.cameraPresent_ = value;
200 this.cameraImage = null;
204 * Whether camera is actually streaming video. May be |false| even when
205 * camera is present and shown but still initializing.
209 return this.previewElement.classList.contains('online');
211 set cameraOnline(value) {
212 this.previewElement.classList.toggle('online', value);
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
221 startCamera: function(onAvailable, onAbsent) {
223 this.cameraStartInProgress_ = true;
224 navigator.webkitGetUserMedia(
226 this.handleCameraAvailable_.bind(this, onAvailable),
227 this.handleCameraAbsent_.bind(this));
231 * Stops camera capture, if it's currently active.
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;
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.
250 handleCameraAvailable_: function(onAvailable, stream) {
251 if (this.cameraStartInProgress_ && onAvailable()) {
252 this.cameraVideo_.src = URL.createObjectURL(stream);
253 this.cameraStream_ = stream;
257 this.cameraStartInProgress_ = false;
261 * Handles camera check failure.
262 * @param {NavigatorUserMediaError=} err Error object.
265 handleCameraAbsent_: function(err) {
266 this.cameraPresent = false;
267 this.cameraOnline = false;
268 this.cameraStartInProgress_ = false;
272 * Handles successful camera capture start.
275 handleVideoStarted_: function() {
276 this.cameraOnline = true;
277 this.handleVideoUpdate_();
281 * Handles camera stream update. Called regularly (at rate no greater then
282 * 4/sec) while camera stream is live.
285 handleVideoUpdate_: function() {
286 this.lastFrameTime_ = new Date().getTime();
290 * Type of the selected image (one of 'default', 'profile', 'camera').
291 * Setting it will update class list of |previewElement|.
294 get selectionType() {
295 return this.selectionType_;
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');
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();
311 // Timeout guarantees processing AFTER style changes display attribute.
312 setTimeout(setFocusIfLost, 0);
316 * Current image captured from camera as data URL. Setting to null will
317 * return to the live camera stream.
318 * @type {(string|undefined)}
321 return this.cameraImage_;
323 set cameraImage(imageUrl) {
324 this.cameraLive = !imageUrl;
325 if (this.cameraPresent && !imageUrl)
326 imageUrl = UserImagesGrid.ButtonImages.TAKE_PHOTO;
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';
333 this.removeItem(this.cameraImage_);
334 this.cameraImage_ = null;
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.
343 setCameraTitles: function(placeholderTitle, capturedImageTitle) {
344 this.placeholderTitle_ = placeholderTitle;
345 this.capturedImageTitle_ = capturedImageTitle;
346 this.cameraTitle_ = this.placeholderTitle_;
350 * True when camera is in live mode (i.e. no still photo selected).
354 return this.cameraLive_;
356 set cameraLive(value) {
357 this.cameraLive_ = value;
358 this.previewElement.classList[value ? 'add' : 'remove']('live');
362 * Should only be queried from the 'change' event listener, true if the
363 * change event was triggered by a programmatical selection change.
366 get inProgramSelection() {
367 return this.inProgramSelection_;
371 * URL of the image selected.
374 get selectedItemUrl() {
375 var selectedItem = this.selectedItem;
376 return selectedItem ? selectedItem.url : null;
378 set selectedItemUrl(url) {
379 for (var i = 0, el; el = this.dataModel.item(i); i++) {
381 this.selectedItemIndex = i;
386 * Set index to the image selected.
387 * @type {number} index The index of selected image.
389 set selectedItemIndex(index) {
390 this.inProgramSelection_ = true;
391 this.selectionModel.selectedIndex = index;
392 this.inProgramSelection_ = false;
397 var index = this.selectionModel.selectedIndex;
398 return index != -1 ? this.dataModel.item(index) : null;
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;
409 * Element containing the preview image (the first IMG element) and the
410 * camera live stream (the first VIDEO element).
411 * @type {HTMLElement}
413 get previewElement() {
414 // TODO(ivankr): temporary hack for non-HTML5 version.
415 return this.previewElement_ || this;
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;
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
439 return this.flipPhoto_ || false;
441 set flipPhoto(value) {
442 if (this.flipPhoto_ == value)
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);
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.
463 takePhoto: function() {
464 if (!this.cameraOnline)
466 var canvas = /** @type {HTMLCanvasElement} */(
467 document.createElement('canvas'));
468 canvas.width = CAPTURE_SIZE.width;
469 canvas.height = CAPTURE_SIZE.height;
472 /** @type {CanvasRenderingContext2D} */(canvas.getContext('2d')),
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;
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);
488 * Discard current photo and return to the live camera stream.
490 discardPhoto: function() {
491 this.cameraTitle_ = this.placeholderTitle_;
492 this.cameraImage = null;
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.
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 + '!');
511 if (width / destSize.width > height / destSize.height) {
512 // Full height, crop left/right.
514 src.width = height * destSize.width / destSize.height;
516 // Full width, crop top/bottom.
518 src.height = width * destSize.height / destSize.width;
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);
527 * Flips frame horizontally.
528 * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} source
530 * @return {string} Flipped frame as data URL.
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');
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.
554 // TODO(ivankr): this function needs some argument list refactoring.
555 addItem: function(url, opt_title, opt_clickHandler, opt_position,
560 clickHandler: opt_clickHandler,
561 decorateFn: opt_decorateFn
563 this.inProgramSelection_ = true;
564 if (opt_position !== undefined)
565 this.dataModel.splice(opt_position, 0, imageInfo);
567 this.dataModel.push(imageInfo);
568 this.inProgramSelection_ = false;
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.
577 indexOf: function(imageInfo) {
578 return this.dataModel.indexOf(imageInfo);
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.
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(
595 opt_title === undefined ? imageInfo.title : opt_title,
596 imageInfo.clickHandler,
598 imageInfo.decorateFn);
599 // Update image data with the reset of the keys from the old data.
600 for (var k in imageInfo) {
602 newInfo[k] = imageInfo[k];
605 this.selectedItem = newInfo;
610 * Removes previously added image from the grid.
611 * @param {Object} imageInfo Image data returned from the addItem() call.
613 removeItem: function(imageInfo) {
614 var index = this.indexOf(imageInfo);
616 var wasSelected = this.selectionModel.selectedIndex == index;
617 this.inProgramSelection_ = true;
618 this.dataModel.splice(index, 1);
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));
624 this.inProgramSelection_ = false;
629 * Forces re-display, size re-calculation and focuses grid.
631 updateAndFocus: function() {
632 // Recalculate the measured item size.
633 this.measured_ = null;
641 * URLs of special button images.
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'
651 UserImagesGrid: UserImagesGrid