VulpesOS/gaia/apps/gallery/js/ImageEditor.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

1977 lines
64 KiB
JavaScript
Executable file

// This file contains Gallery code for editing images
//
'use strict';
/* global
$,
CONFIG_MAX_EDIT_PIXEL_SIZE,
CONFIG_MAX_IMAGE_PIXEL_SIZE,
cropResizeRotate,
currentFileIndex,
Downsample,
editedPhotoIndex:true,
GestureDetector,
ImageData,
ImageProcessorThread,
files,
justSavedEditedImage:true,
KeyEvent,
LAYOUT_MODE,
photodb,
setView,
showFile,
Spinner,
LazyLoader,
JPEGParser
*/
/* exported
editPhotoIfCardNotFull
*/
var editedPhotoURL; // The blob URL of the photo we're currently editing
var editSettings; // Settings object to perform edits and initialize the UI
var imageEditor;
var currentEditTool;
var savedEditSettings; // Object to save editSettings before entering edit tool
var editOptionButtons =
Array.slice($('edit-options').querySelectorAll('a.radio.button'), 0);
var editBgImageButtons =
Array.slice($('edit-options').querySelectorAll('a.bgimage.button'), 0);
var editAspectMoreButtons =
Array.slice($('aspect-ratio-options-view').querySelectorAll('button'), 0);
// Edit mode event handlers
$('edit-exposure-button').onclick = setEditTool.bind(null, 'exposure');
$('edit-crop-button').onclick = setEditTool.bind(null, 'crop');
$('edit-effect-button').onclick = setEditTool.bind(null, 'effect');
$('edit-enhance-button').onclick = setEditTool.bind(null, 'enhance');
$('edit-header').addEventListener('action', onCancelButton);
$('edit-tool-apply-button').onclick = applyEditTool;
$('edit-save-button').onclick = saveEditedImage;
editOptionButtons.forEach(function(b) { b.onclick = editOptionsHandler; });
editAspectMoreButtons.forEach(function(b) {b.onclick = editAspectMoreHandler;});
/*
* These arrays represent matricies for image processing effects. They
* are passed to the ImageProcessor.setMatrix() method to specify color
* transformations of an image. The property names in this object are
* linked to the Gallery editor UX: if they are changed here, they also
* must be changed in the HTML.
*/
var Effects = {
none: null,
bw: [
0.65, 0.25, 0.10, 0,
0.65, 0.25, 0.10, 0,
0.65, 0.25, 0.10, 0
],
sepia: [
0.393, 0.769, 0.189, 0,
0.349, 0.686, 0.168, 0,
0.272, 0.534, 0.131, 0
],
bluesteel: [
1, 0.25, 0.65, 0,
0.1, 1, 0.65, 0,
0.1, 0.25, 1, 0.1
],
faded: [
1, 0.2, 0.2, 0.03,
0.2, 0.7, 0.2, 0.05,
0.1, 0, 0.8, 0
]
};
// Ensure there is enough space to store an edited copy of photo n
// and if there is, call editPhoto to do so
function editPhotoIfCardNotFull(n) {
var fileinfo = files[n];
var imagesize = fileinfo.size;
photodb.freeSpace(function(freespace) {
// the edited image might take up more space on the disk, but
// not all that much more
if (freespace > imagesize * 2) {
editPhoto(n);
}
else {
document.l10n.formatValue('memorycardfull').then(alert);
}
});
}
/*
* This is the exposure slider component for edit tool. This ought to be
* converted into a reusable slider module, but for now this is a
* custom version that hardcodes things like the -3 to +3 range of values.
*/
var exposureSlider = (function() {
var slider = document.getElementById('exposure-slider');
var bar = document.getElementById('sliderline');
var thumb = document.getElementById('sliderthumb');
var currentExposure = 0;
var dragStart = null;
// prepare gesture detector for slider
var gestureDetector = new GestureDetector(thumb);
gestureDetector.startDetecting();
thumb.addEventListener('pan', function(e) {
// Handle delta so that slider moves correct way
// when user drags it for RTL locales
var delta = document.documentElement.dir === 'ltr' ?
e.detail.absolute.dx : - e.detail.absolute.dx;
var exposureDelta = delta / parseInt(bar.clientWidth, 10) * 6;
// For the firt time of pan event triggered
// set start value to current value.
if (!dragStart) {
dragStart = currentExposure;
}
setExposure(dragStart + exposureDelta);
e.preventDefault();
});
thumb.addEventListener('swipe', function(e) {
// when stopping we init the dragStart to be null
// this way we avoid a 'panstart' event
dragStart = null;
e.preventDefault();
});
thumb.addEventListener('touchstart', function(e) {
thumb.classList.add('active');
});
thumb.addEventListener('touchend', function(e) {
thumb.classList.remove('active');
});
slider.addEventListener('keypress', function(e) {
// screen reader sends key arrow up/down events for adjusting the slider.
if (e.keyCode == KeyEvent.DOM_VK_DOWN) {
setExposure(currentExposure - 0.25);
} else if (e.keyCode == KeyEvent.DOM_VK_UP) {
setExposure(currentExposure + 0.25);
}
});
function resize() {
forceSetExposure(currentExposure);
}
// Set the thumb position between -3 and +3
function setExposure(exposure) {
// Make sure it is not out of bounds
if (exposure < -3) {
exposure = -3;
} else if (exposure > 3) {
exposure = 3;
}
// Round to the closest sixteenth
exposure = Math.round(exposure * 16) / 16;
if (exposure === currentExposure) {
return;
}
forceSetExposure(exposure);
}
function forceSetExposure(exposure) {
// Remove left/right 4 pixels of margin
var barWidth = parseInt(bar.clientWidth, 10) - 4 * 2;
var thumbWidth = parseInt(thumb.clientWidth, 10);
var offset = bar.offsetLeft + 4;
// Convert exposure value to a unit coefficient position of thumb center
var unitCoef = (exposure + 3) / 6;
// Convert unitCoef to pixel position of thumb center
var pixel = offset + barWidth * unitCoef;
// Compute pixel position of left edge of thumb
pixel -= thumbWidth / 2;
// Move the thumb to that position
thumb.style.MozMarginStart = pixel + 'px';
// Display exposure value in thumb
thumb.textContent = exposure;
// Don't need to update the currentExposure and dispatch the event if they
// are the same.
if (currentExposure === exposure) {
return;
}
// Remember the new exposure value
currentExposure = exposure;
// Set value for ARIA widget
slider.setAttribute('aria-valuenow', exposure);
// Dispatch an event to actually change the image
slider.dispatchEvent(new Event('change', {bubbles: true}));
}
return {
resize: resize,
forceSetExposure: forceSetExposure,
setExposure: setExposure,
getExposure: function() { return currentExposure; }
};
})();
function resizeHandler() {
exposureSlider.resize();
imageEditor.resize();
}
function localizeHandler() {
// If the locale changes while user is using exposure edit option,
// then we need to re-position slider thumb for respective locale.
exposureSlider.forceSetExposure(exposureSlider.getExposure());
}
// Handle changes on the slider and turn them into calls to edit the image
(function() {
var requestId = null;
$('exposure-slider').onchange = function() {
if (!imageEditor) { // If it has not been created yet
return;
}
// If there is already a pending request, it will take care of this change
// and we don't have to request a new one.
if (requestId !== null) {
return;
}
requestId = requestAnimationFrame(function() {
requestId = null;
var stops = exposureSlider.getExposure();
// Convert the exposure compensation stops gamma correction value.
var factor = -1; // Adjust this factor to get something reasonable.
var gamma = Math.pow(2, stops * factor);
editSettings.exposure.gamma = gamma;
editSettings.exposure.sliderThumbPos = stops;
enableSaveAndApplyButtons();
imageEditor.edit();
});
};
}());
function editPhoto(n) {
editedPhotoIndex = n;
var metadata = files[n].metadata;
editSettings = {
crop: {
x: 0, y: 0, w: metadata.width, h: metadata.height,
cropModeId: 'edit-crop-aspect-free' // selected crop mode element Id
},
exposure: {
sliderThumbPos: 0, // Slider thumb position
gamma: 1
},
effect: {
effectId: 'edit-effect-none', // selected effect mode element id
matrix: null
},
enhance: {
autoLevels: null
},
isImageEdited: false // flag to store if first edit has occurred
};
// Start looking up the image file
photodb.getFile(files[n].name, gotFile);
function gotFile(file) {
// The image editor does not handle EXIF rotation, so if the image has
// EXIF orientation flags, we alter the image in place before starting
// to edit it. Similarly, if the image is too big for us to decode at
// full size we need to create a downsampled version that is editable.
// For low-memory devices like Tarako, CONFIG_MAX_EDIT_PIXEL_SIZE will
// be set to a non-zero value, and this may cause us to downsample the
// image even further than we would otherwise.
var imagesize = metadata.width * metadata.height;
var maxsize = CONFIG_MAX_EDIT_PIXEL_SIZE || CONFIG_MAX_IMAGE_PIXEL_SIZE;
if (file.type === 'image/jpeg') {
LazyLoader.load(['shared/js/media/jpeg-exif.js'], () => {
JPEGParser.readExifMetaData(file, (error, metaData) => {
editSettings.EXIF = metaData || {};
});
});
}
if (metadata.rotation || metadata.mirrored || imagesize > maxsize) {
Spinner.show();
cropResizeRotate(file, null, maxsize || null,
null, metadata,
function(error, rotatedBlob) {
Spinner.hide();
if (error) {
console.error('Error while rotating image:', error);
rotatedBlob = file;
}
startEdit(rotatedBlob);
});
}
else {
startEdit(file);
}
}
// This will be called with the file the user is editing or with a rotated
// version of that file
function startEdit(blob) {
// Create the image editor object
// This has to come after setView or the canvas size is wrong.
imageEditor = new ImageEditor(blob,
$('edit-preview-area'),
editSettings);
// Show editor screen and hide edit tool screen
hideEditToolView();
currentEditTool = null;
// Set auto enhance icon to default off state
var editEnhanceButton = $('edit-enhance-button');
editEnhanceButton.setAttribute('aria-pressed', false);
editEnhanceButton.classList.remove('on');
// Set edit screen header title
$('edit-title').setAttribute('data-l10n-id', 'edit');
// Disable save and edit tool apply button until an edit is applied
$('edit-save-button').disabled = true;
$('edit-tool-apply-button').disabled = true;
window.addEventListener('resize', resizeHandler);
window.addEventListener('localized', localizeHandler);
// Set the background for all of the image buttons
editedPhotoURL = URL.createObjectURL(blob);
// Use #-moz-samplesize media fragment to downsample images
// so the resulting images are smaller and fits 5 image buttons
// Here we assume image buttons are 50px high
var scale = window.innerWidth / 5 * window.devicePixelRatio *
window.devicePixelRatio * 50 /
(metadata.width * metadata.height);
var sampleSize = Downsample.areaNoMoreThan(scale);
var backgroundImage = 'url(' + editedPhotoURL + sampleSize + ')';
editBgImageButtons.forEach(function(b) {
b.style.backgroundImage = backgroundImage;
});
}
// Display the edit screen
setView(LAYOUT_MODE.edit);
// Set the default option buttons to correspond to those edits
editOptionButtons.forEach(function(b) { selectEditOption(b, false); });
selectEditOption($('edit-crop-aspect-free'), true);
selectEditOption($('edit-effect-none'), true);
}
function selectEditOption(radio, selected) {
radio.classList.toggle('selected', selected);
radio.setAttribute('aria-checked', selected);
}
// Stores last selected aspect ratio in crop options. This variable
// is used to handle cancel in aspect more options view.
var editCropSelectedAspectRatio;
// Crop and Effect buttons call this
function editOptionsHandler() {
/* jshint validthis: true */
// First, unhighlight all buttons in this group and then
// highlight the button that has just been chosen. These
// buttons have radio behavior
var parent = this.parentNode;
var buttons = parent.querySelectorAll('a.radio.button');
Array.forEach(buttons, function(b) { selectEditOption(b, false); });
selectEditOption(this, true);
// Set selected effect or cropMode elementId in editSettings object
// to initialize UI on re-enter of an editMode
if (this.dataset.effect) {
editSettings.effect.effectId =
$('edit-effect-options').getElementsByClassName('selected')[0].id;
} else {
editCropSelectedAspectRatio = editSettings.crop.cropModeId;
editSettings.crop.cropModeId =
$('edit-crop-options').getElementsByClassName('selected')[0].id;
}
if (this === $('edit-crop-aspect-free')) {
imageEditor.setCropAspectRatio();
} else if (this === $('edit-crop-aspect-portrait')) {
imageEditor.setCropAspectRatio(2, 3);
} else if (this === $('edit-crop-aspect-landscape')) {
imageEditor.setCropAspectRatio(3, 2);
} else if (this === $('edit-crop-aspect-square')) {
imageEditor.setCropAspectRatio(1, 1);
} else if (this === $('edit-crop-aspect-more')) {
$('aspect-ratio-options-view').show();
}
else if (this.dataset.effect) {
editSettings.effect.matrix = Effects[this.dataset.effect];
imageEditor.edit();
}
enableSaveAndApplyButtons();
}
function editAspectMoreHandler() {
/* jshint validthis: true */
if (this === $('edit-crop-aspect-3x4')) {
imageEditor.setCropAspectRatio(3, 4);
} else if (this === $('edit-crop-aspect-4x3')) {
imageEditor.setCropAspectRatio(4, 3);
} else if (this === $('edit-crop-aspect-16x9')) {
imageEditor.setCropAspectRatio(16, 9);
} else if (this === $('aspect-ratio-options-cancel')) {
// On cancel in aspect more options view, update selected crop mode id
// in editSettings object and highlight previous selected option node.
selectEditOption($('edit-crop-aspect-more'), false);
selectEditOption($(editCropSelectedAspectRatio), true);
editSettings.crop.cropModeId = editCropSelectedAspectRatio;
}
enableSaveAndApplyButtons();
$('aspect-ratio-options-view').hide();
}
// Set editTool to respective edit modes on click
// of crop, exposure, effect, enhance.
function setEditTool(editTool) {
// Hide all options
var options = $('edit-options').querySelectorAll('div.edit-options-bar');
Array.forEach(options, function(o) { o.classList.add('hidden'); });
// Hide auto enhance message banner before entering
// an edit tool or cancel of edit screens.
$('edit-enhance-banner').hidden = true;
// Make a copy of editSettings when you enter edit tool
savedEditSettings = clone(editSettings);
// Now select and show the correct UI based on editTool
switch (editTool) {
case 'exposure':
// Show exposure EditTool and hide main edit screen
showEditToolView();
$('exposure-slider').classList.remove('hidden');
$('edit-title').setAttribute('data-l10n-id', 'exposure');
// Set exposure slider thumb position
exposureSlider.forceSetExposure(editSettings.exposure.sliderThumbPos);
break;
case 'crop':
showEditToolView();
$('edit-crop-options').classList.remove('hidden');
$('edit-title').setAttribute('data-l10n-id', 'crop');
// Initialize the source rectangle to full size of the original image
imageEditor.resetCropRegion();
imageEditor.resetPreview();
// Show screen with crop overlay on entering crop edit tool
imageEditor.edit(function() {
// Use saved crop region in editSettings to show crop overlay
imageEditor.showCropOverlay(
imageEditor.getCropOverlayRegion(savedEditSettings.crop));
});
break;
case 'effect':
showEditToolView();
$('edit-effect-options').classList.remove('hidden');
$('edit-title').setAttribute('data-l10n-id', 'filters');
break;
case 'enhance':
imageEditor.autoEnhancement(setAutoEnhanceState);
enableSaveAndApplyButtons();
break;
}
// Remember the new edit mode
currentEditTool = editTool;
}
function cancelEditTool() {
// Do any necessary cleanup of the currentEditTool view we're exiting.
switch (currentEditTool) {
case 'exposure':
// On cancel in exposure EditTool restore exposure state
// inside editsettings object.
editSettings.exposure = savedEditSettings.exposure;
break;
case 'crop':
// On cancel in Crop Edit Tool, restore crop state in editSettings object
// and revert selected option node.
selectEditOption($(editSettings.crop.cropModeId), false);
selectEditOption($(savedEditSettings.crop.cropModeId), true);
editSettings.crop = savedEditSettings.crop;
// Set source rectangle size from saved editSettings
// to go back to previous cropped size preview.
imageEditor.forceSetCropRegion();
// Discard changes and revert to previous crop sizes
imageEditor.resetPreview();
break;
case 'effect':
// On cancel in Effect Edit Tool, restore effect state
// in editSettings object and revert selected option node.
selectEditOption($(editSettings.effect.effectId), false);
selectEditOption($(savedEditSettings.effect.effectId), true);
editSettings.effect = savedEditSettings.effect;
break;
}
// Revert to main edit screen on edit tool cancel
// Update preview image with editSettings object
imageEditor.edit(function() {
imageEditor.hideCropOverlay();
});
$('edit-title').setAttribute('data-l10n-id', 'edit');
// Hide Edit Tool and show main edit screen
hideEditToolView();
currentEditTool = null;
}
// Apply changes made in crop edit tool and go back to edit screen
// Other edit tools - exposure, effects, enhance apply their edits
// as the user interacts with the buttons or slider
function applyEditTool() {
switch (currentEditTool) {
case 'crop':
imageEditor.cropImage(function() {
imageEditor.hideCropOverlay();
});
break;
}
$('edit-title').setAttribute('data-l10n-id', 'edit');
// Hide Edit tool screen
hideEditToolView();
// Reset current edit tool and editSettings saved state to null
currentEditTool = null;
savedEditSettings = null;
}
function onCancelButton() {
// Check for currentEditTool to find if user is cancelling out from
// edit tool screen - crop, effect, exposure or main edit screen
if (currentEditTool === null || currentEditTool === 'enhance') {
currentEditTool = null;
// Exit main edit screen withot saving edited image
exitEdit(false);
} else {
// Cancel edit tool and go back to main edit screen
cancelEditTool();
}
}
function enableSaveAndApplyButtons() {
// Enable Apply and Save buttons if it's first edit and isImageEdited
// is false. For subesquent edits apply and save button stays enabled.
if (!editSettings || editSettings.isImageEdited) {
return;
}
if (imageEditor.hasBeenCropped() ||
editSettings.effect.matrix !== null ||
editSettings.enhance.autoLevels !== null ||
editSettings.exposure.gamma !== 1) {
editSettings.isImageEdited = true;
$('edit-tool-apply-button').disabled = false;
$('edit-save-button').disabled = false;
}
}
// Create deep copy of an object
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// Toggle the auto enhancement on/off.
function setAutoEnhanceState(isEnhanced) {
var statusLabel = $('edit-enhance-status');
var enhanceButton = $('edit-enhance-button');
var banner = $('edit-enhance-banner');
enhanceButton.setAttribute('aria-pressed', isEnhanced);
if (isEnhanced) {
showStatus('enhance-on');
enhanceButton.classList.add('on');
} else {
showStatus('enhance-off');
enhanceButton.classList.remove('on');
}
function showStatus(msgId) {
document.l10n.setAttributes(statusLabel, msgId);
banner.hidden = false;
setTimeout(function() {
banner.hidden = true;
}, 3000);
}
}
// Handle show and hide of edit tool screen
function showEditToolView() {
// Hide Editor toolbar and save button
$('edit-toolbar').classList.add('hidden');
$('edit-save-button').hidden = true;
// Show Edit Tool options and apply button
$('edit-options').classList.remove('hidden');
$('edit-tool-apply-button').hidden = false;
}
function hideEditToolView() {
// Show Editor toolbar and save button
$('edit-toolbar').classList.remove('hidden');
$('edit-save-button').hidden = false;
// Hide Edit Tool options and apply button
$('edit-options').classList.add('hidden');
$('edit-tool-apply-button').hidden = true;
}
function exitEdit(saved) {
// Revoke the blob URL we've been using
URL.revokeObjectURL(editedPhotoURL);
editedPhotoURL = null;
// Check for imageEditor and close the editor object
if (imageEditor) {
imageEditor.destroy();
imageEditor = null;
}
savedEditSettings = null;
editSettings = null;
window.removeEventListener('resize', resizeHandler);
window.removeEventListener('localized', localizeHandler);
// we want the slider re-zeroed for the next time we come into the editor
exposureSlider.forceSetExposure(0.0);
// If exitEdit is called without saved argument e.g. when device storage
// is unavailable, exit now and do not check for saved.
if (saved === undefined) {
return;
}
// We came in to edit mode from fullscreenView. If the user cancels the edit
// go back to fullscreenView to original image. Otherwise, if the user saves
// the photo, we should go back to fullscreenView to view the edited photo.
if (saved) {
// After we sucessfully save a picture, we need to make sure that the
// current file will point to it. We need a flag for fileCreated(),
// so that the currentFileIndex will stay at 0 which is the newest one.
justSavedEditedImage = true;
// After insert sucessfully, db will call file created and setFile to
// latest file, then we go to fullscreen mode to see the edited picture.
setView(LAYOUT_MODE.fullscreen);
} else {
setView(LAYOUT_MODE.fullscreen);
showFile(currentFileIndex);
}
}
// When the user clicks the save button, we produce a full-size version
// of the edited image, save it into the media database and return to
// photo view mode.
// XXX: figure out what the image number of the edited photo is or will be
// and return to viewing that one. Ideally, edited photos would be grouped
// with the original, rather than by date, but I'm not sure I can
// do that sort order. Ideally, I'd like the mediadb to not generate a
// change event when we manually add something to it or at least have that
// option
function saveEditedImage() {
// Save button disabled to prevent the user triggering multiple
// save operations
$('edit-save-button').disabled = true;
// Check for imageEditor before continuing with save
if (!imageEditor) {
return;
}
var progressBar = $('save-progress');
// Show progressbar when start to save.
progressBar.classList.remove('hidden');
progressBar.value = 0;
progressBar.max = 100;
imageEditor.getFullSizeBlob('image/jpeg', gotBlob, onProgress);
function onProgress(p, error) {
// If error occurs, reset the save button for user.
if (error) {
$('edit-save-button').disabled = false;
progressBar.value = 0;
return;
}
progressBar.value = Math.floor(p * 100);
}
function gotBlob(blob) {
// Hide progressbar when saved.
progressBar.classList.add('hidden');
var original = files[editedPhotoIndex].name;
var basename, extension, filename;
var version = 1;
var p = original.lastIndexOf('.');
if (p === -1) {
basename = original;
extension = '';
}
else {
basename = original.substring(0, p);
extension = original.substring(p);
}
// Create a filename for the edited image. Loop if necessary and
// increment the version number until we find a version a name that
// is not in use.
// XXX: this loop is O(n^2) and slow if the user saves many edits
// of the same image.
filename = basename + '.edit' + version + extension;
/* jshint loopfunc: true */
while (files.some(function(i) { return i.name === filename; })) {
version++;
filename = basename + '.edit' + version + extension;
}
// Now that we have a filename, save the file This will send a
// change event, which will cause us to rebuild our thumbnails.
// For now, the edited image will become the first thumbnail since
// it si the most recent one. Ideally, I'd like a more
// sophisticated sort order that put edited sets of photos next to
// each other.
photodb.addFile(filename, blob);
// We're done.
exitEdit(true);
progressBar.value = 0;
}
}
/*
* ImageEditor.js: simple image editing and previews in a <canvas> element.
*
* Display an edited version of the specified image in a <canvas> element
* inside the specified container element. The image (or cropped region of
* the image) will be displayed as large as possible within the container's
* area. Edits is an object that specifies the edits to apply to the image.
* The edits object may include these properties:
*
* gamma: a float specifying gamma correction
* matrix: a 3x4 matrix that represents a transformation of each rgba pixel.
* this can be used to convert to bw or sepia, for example.
*
* In addition to previewing the image, this class also defines a
* getFullSizeBlob() function that creates a full-size version of the
* edited image.
*
* This class also handles cropping. See:
*
* showCropOverlay()
* hideCropOverlay()
* cropImage()
* undoCrop()
*
* The stylesheet includes static styles to position those dyanamic elements.
*/
function ImageEditor(imageBlob, container, edits, ready, croponly) {
this.imageBlob = imageBlob;
this.imageURL = URL.createObjectURL(imageBlob);
this.container = container;
this.edits = edits || {};
this.croponly = !!croponly;
// The source rectangle (crop region) of the image. Image pixels.
this.source = {};
// The destination (preview) rectangle of canvas. Device pixels.
this.dest = {};
// Region displayed in crop overlay during drags. CSS pixels.
this.cropOverlayRegion = {};
// The canvas that displays the preview
this.previewCanvas = document.createElement('canvas');
this.previewCanvas.id = 'edit-preview-canvas'; // for stylesheet
this.previewCanvas.setAttribute('data-l10n-id', 'editImagePreview');
this.previewCanvas.setAttribute('role', 'img');
this.container.appendChild(this.previewCanvas);
// Make sure the canvas is size for device pixels, not css pixels
var dpr = window.devicePixelRatio;
this.previewCanvas.width = Math.ceil(this.previewCanvas.clientWidth * dpr);
this.previewCanvas.height = Math.ceil(this.previewCanvas.clientHeight * dpr);
// The raw pixels that we edit and then display in the preview canvas
this.previewImageData = null;
// Auto enhancement parameters generated from the preview image data
this.autoLevels = null;
// prepare gesture detector for ImageEditor
this.gestureDetector = new GestureDetector(container, { panThreshold: 3 });
this.gestureDetector.startDetecting();
// preset the scale to something useful in case resize() gets called
// before generateNewPreview()
this.scale = 1.0;
var self = this;
if (this.croponly) {
this.original = null;
this.preview = new Image();
this.preview.src = this.imageURL;
this.preview.onload = function() {
self.displayCropOnlyPreview();
if (ready) {
ready();
}
};
}
else {
// Start loading the image into a full-size offscreen image
this.original = new Image();
this.original.src = this.imageURL;
// When the image loads display it
this.original.onload = function() {
// Initialize the crop region to the full size of the original image
self.resetCropRegion();
self.resetPreview();
// Display an edited preview of it
self.edit(function() {
// If the constructor had a ready callback argument, call it now
if (ready) {
ready();
}
});
};
}
}
ImageEditor.prototype.displayCropOnlyPreview = function() {
var previewContext = this.previewCanvas.getContext('2d');
var scalex = this.previewCanvas.width / this.preview.width;
var scaley = this.previewCanvas.height / this.preview.height;
var scale = Math.min(Math.min(scalex, scaley), 1);
var previewWidth = Math.floor(this.preview.width * scale);
var previewHeight = Math.floor(this.preview.height * scale);
var previewX = Math.floor((this.previewCanvas.width - previewWidth) / 2);
var previewY =
Math.floor((this.previewCanvas.height - previewHeight) / 2);
previewContext.drawImage(this.preview,
previewX, previewY,
previewWidth, previewHeight);
this.scale = scale;
this.dest.x = previewX;
this.dest.y = previewY;
this.dest.width = previewWidth;
this.dest.height = previewHeight;
};
ImageEditor.prototype.generateNewPreview = function(callback) {
// infer the previewHeight such that the aspect ratio stays the same
var scalex = this.previewCanvas.width / this.source.width;
var scaley = this.previewCanvas.height / this.source.height;
this.scale = Math.min(Math.min(scalex, scaley), 1);
var previewWidth = Math.floor(this.source.width * this.scale);
var previewHeight = Math.floor(this.source.height * this.scale);
// Update edits with new dimensions
this.edits.crop.x = this.source.x;
this.edits.crop.y = this.source.y;
this.edits.crop.w = this.source.width;
this.edits.crop.h = this.source.height;
// Update the destination rectangle with the new dimensions, too
this.dest.x = Math.floor((this.previewCanvas.width - previewWidth) / 2);
this.dest.y = Math.floor((this.previewCanvas.height - previewHeight) / 2);
this.dest.width = previewWidth;
this.dest.height = previewHeight;
// Create a preview image
var canvas = document.createElement('canvas');
canvas.width = previewWidth;
canvas.height = previewHeight;
// In this case, we only need graphic 2d to do the scaling. The
// willReadFrequently option makes canvas use software graphic 2d. This can
// skip a bug of GPU version, bug 960276.
var context = canvas.getContext('2d', { willReadFrequently: true });
// Draw that region of the image into the canvas, scaling it down
context.drawImage(this.original, this.source.x, this.source.y,
this.source.width, this.source.height,
0, 0, previewWidth, previewHeight);
this.previewImageData = context.getImageData(0, 0,
previewWidth, previewHeight);
// If we don't have an image processor yet, or if the processor
// can't handle an image of this size, create one now.
if (!this.processor ||
this.processor.maxPixels < previewWidth * previewHeight)
{
// If we already had a processor, destroy it
if (this.processor) {
this.processor.destroy();
}
this.processor = new ImageProcessorThread(previewWidth * previewHeight);
}
if (callback) {
callback();
}
};
ImageEditor.prototype.resetPreview = function() {
if (this.previewImageData) {
this.previewImageData = null;
this.autoLevels = null;
}
};
ImageEditor.prototype.resize = function() {
var self = this;
var canvas = this.previewCanvas;
var dpr = window.devicePixelRatio;
canvas.width = Math.ceil(canvas.clientWidth * dpr);
canvas.height = Math.ceil(canvas.clientHeight * dpr);
// Save the crop region (scaled up to full image dimensions)
var savedCropRegion = {};
var hadCropOverlay = this.isCropOverlayShown();
if (hadCropOverlay) {
savedCropRegion.left = this.cropOverlayRegion.left / this.scale;
savedCropRegion.top = this.cropOverlayRegion.top / this.scale;
savedCropRegion.right = this.cropOverlayRegion.right / this.scale;
savedCropRegion.bottom = this.cropOverlayRegion.bottom / this.scale;
this.hideCropOverlay();
}
// Now update the preview image. This is done differently in the
// croponly case and in the regular image editing case
if (this.croponly) {
this.displayCropOnlyPreview();
restoreCropRegion();
}
else {
this.resetPreview();
this.edit(restoreCropRegion);
}
// Restore the crop region to what it was before the resize
function restoreCropRegion() {
if (hadCropOverlay) {
// showCropOverlay normally resets cropRegion to the full extent,
// so we need to pass in a new crop region to use
var newRegion = {};
newRegion.left = Math.floor(savedCropRegion.left * self.scale);
newRegion.top = Math.floor(savedCropRegion.top * self.scale);
newRegion.right = Math.floor(savedCropRegion.right * self.scale);
newRegion.bottom = Math.floor(savedCropRegion.bottom * self.scale);
self.showCropOverlay(newRegion);
}
}
};
ImageEditor.prototype.destroy = function() {
this.needsAnotherEdit = false;
if (this.original) {
this.original.src = '';
this.original = null;
}
this.previewImageData = null;
URL.revokeObjectURL(this.imageURL);
if (this.previewCanvas) {
this.container.removeChild(this.previewCanvas);
// Setting the canvas size to 0 causes a WebGL error so we don't do it
// this.previewCanvas.width = 0;
this.previewCanvas = null;
}
this.hideCropOverlay();
this.gestureDetector.stopDetecting();
this.gestureDetector = null;
};
// Preview the image with the specified edits applied. If edit is omitted,
// displays the original image. Clients should call this function when the
// desired edits change or when the size of the container changes (on
// orientation change events, for example)
ImageEditor.prototype.edit = function(callback) {
// A queued preview may finish after the user has left the editor.
if (!this.previewCanvas) {
return;
}
// If we are already editing the image, then we can't edit again now
// so instead we'll just set a flag to indicate that the image needs
// to be editing again. When the current edit ends, we'll check this
// flag and re-edit as needed. Using this flag means that it is okay
// to call edit() more quickly than the ImageProcessor can actually
// process the preview image. If the user drags the exposure slider
// quickly, for example, we will end up skipping some intermediate
// gamma values and will not do more image processing than the device
// can handle.
if (!callback && this.editing) {
this.needsAnotherEdit = true;
return;
}
this.editing = true;
if (!this.previewImageData) {
var self = this;
this.generateNewPreview(function() {
// If we got a new preview image it may be smaller than
// the old one so we have to clear the canvas before drawing it
self.finishEdit(callback, true);
});
} else {
// If we're using the same preview image we don't have to clear
// first because we will set all of the pixels that are already set.
this.finishEdit(callback, false);
}
};
ImageEditor.prototype.finishEdit = function(callback, eraseCanvas) {
var self = this;
var canvas = this.previewCanvas;
var previewContext = canvas.getContext('2d');
var imagedata = this.previewImageData;
var left = Math.floor((canvas.width - imagedata.width) / 2);
var top = Math.floor((canvas.height - imagedata.height) / 2);
this.dest.x = left;
this.dest.y = top;
this.dest.width = imagedata.width;
this.dest.height = imagedata.height;
this.processor.setGamma(this.edits.exposure.gamma);
this.processor.setMatrix(this.edits.effect.matrix);
this.processor.setLevels(this.edits.enhance.autoLevels);
var copiedPixels = new Uint8Array(imagedata.data);
this.processor.processImage(copiedPixels.buffer)
.then(function(processedPixels) {
var processedImageData =
new ImageData(new Uint8ClampedArray(processedPixels),
imagedata.width, imagedata.height);
requestAnimationFrame(function() {
if (!self.previewCanvas) {
return;
}
if (eraseCanvas) {
previewContext.clearRect(0, 0, canvas.width, canvas.height);
}
previewContext.putImageData(processedImageData, left, top);
if (callback) {
callback();
}
self.editing = false;
if (self.needsAnotherEdit) {
self.needsAnotherEdit = false;
self.edit();
}
});
});
};
// Apply the edits offscreen and pass the full-size edited image as a blob
// to the specified callback function. The code here is much like the
// code above in edit().
//
// This function releases the this.original image so you should only call
// it to get the edited image when you are done with the ImageEditor.
//
ImageEditor.prototype.getFullSizeBlob = function(type, done, progress) {
//
// We can tune this algorithm by varying these constants.
//
// In theory, using more threads will allow the save to be faster on
// multi-core CPUs. But testing with 5mp images on Flame and Nexus 5
// I find that using 2 threds is only marginally faster than using 1,
// and using more than 2 slows things down. I suspect that a 5mp image
// is just not enough work for a fast device like nexus 5. Maybe with
// larger images more threads will have more of an impact.
//
// The more tiles, the smoother the progress bar animation will be but
// the longer the overall save will take.
//
const NUM_THREADS = 2;
const NUM_TILES = 24;
if (progress) { // Get the progress bar to appear right away
progress(0.01);
}
// Create an offscreen canvas and copy the image into it
var canvas = document.createElement('canvas');
canvas.width = this.source.width; // "full size" is cropped image size
canvas.height = this.source.height;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(this.original,
this.source.x, this.source.y,
this.source.width, this.source.height,
0, 0, this.source.width, this.source.height);
// As soon as we've copied the original image into the canvas we are
// done with the original and should release it to reduce memory usage.
this.original.src = '';
// How many pixels do we have to process?
var total_pixels = canvas.width * canvas.height;
// How many pixels have we processed so far?
var processed_pixels = 0;
function makeTileList(imageWidth, imageHeight, tileWidth, tileHeight) {
var tiles = [];
var x = 0, y = 0;
while (y < imageHeight) {
x = 0;
while (x < imageWidth) {
var tile = {x: x, y: y, w: tileWidth, h: tileHeight};
if (x + tileWidth > imageWidth) {
tile.w = imageWidth - x;
}
if (y + tileHeight > imageHeight) {
tile.h = imageHeight - y;
}
tiles.push(tile);
x += tileWidth;
}
y += tileHeight;
}
return tiles;
}
// Divide the image into a set of tiled rectangles
var tileHeight = Math.max(Math.round(this.source.height / NUM_TILES), 32);
var rectangles = makeTileList(this.source.width, this.source.height,
this.source.width, tileHeight);
// Create and initialize image processors to do the work.
// Each one has its own worker thread.
var processors = [];
for (var i = 0; i < NUM_THREADS; i++) {
processors[i] = new ImageProcessorThread(this.source.width * tileHeight);
processors[i].setGamma(this.edits.exposure.gamma);
processors[i].setMatrix(this.edits.effect.matrix);
processors[i].setLevels(this.edits.enhance.autoLevels);
}
// After this first synchronous portion, including the big drawImage()
// we'll guess that we're about 5% complete.
if (progress) {
progress(0.05);
}
// Loop through all the rectangles, get the image data for each, send
// it to a processor and collect the promises we get.
//
// XXX: this might be more efficient if we didn't get all the image data
// at once and instead waited for the processors to finish their tiles
// before getting more data for them.
//
var promises = [];
rectangles.forEach(function(rect, index) {
// Get the input pixels for this tile
var tile = context.getImageData(rect.x, rect.y, rect.w, rect.h);
// Choose a processor to handle this tile
var processor = processors[index % processors.length];
var promise = processor.processImage(tile.data.buffer)
.then(function(editedBuffer) {
// Copy the edited pixels from the tile back to the canvas
var editedTile =
new ImageData(new Uint8ClampedArray(editedBuffer), rect.w, rect.h);
context.putImageData(editedTile, rect.x, rect.y);
// Update the progress bar
processed_pixels += rect.w * rect.h;
if (progress) {
// Processing the pixels takes 85% of our time (as a rough guess)
progress(0.05 + 0.85 * processed_pixels / total_pixels);
}
});
promises.push(promise);
});
// Now wait for all the editing promises to be complete and save
// the edited image
Promise.all(promises).then(function() {
// We're done processing the image, so we can free the processors
// This should kill the workers and their memory a little faster
// than they might otherwise be garbage collected.
processors.forEach(function(processor) { processor.destroy(); });
function gotEncodedBlob(blob) {
// Move the progress bar to the end
if (progress) {
progress(1.0);
}
// Pass the blob to the callback.
// setTimeout is just so that the user can see the progress bar change.
setTimeout(function() {
done(blob);
});
}
// we need to save these for Exif rewrite.
var canvasW = canvas.width;
var canvasH = canvas.height;
function updateExif(blob, metaData, callback) {
metaData.Orientation = 1;
if (metaData.PixelXDimension) {
metaData.PixelXDimension = canvasW;
}
if (metaData.PixelYDimension) {
metaData.PixelYDimension = canvasH;
}
JPEGParser.writeExifMetaData(
blob, metaData,
(error, modifiedBlob) => {
if (error) {
console.error('Error' + error);
}
// Process modified file
callback(modifiedBlob);
});
}
var originalBlob = this.imageBlob;
var originalExif = this.edits.EXIF;
// Finally, convert the edited image to a blob and pass to the callback.
canvas.toBlob(function(blob) {
// Now that we've got the blob, we don't need the canvas anymore
context = null;
canvas.width = canvas.height = 0;
canvas = null;
progress(0.95);
if (type == 'image/jpeg') {
// we are most likely to have originalExif here.
if (originalExif) {
updateExif(blob, originalExif, gotEncodedBlob);
} else {
LazyLoader.load(['shared/js/media/jpeg-exif.js'], () => {
JPEGParser.readExifMetaData(originalBlob, (error, metaData) => {
if (error || !metaData) {
gotEncodedBlob(blob);
return;
}
updateExif(blob, metaData, gotEncodedBlob);
});
});
}
} else {
gotEncodedBlob(blob);
}
}, type);
}.bind(this));
};
ImageEditor.prototype.isCropOverlayShown = function() {
return this.cropCanvas;
};
// Returns true if the crop region is anything different than the
// entire dest rectangle. If this method returns false, there is no
// need to call getCroppedRegionBlob().
ImageEditor.prototype.hasBeenCropped = function() {
var dpr = window.devicePixelRatio;
return (this.cropOverlayRegion.left !== 0 ||
this.cropOverlayRegion.top !== 0 ||
this.cropOverlayRegion.right < Math.floor(this.dest.width / dpr) ||
this.cropOverlayRegion.bottom < Math.floor(this.dest.height / dpr));
};
// Display cropping controls
// XXX: have to handle rotate/resize
ImageEditor.prototype.showCropOverlay = function showCropOverlay(newRegion) {
var self = this;
// Create a canvas to display the crop overlay.
// Ignore the device pixel ratio and use CSS pixels for this canvas.
var canvas = this.cropCanvas = document.createElement('canvas');
canvas.id = 'edit-crop-canvas'; // for stylesheet
this.container.appendChild(canvas);
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
var context = this.cropContext = canvas.getContext('2d');
// Crop handle styles
context.translate(25, 15);
context.lineCap = 'round';
// XXX
// Please turn on the followig line when Bug 937529 is fixed. This is an
// workaround to have active handles drawn.
// context.lineJoin = 'round';
context.strokeStyle = 'rgba(255,255,255,1)';
// Start off with a crop region that is the one passed in, if it is not null.
// Otherwise, it should be the entire preview canvas
var region;
if (newRegion) {
region = this.cropOverlayRegion;
region.left = newRegion.left;
region.top = newRegion.top;
region.right = newRegion.right;
region.bottom = newRegion.bottom;
} else {
region = this.cropOverlayRegion;
var dpr = window.devicePixelRatio;
region.left = 0;
region.top = 0;
region.right = Math.floor(this.dest.width / dpr);
region.bottom = Math.floor(this.dest.height / dpr);
}
this.drawCropControls();
var isCropping = false;
this.cropCanvas.addEventListener('pan', function(ev) {
if (!isCropping) {
self.cropStart(ev);
isCropping = true;
}
});
this.cropCanvas.addEventListener('swipe', function() {
isCropping = false;
});
};
ImageEditor.prototype.hideCropOverlay = function hideCropOverlay() {
if (this.isCropOverlayShown()) {
this.container.removeChild(this.cropCanvas);
this.cropCanvas.width = 0;
this.cropCanvas = this.cropContext = null;
}
};
// Force set source size to crop sizes in edit settings
ImageEditor.prototype.forceSetCropRegion = function forceSetCropRegion() {
this.source.x = this.edits.crop.x;
this.source.y = this.edits.crop.y;
this.source.width = this.edits.crop.w;
this.source.height = this.edits.crop.h;
};
// Reset image to full original size
ImageEditor.prototype.resetCropRegion = function resetCropRegion() {
this.source.x = 0;
this.source.y = 0;
this.source.width = this.original.width;
this.source.height = this.original.height;
};
ImageEditor.prototype.drawCropControls = function(handle) {
var canvas = this.cropCanvas;
var context = this.cropContext;
var region = this.cropOverlayRegion;
var dpr = window.devicePixelRatio;
var dest = {
x: Math.floor(this.dest.x / dpr),
y: Math.floor(this.dest.y / dpr),
width: Math.floor(this.dest.width / dpr),
height: Math.floor(this.dest.height / dpr)
};
var left = region.left + dest.x;
var top = region.top + dest.y;
var right = region.right + dest.x;
var bottom = region.bottom + dest.y;
var centerX = (left + right) / 2;
var centerY = (top + bottom) / 2;
var width = right - left;
var height = bottom - top;
// Erase everything
context.clearRect(-25, -15, canvas.width, canvas.height);
// Overlay the preview canvas with translucent gray
context.fillStyle = 'rgba(0, 0, 0, .5)';
context.fillRect(dest.x, dest.y, dest.width, dest.height);
// Clear a rectangle so interior of the crop region shows through
context.clearRect(left, top, width, height);
// Draw a border around the crop region
context.lineWidth = 1;
context.strokeRect(left, top, width, height);
// Draw the drag handles in the corners of the crop region
context.lineWidth = 5;
context.beginPath();
// N
context.moveTo(centerX - 23, top + 1);
context.lineTo(centerX + 23, top + 1);
// E
context.moveTo(right - 1, centerY - 23);
context.lineTo(right - 1, centerY + 23);
// S
context.moveTo(centerX - 23, bottom - 1);
context.lineTo(centerX + 23, bottom - 1);
// W
context.moveTo(left + 1, centerY - 23);
context.lineTo(left + 1, centerY + 23);
// Don't draw the corner handles if there is an aspect ratio we're maintaining
if (!this.cropAspectWidth) {
// NE
context.moveTo(right - 23, top + 1);
context.lineTo(right - 1, top + 1);
context.lineTo(right - 1, top + 23);
// SE
context.moveTo(right - 1, bottom - 23);
context.lineTo(right - 1, bottom - 1);
context.lineTo(right - 23, bottom - 1);
// SW
context.moveTo(left + 23, bottom - 1);
context.lineTo(left + 1, bottom - 1);
context.lineTo(left + 1, bottom - 23);
// NW
context.moveTo(left + 1, top + 23);
context.lineTo(left + 1, top + 1);
context.lineTo(left + 23, top + 1);
}
// Draw all the handles at once
context.stroke();
// If one of the handles is being used, highlight it
if (handle) {
var cx, cy;
switch (handle) {
case 'n':
cx = centerX;
cy = top;
break;
case 'ne':
cx = right;
cy = top;
break;
case 'e':
cx = right;
cy = centerY;
break;
case 'se':
cx = right;
cy = bottom;
break;
case 's':
cx = centerX;
cy = bottom;
break;
case 'sw':
cx = left;
cy = bottom;
break;
case 'w':
cx = left;
cy = centerY;
break;
case 'nw':
cx = left;
cy = top;
break;
}
context.beginPath();
context.arc(cx, cy, 25, 0, 2 * Math.PI);
context.fillStyle = 'rgba(255,255,255,.5)';
context.lineWidth = 1;
context.fill();
}
};
// Called when the first pan event comes in on the crop canvas
ImageEditor.prototype.cropStart = function(ev) {
var self = this;
var region = this.cropOverlayRegion;
var dpr = window.devicePixelRatio;
var dest = {
x: Math.floor(this.dest.x / dpr),
y: Math.floor(this.dest.y / dpr),
width: Math.floor(this.dest.width / dpr),
height: Math.floor(this.dest.height / dpr)
};
var rect = this.previewCanvas.getBoundingClientRect();
var x0 = ev.detail.position.screenX - rect.left - dest.x;
var y0 = ev.detail.position.screenY - rect.top - dest.y;
var left = region.left;
var top = region.top;
var right = region.right;
var bottom = region.bottom;
var aspectRatio = this.cropAspectWidth ?
this.cropAspectWidth / this.cropAspectHeight :
0;
var centerX = (region.left + region.right) / 2;
var centerY = (region.top + region.bottom) / 2;
// Return true if (x0,y0) is within 25 pixels of (x,y)
function hit(x, y) {
return (x0 > x - 25 && x0 < x + 25 &&
y0 > y - 25 && y0 < y + 25);
}
if (hit((left + right) / 2, top)) {
drag('n');
} else if (hit(right, (top + bottom) / 2)) {
drag('e');
} else if (hit((left + right) / 2, bottom)) {
drag('s');
} else if (hit(left, (top + bottom) / 2)) {
drag('w');
} else if (!aspectRatio) {
if (hit(right, top)) {
drag('ne');
} else if (hit(right, bottom)) {
drag('se');
} else if (hit(left, bottom)) {
drag('sw');
} else if (hit(left, top)) {
drag('nw');
} else {
drag(); // with no argument, do a pan instead of a drag
}
} else {
drag(); // pan
}
function drag(handle) {
window.addEventListener('pan', move, true);
window.addEventListener('swipe', up, true);
self.drawCropControls(handle); // highlight drag handle
function move(e) {
var dx = e.detail.absolute.dx;
var dy = e.detail.absolute.dy;
var newleft = region.left;
var newright = region.right;
var newtop = region.top;
var newbottom = region.bottom;
if (!handle) {
pan(dx, dy);
return;
}
switch (handle) {
case 'n':
newtop = top + dy;
break;
case 'ne':
newright = right + dx;
newtop = top + dy;
break;
case 'e':
newright = right + dx;
break;
case 'se':
newright = right + dx;
newbottom = bottom + dy;
break;
case 's':
newbottom = bottom + dy;
break;
case 'sw':
newleft = left + dx;
newbottom = bottom + dy;
break;
case 'w':
newleft = left + dx;
break;
case 'nw':
newleft = left + dx;
newtop = top + dy;
break;
}
// If there is an aspect ratio, make sure we maintain it.
// Note that if there is an aspect ratio we won't display
// the corner drag handles, so we don't have to handle those.
if (aspectRatio) {
var width, height;
switch (handle) {
case 'n':
case 's':
// change width to match the new height, keeping the center still
height = newbottom - newtop;
width = height * aspectRatio;
newleft = Math.floor(centerX - Math.floor(width / 2));
newright = Math.ceil(centerX + Math.ceil(width / 2));
break;
case 'e':
case 'w':
// Change height to match new width, keeping center still
width = newright - newleft;
height = width / aspectRatio;
newtop = Math.floor(centerY - Math.floor(height / 2));
newbottom = Math.ceil(centerY + Math.ceil(height / 2));
break;
}
}
// Now if the new region is out of bounds then bail out without
// changing the region at all and ignore this move event
if (newtop < 0 || newleft < 0 ||
newright > dest.width || newbottom > dest.height) {
return;
}
// Don't let the crop region become smaller than 100x100. If it does
// then the sensitive regions of the crop handles start to intersect.
// If there is a cropping aspect ratio, then the minimum size in
// one dimension will be 100 and will be larger in the other.
var minWidth = 100, minHeight = 100;
if (aspectRatio) {
if (aspectRatio > 1) {
minWidth = Math.round(minWidth * aspectRatio);
} else if (aspectRatio < 1) {
minHeight = Math.round(minHeight / aspectRatio);
}
}
// if the width is less than the minimum allowed (due to orientation
// change), only allow the crop region to get bigger
var newWidth = newright - newleft;
if ((newWidth < (region.right - region.left)) && (newWidth < minWidth)) {
return;
}
var newHeight = newbottom - newtop;
if ((newHeight < (region.bottom - region.top)) &&
(newHeight < minHeight)) {
return;
}
// Otherwise, all is well, so update the crop region and redraw
region.left = newleft;
region.right = newright;
region.top = newtop;
region.bottom = newbottom;
self.drawCropControls(handle);
}
function pan(dx, dy) {
if (dx > 0) {
dx = Math.min(dx, dest.width - right);
}
if (dx < 0) {
dx = Math.max(dx, -left);
}
if (dy > 0) {
dy = Math.min(dy, dest.height - bottom);
}
if (dy < 0) {
dy = Math.max(dy, -top);
}
region.left = left + dx;
region.right = right + dx;
region.top = top + dy;
region.bottom = bottom + dy;
self.drawCropControls();
}
function up(e) {
// Enable Apply and Save button if it's first edit.
if (enableSaveAndApplyButtons) {
enableSaveAndApplyButtons();
}
window.removeEventListener('pan', move, true);
window.removeEventListener('swipe', up, true);
self.drawCropControls(); // erase drag handle highlight
e.preventDefault();
}
}
};
// If the crop overlay is displayed, use the current position of the
// overlaid crop region to actually set the crop region of the original image
ImageEditor.prototype.cropImage = function(callback) {
if (!this.isCropOverlayShown()) {
if (callback) {
callback();
}
return;
}
var region = this.cropOverlayRegion;
var dpr = window.devicePixelRatio;
var dest = {
x: Math.floor(this.dest.x / dpr),
y: Math.floor(this.dest.y / dpr),
width: Math.floor(this.dest.width / dpr),
height: Math.floor(this.dest.height / dpr)
};
// Convert the preview crop region to fractions
var left = Math.min(region.left / dest.width, 1.0);
var right = Math.min(region.right / dest.width, 1.0);
var top = Math.min(region.top / dest.height, 1.0);
var bottom = Math.min(region.bottom / dest.height, 1.0);
// Now convert those fractions to pixels in the original image
left = Math.floor(left * this.source.width);
right = Math.ceil(right * this.source.width);
top = Math.floor(top * this.source.height);
bottom = Math.ceil(bottom * this.source.height);
// XXX: tweak these to make sure we still have the right aspect ratio
// after rounding to pixels
console.error('Maintain aspect ratio precisely!!!');
// And update the real crop region
this.source.x += left;
this.source.y += top;
this.source.width = right - left;
this.source.height = bottom - top;
this.resetPreview();
this.edit(callback);
};
// Pass no arguments for freeform 1,1 for square,
// 2,3 for portrait, 3,2 for landscape.
ImageEditor.prototype.setCropAspectRatio = function(ratioWidth, ratioHeight) {
var region = this.cropOverlayRegion;
var dpr = window.devicePixelRatio;
var dest = {
x: Math.floor(this.dest.x / dpr),
y: Math.floor(this.dest.y / dpr),
width: Math.floor(this.dest.width / dpr),
height: Math.floor(this.dest.height / dpr)
};
this.cropAspectWidth = ratioWidth || 0;
this.cropAspectHeight = ratioHeight || 0;
if (ratioWidth && ratioHeight) {
// Constrained cropping, centered on image
var centerX = dest.width / 2;
var centerY = dest.height / 2;
var scaleX = dest.width / ratioWidth;
var scaleY = dest.height / ratioHeight;
var scale = Math.min(scaleX, scaleY);
var width = Math.floor(scale * ratioWidth);
var height = Math.floor(scale * ratioHeight);
region.left = centerX - width / 2;
region.right = centerX + width / 2;
region.top = centerY - height / 2;
region.bottom = centerY + height / 2;
}
else {
// Freeform cropping
region.left = 0;
region.top = 0;
region.right = dest.width;
region.bottom = dest.height;
}
this.drawCropControls();
};
// Return the crop region as an object with left, top, width and height
// properties. The values of these properties are numbers between 0 and 1
// representing fractions of the full image width and height.
ImageEditor.prototype.getCropRegion = function() {
var region = this.cropOverlayRegion;
var previewRect = this.dest;
var dpr = window.devicePixelRatio;
// Convert the preview crop region to fractions
var left = dpr * region.left / previewRect.width;
var right = dpr * region.right / previewRect.width;
var top = dpr * region.top / previewRect.height;
var bottom = dpr * region.bottom / previewRect.height;
return {
left: left,
top: top,
width: Math.min(right - left, 1.0),
height: Math.min(bottom - top, 1.0)
};
};
// Covert the cropped region in image pixels to object with left, right, top,
// bottom properties in CSS Pixel to be used to show crop overlay
ImageEditor.prototype.getCropOverlayRegion = function(cropSettings) {
var dpr = window.devicePixelRatio;
var cropOverlay = {};
cropOverlay.left = Math.floor((cropSettings.x * this.scale) / dpr);
cropOverlay.top = Math.floor((cropSettings.y * this.scale) / dpr);
cropOverlay.right = Math.floor(((cropSettings.x + cropSettings.w) *
this.scale) / dpr);
cropOverlay.bottom = Math.floor(((cropSettings.y + cropSettings.h) *
this.scale) / dpr);
return cropOverlay;
};
ImageEditor.prototype.autoEnhancement = function(callback) {
var self = this;
if (!this.edits.enhance.autoLevels) { // if not already enhanced
this.getAutoLevels().then(setEnhancement);
} else {
setEnhancement(null);
}
function setEnhancement(levels) {
self.edits.enhance.autoLevels = levels;
self.edit();
if (callback) {
callback(levels !== null);
}
}
};
/*
* Compute red, green, and blue min and max levels for auto enhancement
* based on the image histogram returned by computeHistogram().
*
* The levels computed here are used by ImageProcessor to do simple
* linear equalization. For each channel, we find the lowest pixel and
* the highest, and then do a linear transformation so that the lowest
* go to 0 and the highest go to 255. A couple of refinements: instead
* of finding the lowest and highest pixels, we find the level of the
* bottom 1% and top 1% of pixels and use those. This allows us to
* deal with small amounts of noise in the image. Also, if the min-max
* range isn't very wide, ImageProcessor doesn't modify that channel
* at all. This is for non-photographic images that have solid colors
* in them: we don't want to wash those out.
*
* I also tried implementing "histogram equalization" based on the post at
* http://www.generation5.org/content/2004/histogramEqualization.asp
* This gave really vivid color enhancement, probably too vivid.
* Also, it enhanced noise and overexposed regions in a bad way, and it
* didn't deal with non-photo images that were mostly the same color.
* Black text on light blue turned into black on white. Wikipedia lists
* some of the shortcomings of histogram equalization here:
* http://en.wikipedia.org/wiki/Histogram_equalization#Overview
*
* In the future, we could try histogram equalization with the
* clipping technique described here:
* http://en.wikipedia.org/wiki/Adaptive_histogram_equalization#Contrast_Limited_AHE
* By aggressively clipping the peaks of the histogram before
* computing the cumulative histogram maybe we can get the best of the
* the linear and histogram techniques.
*
* Also, maybe we'd get better results if we only computed a histogram
* for the center of the image and then enhanced the entire image
* based on the middle. Or, more generally, we could weight the
* histogram so that pixels near the middle count for more than pixels
* near the edge. All pixels get 1 count. Then those more than 1/8th
* from any edge get another count (that's the center 9/16ths of the
* pixels). Then all the pixels that are more than 1/4 from the edge
* (the center 1/4 of the pixels) get a third count.
*/
// Return a Promise for auto enhancment parameters for the current preview.
// If they have already been computed, then the promise will resolve
// immediately. Otherwise, the promise will resolve when they are computed.
// This calls the computeHistogram() method of ImageProcessor to compute
// a histogram of the current preview, if that has not already been computed.
ImageEditor.prototype.getAutoLevels = function() {
var self = this;
if (this.autoLevels) {
return Promise.resolve(this.autoLevels);
}
if (!this.previewImageData) {
// this should not happen
return Promise.reject(new Error('called getAutoLevels when no preview' +
'image was defined'));
}
var copiedPixels = new Uint8Array(this.previewImageData.data);
return this.processor.computeHistogram(copiedPixels.buffer)
.then(function(histogram) {
var redHistogram = new Uint32Array(histogram, 0, 256);
var greenHistogram = new Uint32Array(histogram, 256 * 4, 256);
var blueHistogram = new Uint32Array(histogram, 256 * 8, 256);
self.autoLevels = {
red: getLevels(redHistogram, 1),
green: getLevels(greenHistogram, 1),
blue: getLevels(blueHistogram, 1)
};
return self.autoLevels;
});
function getLevels(histogram, thresholdPercent) {
var total, threshold, min = 255, max = 0, i;
// How many total pixels?
// If we do weighted histograms, this may be different than
// the size of the image.
total = 0;
for (i = 0; i < 256; i++) {
total += histogram[i];
}
// How many pixels do we need to see to count as the minimum or maximum
threshold = thresholdPercent / 100 * total;
// Find the minimum level
total = 0;
for (i = 0; i < 256; i++) {
total += histogram[i];
if (total > threshold) {
min = i;
break;
}
}
// Find the maximum level
total = 0;
for (i = 255; i >= 0; i--) {
total += histogram[i];
if (total > threshold) {
max = i;
break;
}
}
return {
min: min,
max: max
};
}
};