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 imageEl
.alt
= imageEl
.title
;
58 if (typeof this.dataItem
.clickHandler
== 'function')
59 imageEl
.addEventListener('mousedown', this.dataItem
.clickHandler
);
60 // Remove any garbage added by GridItem and ListItem decorators.
61 this.textContent
= '';
62 this.appendChild(imageEl
);
63 if (typeof this.dataItem
.decorateFn
== 'function')
64 this.dataItem
.decorateFn(this);
65 this.setAttribute('role', 'option');
66 this.oncontextmenu = function(e
) { e
.preventDefault(); };
71 * Creates a selection controller that wraps selection on grid ends
72 * and translates Enter presses into 'activate' events.
73 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
75 * @param {cr.ui.Grid} grid The grid to interact with.
77 * @extends {cr.ui.GridSelectionController}
79 function UserImagesGridSelectionController(selectionModel
, grid
) {
80 GridSelectionController
.call(this, selectionModel
, grid
);
83 UserImagesGridSelectionController
.prototype = {
84 __proto__
: GridSelectionController
.prototype,
87 getIndexBefore: function(index
) {
89 GridSelectionController
.prototype.getIndexBefore
.call(this, index
);
90 return result
== -1 ? this.getLastIndex() : result
;
94 getIndexAfter: function(index
) {
96 GridSelectionController
.prototype.getIndexAfter
.call(this, index
);
97 return result
== -1 ? this.getFirstIndex() : result
;
101 handleKeyDown: function(e
) {
102 if (e
.keyIdentifier
== 'Enter')
103 cr
.dispatchSimpleEvent(this.grid_
, 'activate');
105 GridSelectionController
.prototype.handleKeyDown
.call(this, e
);
110 * Creates a new user images grid element.
111 * @param {Object=} opt_propertyBag Optional properties.
113 * @extends {cr.ui.Grid}
115 var UserImagesGrid
= cr
.ui
.define('grid');
117 UserImagesGrid
.prototype = {
118 __proto__
: Grid
.prototype,
121 createSelectionController: function(sm
) {
122 return new UserImagesGridSelectionController(sm
, this);
126 decorate: function() {
127 Grid
.prototype.decorate
.call(this);
128 this.dataModel
= new ArrayDataModel([]);
129 this.itemConstructor
= /** @type {function(new:cr.ui.ListItem, *)} */(
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.
249 * @suppress {deprecated}
251 handleCameraAvailable_: function(onAvailable
, stream
) {
252 if (this.cameraStartInProgress_
&& onAvailable()) {
253 this.cameraVideo_
.src
= URL
.createObjectURL(stream
);
254 this.cameraStream_
= stream
;
258 this.cameraStartInProgress_
= false;
262 * Handles camera check failure.
263 * @param {NavigatorUserMediaError=} err Error object.
266 handleCameraAbsent_: function(err
) {
267 this.cameraPresent
= false;
268 this.cameraOnline
= false;
269 this.cameraStartInProgress_
= false;
273 * Handles successful camera capture start.
276 handleVideoStarted_: function() {
277 this.cameraOnline
= true;
278 this.handleVideoUpdate_();
282 * Handles camera stream update. Called regularly (at rate no greater then
283 * 4/sec) while camera stream is live.
286 handleVideoUpdate_: function() {
287 this.lastFrameTime_
= new Date().getTime();
291 * Type of the selected image (one of 'default', 'profile', 'camera').
292 * Setting it will update class list of |previewElement|.
295 get selectionType() {
296 return this.selectionType_
;
298 set selectionType(value
) {
299 this.selectionType_
= value
;
300 var previewClassList
= this.previewElement
.classList
;
301 previewClassList
[value
== 'default' ? 'add' : 'remove']('default-image');
302 previewClassList
[value
== 'profile' ? 'add' : 'remove']('profile-image');
303 previewClassList
[value
== 'camera' ? 'add' : 'remove']('camera');
305 var setFocusIfLost = function() {
306 // Set focus to the grid, if focus is not on UI.
307 if (!document
.activeElement
||
308 document
.activeElement
.tagName
== 'BODY') {
309 $('user-image-grid').focus();
312 // Timeout guarantees processing AFTER style changes display attribute.
313 setTimeout(setFocusIfLost
, 0);
317 * Current image captured from camera as data URL. Setting to null will
318 * return to the live camera stream.
319 * @type {(string|undefined)}
322 return this.cameraImage_
;
324 set cameraImage(imageUrl
) {
325 this.cameraLive
= !imageUrl
;
326 if (this.cameraPresent
&& !imageUrl
)
327 imageUrl
= UserImagesGrid
.ButtonImages
.TAKE_PHOTO
;
329 this.cameraImage_
= this.cameraImage_
?
330 this.updateItem(this.cameraImage_
, imageUrl
, this.cameraTitle_
) :
331 this.addItem(imageUrl
, this.cameraTitle_
, undefined, 0);
332 this.cameraImage_
.type
= 'camera';
334 this.removeItem(this.cameraImage_
);
335 this.cameraImage_
= null;
340 * Updates the titles for the camera element.
341 * @param {string} placeholderTitle Title when showing a placeholder.
342 * @param {string} capturedImageTitle Title when showing a captured photo.
344 setCameraTitles: function(placeholderTitle
, capturedImageTitle
) {
345 this.placeholderTitle_
= placeholderTitle
;
346 this.capturedImageTitle_
= capturedImageTitle
;
347 this.cameraTitle_
= this.placeholderTitle_
;
351 * True when camera is in live mode (i.e. no still photo selected).
355 return this.cameraLive_
;
357 set cameraLive(value
) {
358 this.cameraLive_
= value
;
359 this.previewElement
.classList
[value
? 'add' : 'remove']('live');
363 * Should only be queried from the 'change' event listener, true if the
364 * change event was triggered by a programmatical selection change.
367 get inProgramSelection() {
368 return this.inProgramSelection_
;
372 * URL of the image selected.
375 get selectedItemUrl() {
376 var selectedItem
= this.selectedItem
;
377 return selectedItem
? selectedItem
.url
: null;
379 set selectedItemUrl(url
) {
380 for (var i
= 0, el
; el
= this.dataModel
.item(i
); i
++) {
382 this.selectedItemIndex
= i
;
387 * Set index to the image selected.
388 * @type {number} index The index of selected image.
390 set selectedItemIndex(index
) {
391 this.inProgramSelection_
= true;
392 this.selectionModel
.selectedIndex
= index
;
393 this.inProgramSelection_
= false;
398 var index
= this.selectionModel
.selectedIndex
;
399 return index
!= -1 ? this.dataModel
.item(index
) : null;
401 set selectedItem(selectedItem
) {
402 var index
= this.indexOf(selectedItem
);
403 this.inProgramSelection_
= true;
404 this.selectionModel
.selectedIndex
= index
;
405 this.selectionModel
.leadIndex
= index
;
406 this.inProgramSelection_
= false;
410 * Element containing the preview image (the first IMG element) and the
411 * camera live stream (the first VIDEO element).
412 * @type {HTMLElement}
414 get previewElement() {
415 // TODO(ivankr): temporary hack for non-HTML5 version.
416 return this.previewElement_
|| this;
418 set previewElement(value
) {
419 this.previewElement_
= value
;
420 this.previewImage_
= value
.querySelector('img');
421 this.cameraVideo_
= value
.querySelector('video');
422 this.cameraVideo_
.addEventListener('canplay',
423 this.handleVideoStarted_
.bind(this));
424 this.cameraVideo_
.addEventListener('timeupdate',
425 this.handleVideoUpdate_
.bind(this));
426 this.updatePreview_();
427 // Initialize camera state and check for its presence.
428 this.cameraLive
= true;
429 this.cameraPresent
= false;
433 * Whether the camera live stream and photo should be flipped horizontally.
434 * If setting this property results in photo update, 'photoupdated' event
435 * will be fired with 'dataURL' property containing the photo encoded as
440 return this.flipPhoto_
|| false;
442 set flipPhoto(value
) {
443 if (this.flipPhoto_
== value
)
445 this.flipPhoto_
= value
;
446 this.previewElement
.classList
.toggle('flip-x', value
);
447 /* TODO(merkulova): remove when webkit crbug.com/126479 is fixed. */
448 this.flipPhotoElement.classList.toggle('flip-trick', value);
449 if (!this.cameraLive) {
450 // Flip current still photo.
451 var e = new Event('photoupdated');
452 e.dataURL = this.flipPhoto ?
453 this.flipFrame_(this.previewImage_) : this.previewImage_.src;
454 this.dispatchEvent(e);
459 * Performs photo capture from the live camera stream. 'phototaken' event
460 * will be fired as soon as captured photo is available, with 'dataURL'
461 * property containing the photo encoded as a data URL.
462 * @return {boolean} Whether photo capture was successful.
464 takePhoto: function() {
465 if (!this.cameraOnline
)
467 var canvas
= /** @type {HTMLCanvasElement} */(
468 document
.createElement('canvas'));
469 canvas
.width
= CAPTURE_SIZE
.width
;
470 canvas
.height
= CAPTURE_SIZE
.height
;
473 /** @type {CanvasRenderingContext2D} */(canvas
.getContext('2d')),
475 // Preload image before displaying it.
476 var previewImg
= new Image();
477 previewImg
.addEventListener('load', function(e
) {
478 this.cameraTitle_
= this.capturedImageTitle_
;
479 this.cameraImage
= previewImg
.src
;
481 previewImg
.src
= canvas
.toDataURL('image/png');
482 var e
= new Event('phototaken');
483 e
.dataURL
= this.flipPhoto
? this.flipFrame_(canvas
) : previewImg
.src
;
484 this.dispatchEvent(e
);
489 * Discard current photo and return to the live camera stream.
491 discardPhoto: function() {
492 this.cameraTitle_
= this.placeholderTitle_
;
493 this.cameraImage
= null;
497 * Capture a single still frame from a <video> element, placing it at the
498 * current drawing origin of a canvas context.
499 * @param {HTMLVideoElement} video Video element to capture from.
500 * @param {CanvasRenderingContext2D} ctx Canvas context to draw onto.
501 * @param {{width: number, height: number}} destSize Capture size.
504 captureFrame_: function(video
, ctx
, destSize
) {
505 var width
= video
.videoWidth
;
506 var height
= video
.videoHeight
;
507 if (width
< destSize
.width
|| height
< destSize
.height
) {
508 console
.error('Video capture size too small: ' +
509 width
+ 'x' + height
+ '!');
512 if (width
/ destSize
.width
> height
/ destSize
.height
) {
513 // Full height, crop left/right.
515 src
.width
= height
* destSize
.width
/ destSize
.height
;
517 // Full width, crop top/bottom.
519 src
.height
= width
* destSize
.height
/ destSize
.width
;
521 src
.x
= (width
- src
.width
) / 2;
522 src
.y
= (height
- src
.height
) / 2;
523 ctx
.drawImage(video
, src
.x
, src
.y
, src
.width
, src
.height
,
524 0, 0, destSize
.width
, destSize
.height
);
528 * Flips frame horizontally.
529 * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} source
531 * @return {string} Flipped frame as data URL.
533 flipFrame_: function(source
) {
534 var canvas
= document
.createElement('canvas');
535 canvas
.width
= CAPTURE_SIZE
.width
;
536 canvas
.height
= CAPTURE_SIZE
.height
;
537 var ctx
= canvas
.getContext('2d');
538 ctx
.translate(CAPTURE_SIZE
.width
, 0);
539 ctx
.scale(-1.0, 1.0);
540 ctx
.drawImage(source
, 0, 0);
541 return canvas
.toDataURL('image/png');
545 * Adds new image to the user image grid.
546 * @param {string} url Image URL.
547 * @param {string=} opt_title Image tooltip.
548 * @param {Function=} opt_clickHandler Image click handler.
549 * @param {number=} opt_position If given, inserts new image into
550 * that position (0-based) in image list.
551 * @param {Function=} opt_decorateFn Function called with the list element
552 * as argument to do any final decoration.
553 * @return {!Object} Image data inserted into the data model.
555 // TODO(ivankr): this function needs some argument list refactoring.
556 addItem: function(url
, opt_title
, opt_clickHandler
, opt_position
,
561 clickHandler
: opt_clickHandler
,
562 decorateFn
: opt_decorateFn
564 this.inProgramSelection_
= true;
565 if (opt_position
!== undefined)
566 this.dataModel
.splice(opt_position
, 0, imageInfo
);
568 this.dataModel
.push(imageInfo
);
569 this.inProgramSelection_
= false;
574 * Returns index of an image in grid.
575 * @param {Object} imageInfo Image data returned from addItem() call.
576 * @return {number} Image index (0-based) or -1 if image was not found.
578 indexOf: function(imageInfo
) {
579 return this.dataModel
.indexOf(imageInfo
);
583 * Replaces an image in the grid.
584 * @param {Object} imageInfo Image data returned from addItem() call.
585 * @param {string} imageUrl New image URL.
586 * @param {string=} opt_title New image tooltip (if undefined, tooltip
587 * is left unchanged).
588 * @return {!Object} Image data of the added or updated image.
590 updateItem: function(imageInfo
, imageUrl
, opt_title
) {
591 var imageIndex
= this.indexOf(imageInfo
);
592 var wasSelected
= this.selectionModel
.selectedIndex
== imageIndex
;
593 this.removeItem(imageInfo
);
594 var newInfo
= this.addItem(
596 opt_title
=== undefined ? imageInfo
.title
: opt_title
,
597 imageInfo
.clickHandler
,
599 imageInfo
.decorateFn
);
600 // Update image data with the reset of the keys from the old data.
601 for (var k
in imageInfo
) {
603 newInfo
[k
] = imageInfo
[k
];
606 this.selectedItem
= newInfo
;
611 * Removes previously added image from the grid.
612 * @param {Object} imageInfo Image data returned from the addItem() call.
614 removeItem: function(imageInfo
) {
615 var index
= this.indexOf(imageInfo
);
617 var wasSelected
= this.selectionModel
.selectedIndex
== index
;
618 this.inProgramSelection_
= true;
619 this.dataModel
.splice(index
, 1);
621 // If item removed was selected, select the item next to it.
622 this.selectedItem
= this.dataModel
.item(
623 Math
.min(this.dataModel
.length
- 1, index
));
625 this.inProgramSelection_
= false;
630 * Forces re-display, size re-calculation and focuses grid.
632 updateAndFocus: function() {
633 // Recalculate the measured item size.
634 this.measured_
= null;
641 * Appends default images to the image grid. Should only be called once.
642 * @param {Array<{url: string, author: string,
643 * website: string, title: string}>} imagesData
644 * An array of default images data, including URL, author, title and
647 setDefaultImages: function(imagesData
) {
648 for (var i
= 0, data
; data
= imagesData
[i
]; i
++) {
649 var item
= this.addItem(data
.url
, data
.title
);
650 item
.type
= 'default';
651 item
.author
= data
.author
|| '';
652 item
.website
= data
.website
|| '';
658 * URLs of special button images.
661 UserImagesGrid
.ButtonImages
= {
662 TAKE_PHOTO
: 'chrome://theme/IDR_BUTTON_USER_IMAGE_TAKE_PHOTO',
663 CHOOSE_FILE
: 'chrome://theme/IDR_BUTTON_USER_IMAGE_CHOOSE_FILE',
664 PROFILE_PICTURE
: 'chrome://theme/IDR_PROFILE_PICTURE_LOADING'
668 UserImagesGrid
: UserImagesGrid