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 * Number of frames recorded by takeVideo().
16 var RECORD_FRAMES
= 48;
19 * FPS at which camera stream is recorded.
25 * Dimensions for camera capture.
34 * Path for internal URLs.
37 var CHROME_THEME_PATH
= 'chrome://theme';
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.
45 * @extends {cr.ui.GridItem}
47 function UserImagesGridItem(imageInfo
) {
48 var el
= new GridItem(imageInfo
);
49 el
.__proto__
= UserImagesGridItem
.prototype;
53 UserImagesGridItem
.prototype = {
54 __proto__
: GridItem
.prototype,
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';
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(); };
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
85 * @param {cr.ui.Grid} grid The grid to interact with.
87 * @extends {cr.ui.GridSelectionController}
89 function UserImagesGridSelectionController(selectionModel
, grid
) {
90 GridSelectionController
.call(this, selectionModel
, grid
);
93 UserImagesGridSelectionController
.prototype = {
94 __proto__
: GridSelectionController
.prototype,
97 getIndexBefore: function(index
) {
99 GridSelectionController
.prototype.getIndexBefore
.call(this, index
);
100 return result
== -1 ? this.getLastIndex() : result
;
104 getIndexAfter: function(index
) {
106 GridSelectionController
.prototype.getIndexAfter
.call(this, index
);
107 return result
== -1 ? this.getFirstIndex() : result
;
111 handleKeyDown: function(e
) {
112 if (e
.keyIdentifier
== 'Enter')
113 cr
.dispatchSimpleEvent(this.grid_
, 'activate');
115 GridSelectionController
.prototype.handleKeyDown
.call(this, e
);
120 * Creates a new user images grid element.
121 * @param {Object=} opt_propertyBag Optional properties.
123 * @extends {cr.ui.Grid}
125 var UserImagesGrid
= cr
.ui
.define('grid');
127 UserImagesGrid
.prototype = {
128 __proto__
: Grid
.prototype,
131 createSelectionController: function(sm
) {
132 return new UserImagesGridSelectionController(sm
, this);
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;
149 * Handles double click on the image grid.
150 * @param {Event} e Double click Event.
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');
161 * Handles selection change.
162 * @param {Event} e Double click Event.
165 handleChange_: function(e
) {
166 if (this.selectedItem
=== null)
169 var oldSelectionType
= this.selectionType
;
171 // Update current selection type.
172 this.selectionType
= this.selectedItem
.type
;
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');
178 this.updatePreview_();
180 var e
= new Event('select');
181 e
.oldSelectionType
= oldSelectionType
;
182 this.dispatchEvent(e
);
186 * Updates the preview image, if present.
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';
195 this.previewImage_
.src
= url
;
200 * Whether a camera is present or not.
203 get cameraPresent() {
204 return this.cameraPresent_
;
206 set cameraPresent(value
) {
207 this.cameraPresent_
= value
;
209 this.cameraImage
= null;
213 * Whether camera is actually streaming video. May be |false| even when
214 * camera is present and shown but still initializing.
218 return this.previewElement
.classList
.contains('online');
220 set cameraOnline(value
) {
221 this.previewElement
.classList
.toggle('online', value
);
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
230 startCamera: function(onAvailable
, onAbsent
) {
232 this.cameraStartInProgress_
= true;
233 navigator
.webkitGetUserMedia(
235 this.handleCameraAvailable_
.bind(this, onAvailable
),
236 this.handleCameraAbsent_
.bind(this));
240 * Stops camera capture, if it's currently active.
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;
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.
259 handleCameraAvailable_: function(onAvailable
, stream
) {
260 if (this.cameraStartInProgress_
&& onAvailable()) {
261 this.cameraVideo_
.src
= URL
.createObjectURL(stream
);
262 this.cameraStream_
= stream
;
266 this.cameraStartInProgress_
= false;
270 * Handles camera check failure.
271 * @param {NavigatorUserMediaError=} err Error object.
274 handleCameraAbsent_: function(err
) {
275 this.cameraPresent
= false;
276 this.cameraOnline
= false;
277 this.cameraStartInProgress_
= false;
281 * Handles successful camera capture start.
284 handleVideoStarted_: function() {
285 this.cameraOnline
= true;
286 this.handleVideoUpdate_();
290 * Handles camera stream update. Called regularly (at rate no greater then
291 * 4/sec) while camera stream is live.
294 handleVideoUpdate_: function() {
295 this.lastFrameTime_
= new Date().getTime();
299 * Type of the selected image (one of 'default', 'profile', 'camera').
300 * Setting it will update class list of |previewElement|.
303 get selectionType() {
304 return this.selectionType_
;
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');
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();
320 // Timeout guarantees processing AFTER style changes display attribute.
321 setTimeout(setFocusIfLost
, 0);
325 * Current image captured from camera as data URL. Setting to null will
326 * return to the live camera stream.
330 return this.cameraImage_
;
332 set cameraImage(imageUrl
) {
333 this.cameraLive
= !imageUrl
;
334 if (this.cameraPresent
&& !imageUrl
)
335 imageUrl
= UserImagesGrid
.ButtonImages
.TAKE_PHOTO
;
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';
342 this.removeItem(this.cameraImage_
);
343 this.cameraImage_
= null;
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.
352 setCameraTitles: function(placeholderTitle
, capturedImageTitle
) {
353 this.placeholderTitle_
= placeholderTitle
;
354 this.capturedImageTitle_
= capturedImageTitle
;
355 this.cameraTitle_
= this.placeholderTitle_
;
359 * True when camera is in live mode (i.e. no still photo selected).
363 return this.cameraLive_
;
365 set cameraLive(value
) {
366 this.cameraLive_
= value
;
367 this.previewElement
.classList
[value
? 'add' : 'remove']('live');
371 * Should only be queried from the 'change' event listener, true if the
372 * change event was triggered by a programmatical selection change.
375 get inProgramSelection() {
376 return this.inProgramSelection_
;
380 * URL of the image selected.
383 get selectedItemUrl() {
384 var selectedItem
= this.selectedItem
;
385 return selectedItem
? selectedItem
.url
: null;
387 set selectedItemUrl(url
) {
388 for (var i
= 0, el
; el
= this.dataModel
.item(i
); i
++) {
390 this.selectedItemIndex
= i
;
395 * Set index to the image selected.
396 * @type {number} index The index of selected image.
398 set selectedItemIndex(index
) {
399 this.inProgramSelection_
= true;
400 this.selectionModel
.selectedIndex
= index
;
401 this.inProgramSelection_
= false;
406 var index
= this.selectionModel
.selectedIndex
;
407 return index
!= -1 ? this.dataModel
.item(index
) : null;
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;
418 * Element containing the preview image (the first IMG element) and the
419 * camera live stream (the first VIDEO element).
420 * @type {HTMLElement}
422 get previewElement() {
423 // TODO(ivankr): temporary hack for non-HTML5 version.
424 return this.previewElement_
|| this;
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;
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
448 return this.flipPhoto_
|| false;
450 set flipPhoto(value
) {
451 if (this.flipPhoto_
== value
)
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);
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.
472 takePhoto: function() {
473 if (!this.cameraOnline
)
475 var canvas
= document
.createElement('canvas');
476 canvas
.width
= CAPTURE_SIZE
.width
;
477 canvas
.height
= CAPTURE_SIZE
.height
;
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
;
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
);
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.
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);
506 callback
: opt_callback
,
510 lastTimestamp
: new Date().getTime()
512 captureData
.timer
= window
.setInterval(
513 this.captureVideoFrame_
.bind(this, captureData
), 1000 / RECORD_FPS
);
517 * Discard current photo and return to the live camera stream.
519 discardPhoto: function() {
520 this.cameraTitle_
= this.placeholderTitle_
;
521 this.cameraImage
= null;
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.
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
+ '!');
540 if (width
/ destSize
.width
> height
/ destSize
.height
) {
541 // Full height, crop left/right.
543 src
.width
= height
* destSize
.width
/ destSize
.height
;
545 // Full width, crop top/bottom.
547 src
.height
= width
* destSize
.height
/ destSize
.width
;
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
);
556 * Flips frame horizontally.
557 * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} source
559 * @return {string} Flipped frame as data URL.
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');
573 * Capture next frame of the video being recorded after a takeVideo() call.
574 * @param {Object} data Property bag with the recorder details.
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
;
583 this.captureFrame_(this.cameraVideo_
, data
.ctx
, CAPTURE_SIZE
);
584 data
.ctx
.translate(0, CAPTURE_SIZE
.height
);
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'));
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.
604 // TODO(ivankr): this function needs some argument list refactoring.
605 addItem: function(url
, opt_title
, opt_clickHandler
, opt_position
,
610 clickHandler
: opt_clickHandler
,
611 decorateFn
: opt_decorateFn
613 this.inProgramSelection_
= true;
614 if (opt_position
!== undefined)
615 this.dataModel
.splice(opt_position
, 0, imageInfo
);
617 this.dataModel
.push(imageInfo
);
618 this.inProgramSelection_
= false;
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.
627 indexOf: function(imageInfo
) {
628 return this.dataModel
.indexOf(imageInfo
);
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.
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(
645 opt_title
=== undefined ? imageInfo
.title
: opt_title
,
646 imageInfo
.clickHandler
,
648 imageInfo
.decorateFn
);
649 // Update image data with the reset of the keys from the old data.
650 for (k
in imageInfo
) {
652 newInfo
[k
] = imageInfo
[k
];
655 this.selectedItem
= newInfo
;
660 * Removes previously added image from the grid.
661 * @param {Object} imageInfo Image data returned from the addItem() call.
663 removeItem: function(imageInfo
) {
664 var index
= this.indexOf(imageInfo
);
666 var wasSelected
= this.selectionModel
.selectedIndex
== index
;
667 this.inProgramSelection_
= true;
668 this.dataModel
.splice(index
, 1);
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
));
674 this.inProgramSelection_
= false;
679 * Forces re-display, size re-calculation and focuses grid.
681 updateAndFocus: function() {
682 // Recalculate the measured item size.
683 this.measured_
= null;
691 * URLs of special button images.
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'
701 UserImagesGrid
: UserImagesGrid