blob: 6bcd7ab71dcc2c54f15bf8ab6f08e543388c0b23 [file] [log] [blame]
// Copyright (c) 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {browserProxy} from './browser_proxy/browser_proxy.js';
import {ErrorLevel, ErrorType, reportError} from './error.js';
import * as state from './state.js';
import * as tooltip from './tooltip.js';
import {Facing} from './type.js';
/**
* Gets the clockwise rotation and flip that can orient a photo to its upright
* position of the photo and drop its orientation information.
* @param {!Blob} blob JPEG blob that might contain EXIF orientation field.
* @return {!Promise<{rotation: number, flip: boolean, blob: !Blob}>} The
* rotation, flip information of photo and the photo blob after drop those
* information.
*/
function dropPhotoOrientation(blob) {
let /** !Blob */ blobWithoutOrientation = blob;
const getOrientation = (async () => {
const buffer = await blob.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint16(0, false) !== 0xFFD8) {
return 1;
}
const length = view.byteLength;
let offset = 2;
while (offset < length) {
if (view.getUint16(offset + 2, false) <= 8) {
break;
}
const marker = view.getUint16(offset, false);
offset += 2;
if (marker === 0xFFE1) {
if (view.getUint32(offset += 2, false) !== 0x45786966) {
break;
}
const little = view.getUint16(offset += 6, false) === 0x4949;
offset += view.getUint32(offset + 4, little);
const tags = view.getUint16(offset, little);
offset += 2;
for (let i = 0; i < tags; i++) {
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
offset += (i * 12) + 8;
const orientation = view.getUint16(offset, little);
view.setUint16(offset, 1, little);
blobWithoutOrientation = new Blob([buffer]);
return orientation;
}
}
} else if ((marker & 0xFF00) !== 0xFF00) {
break;
} else {
offset += view.getUint16(offset, false);
}
}
return 1;
})();
return getOrientation
.then((orientation) => {
switch (orientation) {
case 1:
return {rotation: 0, flip: false};
case 2:
return {rotation: 0, flip: true};
case 3:
return {rotation: 180, flip: false};
case 4:
return {rotation: 180, flip: true};
case 5:
return {rotation: 90, flip: true};
case 6:
return {rotation: 90, flip: false};
case 7:
return {rotation: 270, flip: true};
case 8:
return {rotation: 270, flip: false};
default:
return {rotation: 0, flip: false};
}
})
.then((orientInfo) => {
return Object.assign(orientInfo, {blob: blobWithoutOrientation});
});
}
/**
* Orients a photo to the upright orientation.
* @param {!Blob} blob Photo as a blob.
* @param {function(Blob)} onSuccess Success callback with the result photo as
* a blob.
* @param {function()} onFailure Failure callback.
*/
export function orientPhoto(blob, onSuccess, onFailure) {
// TODO(shenghao): Revise or remove this function if it's no longer
// applicable.
const drawPhoto = function(original, orientation, onSuccess, onFailure) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const canvasSquareLength = Math.max(original.width, original.height);
canvas.width = canvasSquareLength;
canvas.height = canvasSquareLength;
const [centerX, centerY] = [canvas.width / 2, canvas.height / 2];
context.translate(centerX, centerY);
context.rotate(orientation.rotation * Math.PI / 180);
if (orientation.flip) {
context.scale(-1, 1);
}
context.drawImage(
original, -original.width / 2, -original.height / 2, original.width,
original.height);
if (orientation.flip) {
context.scale(-1, 1);
}
context.rotate(-orientation.rotation * Math.PI / 180);
context.translate(-centerX, -centerY);
const outputCanvas = document.createElement('canvas');
if (orientation.rotation === 90 || orientation.rotation === 270) {
outputCanvas.width = original.height;
outputCanvas.height = original.width;
} else {
outputCanvas.width = original.width;
outputCanvas.height = original.height;
}
const imageData = context.getImageData(
(canvasSquareLength - outputCanvas.width) / 2,
(canvasSquareLength - outputCanvas.height) / 2, outputCanvas.width,
outputCanvas.height);
const outputContext = outputCanvas.getContext('2d');
outputContext.putImageData(imageData, 0, 0);
outputCanvas.toBlob(function(blob) {
if (blob) {
onSuccess(blob);
} else {
onFailure();
}
}, 'image/jpeg');
};
dropPhotoOrientation(blob).then((orientation) => {
if (orientation.rotation === 0 && !orientation.flip) {
onSuccess(blob);
} else {
const original = document.createElement('img');
original.onload = function() {
drawPhoto(original, orientation, onSuccess, onFailure);
};
original.onerror = onFailure;
original.src = URL.createObjectURL(orientation.blob);
}
});
}
/**
* Cancels animating the element by removing 'animate' class.
* @param {HTMLElement} element Element for canceling animation.
* @return {!Promise} Promise resolved when ongoing animation is canceled and
* next animation can be safely applied.
*/
export function animateCancel(element) {
element.classList.remove('animate');
element.classList.add('cancel-animate');
/** @suppress {suspiciousCode} */
element.offsetWidth; // Force calculation to re-apply animation.
element.classList.remove('cancel-animate');
// Assumes transitioncancel, transitionend, animationend events from previous
// animation are all cleared after requestAnimationFrame().
return new Promise((r) => requestAnimationFrame(r));
}
/**
* Waits for animation completed.
* @param {!HTMLElement} element Element to be animated.
* @return {!Promise} Promise is resolved when animation is completed or
* cancelled.
*/
function waitAnimationCompleted(element) {
return new Promise((resolve, reject) => {
let animationCount = 0;
const onStart = (event) =>
void (event.target === element && animationCount++);
const onFinished = (event, callback) => {
if (event.target !== element || --animationCount !== 0) {
return;
}
events.forEach(([e, fn]) => element.removeEventListener(e, fn));
callback();
};
const events = [
['transitionrun', onStart], ['animationstart', onStart],
['transitionend', (event) => onFinished(event, resolve)],
['animationend', (event) => onFinished(event, resolve)],
['transitioncancel', (event) => onFinished(event, resolve)],
// animationcancel is not implemented on chrome.
];
events.forEach(([e, fn]) => element.addEventListener(e, fn));
});
}
/**
* Animates the element once by applying 'animate' class.
* @param {HTMLElement} element Element to be animated.
* @param {function()=} callback Callback called on completion.
*/
export function animateOnce(element, callback) {
animateCancel(element).then(() => {
element.classList.add('animate');
waitAnimationCompleted(element).finally(() => {
element.classList.remove('animate');
if (callback) {
callback();
}
});
});
}
/**
* Returns a shortcut string, such as Ctrl-Alt-A.
* @param {Event} event Keyboard event.
* @return {string} Shortcut identifier.
*/
export function getShortcutIdentifier(event) {
let identifier = (event.ctrlKey ? 'Ctrl-' : '') +
(event.altKey ? 'Alt-' : '') + (event.shiftKey ? 'Shift-' : '') +
(event.metaKey ? 'Meta-' : '');
if (event.key) {
switch (event.key) {
case 'ArrowLeft':
identifier += 'Left';
break;
case 'ArrowRight':
identifier += 'Right';
break;
case 'ArrowDown':
identifier += 'Down';
break;
case 'ArrowUp':
identifier += 'Up';
break;
case 'a':
case 'p':
case 's':
case 'v':
case 'r':
identifier += event.key.toUpperCase();
break;
default:
identifier += event.key;
}
}
return identifier;
}
/**
* Makes the element unfocusable by mouse.
* @param {HTMLElement} element Element to be unfocusable.
*/
export function makeUnfocusableByMouse(element) {
element.addEventListener('mousedown', (event) => event.preventDefault());
}
/**
* Opens help.
*/
export function openHelp() {
window.open(
'https://support.google.com/chromebook/?p=camera_usage_on_chromebook');
}
/**
* Sets up i18n messages on DOM subtree by i18n attributes.
* @param {!HTMLElement} rootElement Root of DOM subtree to be set up with.
*/
export function setupI18nElements(rootElement) {
const getElements = (attr) => rootElement.querySelectorAll('[' + attr + ']');
const getMessage = (element, attr) =>
browserProxy.getI18nMessage(element.getAttribute(attr));
const setAriaLabel = (element, attr) =>
element.setAttribute('aria-label', getMessage(element, attr));
getElements('i18n-content')
.forEach(
(element) => element.textContent =
getMessage(element, 'i18n-content'));
getElements('i18n-tooltip-true')
.forEach(
(element) => element.setAttribute(
'tooltip-true', getMessage(element, 'i18n-tooltip-true')));
getElements('i18n-tooltip-false')
.forEach(
(element) => element.setAttribute(
'tooltip-false', getMessage(element, 'i18n-tooltip-false')));
getElements('i18n-aria')
.forEach((element) => setAriaLabel(element, 'i18n-aria'));
tooltip.setup(getElements('i18n-label'))
.forEach((element) => setAriaLabel(element, 'i18n-label'));
}
/**
* Reads blob into Image.
* @param {!Blob} blob
* @return {!Promise<!HTMLImageElement>}
* @throws {Error}
*/
export function blobToImage(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load unprocessed image'));
img.src = URL.createObjectURL(blob);
});
}
/**
* Gets default facing according to device mode.
* @return {!Facing}
*/
export function getDefaultFacing() {
return state.get(state.State.TABLET) ? Facing.ENVIRONMENT : Facing.USER;
}
/**
* Scales the input picture to target width and height with respect to original
* aspect ratio.
* @param {string} url Picture as an URL.
* @param {boolean} isVideo Picture is a video.
* @param {number} width Target width to be scaled to.
* @param {number=} height Target height to be scaled to. In default, set to
* corresponding rounded height with respect to target width and aspect
* ratio of input picture.
* @return {!Promise<!Blob>} Promise for the result.
*/
export async function scalePicture(url, isVideo, width, height = undefined) {
const element = document.createElement(isVideo ? 'video' : 'img');
if (isVideo) {
element.preload = 'auto';
}
await new Promise((resolve, reject) => {
element.addEventListener(isVideo ? 'canplay' : 'load', resolve);
element.addEventListener('error', reject);
element.src = url;
});
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (height === undefined) {
const ratio = isVideo ? element.videoHeight / element.videoWidth :
element.height / element.width;
height = Math.round(width * ratio);
}
canvas.width = width;
canvas.height = height;
context.drawImage(element, 0, 0, width, height);
/**
* @type {Uint8ClampedArray} A one-dimensional pixels array in RGBA order.
*/
const data = context.getImageData(0, 0, width, height).data;
if (data.every((byte) => byte === 0)) {
reportError(
ErrorType.BROKEN_THUMBNAIL, ErrorLevel.ERROR,
new Error('The thumbnail content is broken.'));
// Do not throw an error here. A black thumbnail is still better than no
// thumbnail to let user open the corresponding picutre in gallery.
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create thumbnail.'));
}
}, 'image/jpeg');
});
}
/**
* Toggle checked value of element.
* @param {!HTMLInputElement} element
* @param {boolean} checked
*/
export function toggleChecked(element, checked) {
element.checked = checked;
element.dispatchEvent(new Event('change'));
}
/**
* Binds on/off of specified state with different aria label on an element.
* @param {!{element: !Element, state: state.State, onLabel: string,
* offLabel: string}} params
*/
export function bindElementAriaLabelWithState(
{element, state: s, onLabel, offLabel}) {
state.addObserver(s, (value) => {
const label = value ? onLabel : offLabel;
element.setAttribute('i18n-label', label);
element.setAttribute('aria-label', browserProxy.getI18nMessage(label));
});
}