VulpesOS/gaia/apps/gallery/js/pick.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

361 lines
13 KiB
JavaScript
Executable file

/* global $, setView, LAYOUT_MODE, photodb, LazyLoader, Spinner, ImageEditor */
/* global cropResizeRotate, MediaFrame */
/* global CONFIG_MAX_PICK_PIXEL_SIZE, CONFIG_MAX_IMAGE_PIXEL_SIZE */
/* exported Pick */
'use strict';
// XXX: the pick activity could, and probably should be handled with a
// completely different entry point from regular invocations of
// Gallery. If we can modularize the bootstrap/startup code and the
// thumbnail display code enough that we can use it in both entry
// points, it would probably be better to do it that way.
/*
* A pick activity can be in two distinct states:
*
* 1) the picking state where the user is browsing thumbnails.
* Tapping a thumbnail moves to state 2. Tapping the cancel
* button cancels the activity and the app exits.
*
* 2) the preview/cropping phase where the user sees a full-screen
* image and may have the option to crop it and also sees
* cancel and done buttons. Tapping cancel moves back to state 1.
* Tapping Done ends the pick activity and the app exits.
*
* This Pick module defines start(), select(), end(), cancel()
* and restart() methods to handle these state transitions.
* Note that this is not a Pick class, just a module of interacting
* functions for managing picks. The gallery code needs to call
* Pick.start() and Pick.crop().
*/
var Pick = (function() {
var request;
var pickType;
var pickWidth, pickHeight;
var pickedFileInfo;
var pickedFile;
var cropEditor;
// Called when we are first start up with the activity request object
function start(activity) {
request = activity;
pickType = request.source.data.type;
if (request.source.data.width && request.source.data.height) {
pickWidth = request.source.data.width;
pickHeight = request.source.data.height;
}
else {
pickWidth = pickHeight = 0;
}
setView(LAYOUT_MODE.pick);
// re-run the font-fit logic when header is visible
var pickHeading = $('pick-header-title');
pickHeading.textContent = pickHeading.textContent;
// Clicking on the pick back button cancels the pick activity.
$('pick-header').addEventListener('action', cancel);
// In crop view, the back button goes back to pick view
$('crop-top').addEventListener('action', restart);
// In crop view, the Done button crops crops the image and returns
// it to the invoking app;
$('crop-done-button').addEventListener('click', end);
}
// Called when the user selects a thumbnail in pick mode.
function select(fileinfo) {
pickedFileInfo = fileinfo;
// Do we actually want to allow the user to crop the image?
var nocrop = request.source.data.nocrop;
if (nocrop) {
// If we're not cropping show file name in the title bar
var fileName = pickedFileInfo.name.split('/').pop();
$('crop-header').textContent =
fileName.substr(0, fileName.lastIndexOf('.')) || fileName;
}
setView(LAYOUT_MODE.crop);
// Before the picked image is loaded, the done button is disabled
// to avoid users picking a black/empty image.
var doneButton = $('crop-done-button');
doneButton.disabled = true;
// We need all of these for cropping the photo:
// - ImageEditor to display the crop overlay.
// - frame_scripts because it has gesture_detector in it.
// - crop_resize_rotate.js scripts for cropResizeRotate().
LazyLoader.load(['js/frame_scripts.js',
'shared/js/media/crop_resize_rotate.js',
'js/ImageEditor.js'], gotScripts);
// When the scripts we need are loaded, load the picked file we need
function gotScripts() {
photodb.getFile(pickedFileInfo.name, gotFile);
}
// This is called with the file that needs to be cropped
function gotFile(file) {
pickedFile = file;
var previewData = pickedFileInfo.metadata.preview;
if (!previewData) {
// If there is no preview at all, this is a small image and
// it is its own preview. Just crop with the full-size image
startCrop();
}
else if (previewData.filename) {
// If there is an external preview file, use that. This means that
// the EXIF preview was not big enough
var storage = navigator.getDeviceStorage('pictures');
var getreq = storage.get(previewData.filename);
getreq.onsuccess = function() {
startCrop(getreq.result);
};
// If we fail to get the preview file, just use the full-size image
getreq.onerror = function() {
startCrop();
};
}
else {
// Otherwise, use the internal EXIF preview.
// This should be the normal case.
startCrop(pickedFile.slice(previewData.start,
previewData.end,
'image/jpeg'));
}
function startCrop(previewBlob) {
// Before the user can crop the image we have to create a
// preview of the image at the correct size and orientation
// if we do not already have one.
var blob, metadata, outputSize, useSpinner;
if (previewBlob) {
// If there is a preview, use it at full size. If we're using
// a preview we need to pass the size of the preview, but the
// EXIF orientation data from the fullsize image.
blob = previewBlob;
metadata = {
width: previewData.width,
height: previewData.height,
rotation: pickedFileInfo.metadata.rotation,
mirrored: pickedFileInfo.metadata.mirrored
};
outputSize = null;
useSpinner = false;
}
else {
// If there is no preview, use the picked file, but specify a maximum
// size so we don't decode at a size larger than needed.
blob = pickedFile;
metadata = pickedFileInfo.metadata;
var windowSize = window.innerWidth * window.innerHeight *
window.devicePixelRatio * window.devicePixelRatio;
outputSize = Math.min(windowSize,
CONFIG_MAX_PICK_PIXEL_SIZE ||
CONFIG_MAX_IMAGE_PIXEL_SIZE);
useSpinner = metadata.width * metadata.height > outputSize;
}
// Make sure the image is rotated correctly so that it appears
// right side up in the crop UI. Note that we only display a spinner
// here if we have to downsample a large image.
if (useSpinner) {
Spinner.show();
}
cropResizeRotate(blob, null, outputSize, null, metadata,
gotRotatedBlob);
}
function gotRotatedBlob(error, rotatedBlob) {
Spinner.hide();
if (error) {
console.error('Error while rotating image:', error);
rotatedBlob = pickedFile;
}
cropEditor = new ImageEditor(rotatedBlob, $('crop-frame'), {},
cropEditorReady, true);
}
function cropEditorReady() {
// Enable the done button so that users can finish picking image.
doneButton.disabled = false;
// If the initiating app doesn't want to allow the user to crop
// the image, we don't display the crop overlay. But we still use
// this image editor to preview the image.
if (nocrop) {
// Set a fake crop region even though we won't display it
// so that hasBeenCropped() works.
cropEditor.cropOverlayRegion.left = 0;
cropEditor.cropOverlayRegion.top = 0;
cropEditor.cropOverlayRegion.right = cropEditor.dest.w;
cropEditor.cropOverlayRegion.bottom = cropEditor.dest.h;
return;
}
cropEditor.showCropOverlay();
if (pickWidth) {
cropEditor.setCropAspectRatio(pickWidth, pickHeight);
}
else {
cropEditor.setCropAspectRatio(); // free form cropping
}
}
}
}
function end() {
// First, figure out what kind of image to return to the requesting app.
// If the activity request specifically included 'image/jpeg' or
// 'image/png', then we'll use that type. Otherwise, if a generic
// 'image/*' was requested (or if an unsupported type was requested)
// then we use null as the type. This value is passed to
// cropResizeRotate() and will leave the image unchanged if possible
// or will use jpeg if changes are needed.
// EXIF should be preserved.
if (Array.isArray(pickType)) {
if (pickType.indexOf(pickedFileInfo.type) !== -1) {
pickType = pickedFileInfo.type;
}
else if (pickType.indexOf('image/jpeg') !== -1) {
pickType = 'image/jpeg+exif';
}
else if (pickType.indexOf('image/png') !== -1) {
pickType = 'image/png';
}
else {
pickType = null; // Return unchanged or convert to JPEG
}
}
else if (pickType === 'image/*') {
pickType = null; // Return unchanged or convert to JPEG
}
if (pickType && pickType !== 'image/jpeg' &&
pickType !== 'image/jpeg+exif' && pickType !== 'image/png') {
pickType = null; // Return unchanged or convert to JPEG
}
else if (!pickType && pickedFileInfo.type == 'image/jpeg') {
// if we picked a JPEG image, then explicitly force EXIF copy.
pickType = 'image/jpeg+exif';
}
// In order to determine the cropRegion and outputSize arguments to
// cropResizeRotate() below we need to know the actual image size.
// If the image has EXIF rotation, we need to take that into account.
var fullImageWidth, fullImageHeight;
var rotation = pickedFileInfo.metadata.rotation || 0;
if (rotation === 90 || rotation === 270) {
fullImageWidth = pickedFileInfo.metadata.height;
fullImageHeight = pickedFileInfo.metadata.width;
}
else {
fullImageWidth = pickedFileInfo.metadata.width;
fullImageHeight = pickedFileInfo.metadata.height;
}
var cropRegion, cropFraction;
if (request.source.data.nocrop || !cropEditor.hasBeenCropped()) {
cropRegion = null;
cropFraction = 1;
}
else {
// Get the user's crop region from the crop editor
cropRegion = cropEditor.getCropRegion();
cropFraction = cropRegion.width * cropRegion.height;
// Scale to match the actual image size
cropRegion.left = Math.round(cropRegion.left * fullImageWidth);
cropRegion.top = Math.round(cropRegion.top * fullImageHeight);
cropRegion.width = Math.round(cropRegion.width * fullImageWidth);
cropRegion.height = Math.round(cropRegion.height * fullImageHeight);
}
var outputSize;
if (pickWidth && pickHeight) {
outputSize = { width: pickWidth, height: pickHeight };
}
else {
// If no desired size is specified, we have to impose some kind of limit
// so that really big images aren't decoded at full size. If there is no
// build time configuration that specifies the desired maximum pick size,
// check for maximum decode size device can handle based on device memory
// and set outputsize to MediaFrame.maxImageDecodeSize
// else we use half of the configured maximum decode size. Pick
// activities are memory-sensitive because the system app needs to keep
// both the requesting app and the gallery app alive at once.
outputSize =
CONFIG_MAX_PICK_PIXEL_SIZE ||
MediaFrame.maxImageDecodeSize ||
CONFIG_MAX_IMAGE_PIXEL_SIZE >> 1;
// If the pick request specifed a maxFileSizeBytes parameter then
// we'll use this as a hint for the output size. (We make no guarantee
// about the file size of the returned blob, but we try to be close)
// JPEG files typically have about 3 times as many pixels as bytes.
if (request.source.data.maxFileSizeBytes) {
var requestOutputSize =
Math.round(request.source.data.maxFileSizeBytes / cropFraction * 3);
outputSize = Math.min(outputSize, requestOutputSize);
}
}
// show spinner if cropResizeRotate will decode and modify the image
if (cropRegion !== null ||
typeof outputsize === 'object' ||
outputSize < fullImageWidth * fullImageHeight ||
pickedFileInfo.metadata.rotation ||
pickedFileInfo.metadata.mirrored) {
Spinner.show();
}
cropResizeRotate(pickedFile, cropRegion, outputSize, pickType,
pickedFileInfo.metadata,
function(error, blob) {
Spinner.hide();
if (error) {
console.error('while resizing image: ' + error);
blob = pickedFile;
}
// Finally, return the blob to the invoking app
request.postResult({
name: blob.name || pickedFile.name,
type: blob.type,
blob: blob
});
});
}
function cancel() {
request.postError('pick cancelled');
}
// Stop cropping the image and go back to picking mode
function restart() {
pickedFileInfo = pickedFile = null;
if (cropEditor) {
cropEditor.destroy();
cropEditor = null;
}
setView(LAYOUT_MODE.pick);
}
return {
start: start,
select: select,
cancel: cancel,
restart: restart,
end: end
};
}());