Adding simple filters to ChromeOS Image Editor.

Added Autofix, Exposure, Blur, Sharpen.
Also improved appearance a bit by avoiding non-integer coordinates
and fixed W3C compatibility (a standalone demo now works on Opera
and Firefox allowing for performance comparisons).

BUG=
TEST=

Review URL: http://codereview.chromium.org/7552035

git-svn-id: svn://svn.chromium.org/chrome/trunk/src@96384 0039d316-1c4b-4281-b951-d872f2087c98
parent d30db2de
// Copyright (c) 2011 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.
/**
* A namespace for image filter utilities.
*/
var filter = {};
/**
* Create a filter from name and options.
*
* @param {string} name Maps to a filter method name.
* @param {Object} options A map of filter-specific options.
* @return {function(ImageData,ImageData,number,number)}
*/
filter.create = function(name, options) {
var filterFunc = filter[name](options);
return function() {
var time = Date.now();
filterFunc.apply(null, arguments);
var dst = arguments[0];
var mPixPerSec = dst.width * dst.height / 1000 / (Date.now() - time);
ImageUtil.trace.report(name, Math.round(mPixPerSec * 10) / 10 + 'Mps');
}
};
/**
* Apply a filter to a image by splitting it into strips.
*
* To be used with large images to avoid freezing up the UI.
*
* @param {CanvasRenderingContext2D} context
* @param {function(ImageData,ImageData,number,number)} filterFunc
* @param {function(number,number} progressCallback
* @param {number} maxPixelsPerStrip
*/
filter.applyByStrips = function(
context, filterFunc, progressCallback, maxPixelsPerStrip) {
var source = context.getImageData(
0, 0, context.canvas.width, context.canvas.height);
var stripCount = Math.ceil (source.width * source.height /
(maxPixelsPerStrip || 1000000)); // 1 Mpix is a reasonable default.
var strip = context.getImageData(0, 0,
source.width, Math.ceil (source.height / stripCount));
var offset = 0;
function filterStrip() {
// If the strip overlaps the bottom of the source image we cannot shrink it
// and we cannot fill it partially (since canvas.putImageData always draws
// the entire buffer).
// Instead we move the strip up several lines (converting those lines
// twice is a small price to pay).
if (offset > source.height - strip.height) {
offset = source.height - strip.height;
}
filterFunc(strip, source, 0, offset);
context.putImageData(strip, 0, offset);
offset += strip.height;
progressCallback(offset, source.height);
if (offset < source.height) {
setTimeout(filterStrip, 0);
}
}
filterStrip();
};
/**
* Return a color histogram for an image.
*
* @param {ImageData} imageData
* @return {{r: Array.<number>, g: Array.<number>, b: Array.<number>}}
*/
filter.getHistogram = function(imageData) {
var r = [];
var g = [];
var b = [];
for (var i = 0; i != 256; i++) {
r.push(0);
g.push(0);
b.push(0);
}
var data = imageData.data;
var maxIndex = 4 * imageData.width * imageData.height;
for (var index = 0; index != maxIndex;) {
r[data[index++]]++;
g[data[index++]]++;
b[data[index++]]++;
index++;
}
return { r: r, g: g, b: b };
};
/**
* Compute the function for every integer value from 0 up to maxArg.
*
* Rounds and clips the results to fit the [0..255] range.
* Useful to speed up pixel manipulations.
*
* @param {number} maxArg Maximum argument value (inclusive).
* @param {function(number): number} func
* @return {Array.<number>} Computed results
*/
filter.precompute = function(maxArg, func) {
var results = [];
for (var arg = 0; arg <= maxArg; arg ++) {
results.push(Math.max(0, Math.min(0xFF, Math.round(func(arg)))));
}
return results;
};
/**
* Convert pixels by applying conversion tables to each channel individually.
*
* @param {Array.<number>} rMap Red channel conversion table.
* @param {Array.<number>} gMap Green channel conversion table.
* @param {Array.<number>} bMap Blue channel conversion table.
* @param {ImageData} dst Destination image data. Can be smaller than the
* source, must completely fit inside the source.
* @param {ImageData} src Source image data.
* @param {number} offsetX Horizontal offset of dst relative to src.
* @param {number} offsetY Vertical offset of dst relative to src.
*/
filter.mapPixels = function(rMap, gMap, bMap, dst, src, offsetX, offsetY) {
var dstData = dst.data;
var dstWidth = dst.width;
var dstHeight = dst.height;
var srcData = src.data;
var srcWidth = src.width;
var srcHeight = src.height;
if (offsetX < 0 || offsetX + dstWidth > srcWidth ||
offsetY < 0 || offsetY + dstHeight > srcHeight)
throw new Error('Invalid offset');
var dstIndex = 0;
for (var y = 0; y != dstHeight; y++) {
var srcIndex = (offsetX + (offsetY + y)* srcWidth)* 4;
for (var x = 0; x != dstWidth; x++ ) {
dstData[dstIndex++] = rMap[srcData[srcIndex++]];
dstData[dstIndex++] = gMap[srcData[srcIndex++]];
dstData[dstIndex++] = bMap[srcData[srcIndex++]];
dstIndex++;
srcIndex++;
}
}
};
filter.FIXED_POINT_SHIFT = 16;
filter.MAX_SHIFTED_VALUE = 0xFF << filter.FIXED_POINT_SHIFT;
filter.floatToFixedPoint = function(x) {
return Math.round((x || 0) * (1 << filter.FIXED_POINT_SHIFT));
};
/**
* Perform an image convolution with a symmetrical 5x5 matrix:
*
* 0 0 w3 0 0
* 0 w2 w1 w2 0
* w3 w1 w0 w1 w3
* 0 w2 w1 w2 0
* 0 0 w3 0 0
*
* For performance reasons the weights are in fixed point format
* (left shifted by filter.FIXED_POINT_SHIFT).
*
* @param {number} w0 See the picture above
* @param {number} w1 See the picture above
* @param {number} w2 See the picture above
* @param {number} w3 See the picture above
* @param {ImageData} dst Destination image data. Can be smaller than the
* source, must completely fit inside the source.
* @param {ImageData} src Source image data.
* @param {number} offsetX Horizontal offset of dst relative to src.
* @param {number} offsetY Vertical offset of dst relative to src.
*/
filter.convolve5x5 = function(w0, w1, w2, w3, dst, src, offsetX, offsetY) {
var dstData = dst.data;
var dstWidth = dst.width;
var dstHeight = dst.height;
var dstStride = dstWidth * 4;
var srcData = src.data;
var srcWidth = src.width;
var srcHeight = src.height;
var srcStride = srcWidth * 4;
var srcStride2 = srcStride * 2;
if (offsetX < 0 || offsetX + dstWidth > srcWidth ||
offsetY < 0 || offsetY + dstHeight > srcHeight)
throw new Error('Invalid offset');
w0 = filter.floatToFixedPoint(w0);
w1 = filter.floatToFixedPoint(w1);
w2 = filter.floatToFixedPoint(w2);
w3 = filter.floatToFixedPoint(w3);
var margin = 2;
var startX = Math.max(0, margin - offsetX);
var endX = Math.min(dstWidth, srcWidth - margin - offsetX);
var startY = Math.max(0, margin - offsetY);
var endY = Math.min(dstHeight, srcHeight - margin - offsetY);
for (var y = startY; y != endY; y++) {
var dstIndex = y * dstStride + startX * 4;
var srcIndex = (y + offsetY) * srcStride + (startX + offsetX) * 4;
for (var x = startX; x != endX; x++ ) {
for (var c = 0; c != 3; c++) {
var sum = w0 * srcData[srcIndex] +
w1 * (srcData[srcIndex - 4] +
srcData[srcIndex + 4] +
srcData[srcIndex - srcStride] +
srcData[srcIndex + srcStride]) +
w2 * (srcData[srcIndex - srcStride - 4] +
srcData[srcIndex + srcStride - 4] +
srcData[srcIndex - srcStride + 4] +
srcData[srcIndex + srcStride + 4]) +
w3 * (srcData[srcIndex - 8] +
srcData[srcIndex + 8] +
srcData[srcIndex - srcStride2] +
srcData[srcIndex + srcStride2]);
if (sum < 0)
dstData[dstIndex++] = 0;
else if (sum > Filter.MAX_SHIFTED_VALUE)
dstData[dstIndex++] = 0xFF;
else
dstData[dstIndex++] = sum >> Filter.FIXED_POINT_SHIFT;
srcIndex++;
}
srcIndex++;
dstIndex++;
}
}
};
/**
* Return a convolution filter function bound to specific weights.
*
* @param {Array.<number>} weights Weights for the convolution matrix.
* @return {function(ImageData,ImageData,number,number)}
*/
filter.convolution5x5 = function(weights) {
var total = 0;
for (var i = 0; i != weights.length; i++) {
total += weights[i] * (i ? 4 : 1);
}
for (i = 0; i != weights.length; i++) {
weights[i] /= total;
}
return filter.convolve5x5.bind(
null, weights[0], weights[1], weights[2], weights[3]);
};
/**
* Return a blur filter.
* @param {Object} options
* @return {function(ImageData,ImageData,number,number)}
*/
filter.blur = function(options) {
if (options.radius == 1)
return filter.convolution5x5(
[1, options.strength]);
else if (options.radius == 2)
return filter.convolution5x5(
[1, options.strength, options.strength]);
else
return filter.convolution5x5(
[1, options.strength, options.strength, options.strength]);
};
/**
* Return a sharpen filter.
* @param {Object} options
* @return {function(ImageData,ImageData,number,number)}
*/
filter.sharpen = function(options) {
if (options.radius == 1)
return filter.convolution5x5(
[5, -options.strength]);
else if (options.radius == 2)
return filter.convolution5x5(
[10, -options.strength, -options.strength]);
else
return filter.convolution5x5(
[15, -options.strength, -options.strength, -options.strength]);
};
/**
* Return an exposure filter.
* @param {Object} options
* @return {function(ImageData,ImageData,number,number)}
*/
filter.exposure = function(options) {
var pixelMap = filter.precompute(
255,
function(value) {
if (options.brightness > 0) {
value *= (1 + options.brightness);
} else {
value += (0xFF - value) * options.brightness;
}
return 0x80 +
(value - 0x80) * Math.tan((options.contrast + 1) * Math.PI / 4);
});
return filter.mapPixels.bind(null, pixelMap, pixelMap, pixelMap);
};
/**
* Return a color autofix filter.
* @param {Object} options
* @return {function(ImageData,ImageData,number,number)}
*/
filter.autofix = function(options) {
return filter.mapPixels.bind(null,
filter.autofix.stretchColors(options.histogram.r),
filter.autofix.stretchColors(options.histogram.g),
filter.autofix.stretchColors(options.histogram.b));
};
/**
* Return a conversion table that stretches the range of colors used
* in the image to 0..255.
* @param {Array.<number>} channelHistogram
* @return {Array.<number>}
*/
filter.autofix.stretchColors = function(channelHistogram) {
var first = 0;
while (first <= 255 && channelHistogram[first] == 0)
first++;
var last = 255;
while (last > first && channelHistogram[last] == 0)
last--;
return filter.precompute(
255,
last == first ?
function(x) { return x } :
function(x) { return (x - first) / (last - first) * 255 }
);
};
......@@ -5,81 +5,347 @@
/**
* The base class for simple filters that only modify the image content
* but do not modify the image dimensions.
* @constructor
*/
ImageEditor.Mode.Adjust = function(displayName, filterFunc) {
ImageEditor.Mode.Adjust = function(displayName) {
ImageEditor.Mode.call(this, displayName);
this.filterFunc_ = filterFunc;
}
this.viewportGeneration_ = 0;
};
ImageEditor.Mode.Adjust.prototype = {__proto__: ImageEditor.Mode.prototype};
ImageEditor.Mode.Adjust.prototype.rollback = function() {
if (!this.backup_) return; // Did not do anything yet.
this.getContent().drawImageData(this.backup_, 0, 0);
this.backup_ = null;
/*
* ImageEditor.Mode methods overridden.
*/
ImageEditor.Mode.Adjust.prototype.commit = function() {
if (!this.filter_) return; // Did not do anything yet.
// Applying the filter to the entire image takes some time, so we do
// it in small increments, providing visual feedback.
// TODO: provide modal progress indicator.
// First hide the preview and show the original image.
this.repaint();
};
ImageEditor.Mode.Adjust.prototype.update = function(options) {
if (!this.backup_) {
this.backup_ = this.getContent().copyImageData();
this.scratch_ = this.getContent().copyImageData();
var self = this;
function repaintStrip(fromRow, toRow) {
var imageStrip = new Rect(self.getViewport().getImageBounds());
imageStrip.top = fromRow;
imageStrip.height = toRow - fromRow;
var screenStrip = new Rect(self.getViewport().getImageBoundsOnScreen());
screenStrip.top = Math.round(self.getViewport().imageToScreenY(fromRow));
screenStrip.height = Math.round(self.getViewport().imageToScreenY(toRow)) -
screenStrip.top;
self.getBuffer().repaintScreenRect(screenStrip, imageStrip);
}
ImageUtil.trace.resetTimer('filter');
this.filterFunc_(this.scratch_, this.backup_, options);
var lastUpdatedRow = 0;
filter.applyByStrips(
this.getContent().getCanvas().getContext('2d'),
this.filter_,
function (updatedRow, rowCount) {
repaintStrip(lastUpdatedRow, updatedRow);
lastUpdatedRow = updatedRow;
if (updatedRow == rowCount) {
ImageUtil.trace.reportTimer('filter');
this.getContent().drawImageData(this.scratch_, 0, 0);
self.getContent().invalidateCaches();
self.repaint();
}
});
};
ImageEditor.Mode.Adjust.prototype.rollback = function() {
this.filter_ = null;
this.previewImageData_ = null;
};
ImageEditor.Mode.Adjust.prototype.update = function(options) {
// We assume filter names are used in the UI directly.
// This will have to change with i18n.
this.filter_ = this.createFilter(options);
this.previewValid_ = false;
this.repaint();
};
/**
* A simple filter that multiplies every component of a pixel by some number.
* Clip and scale the source image data for the preview.
* Use the cached copy if the viewport has not changed.
*/
ImageEditor.Mode.Brightness = function() {
ImageEditor.Mode.Adjust.call(
this, 'Brightness', ImageEditor.Mode.Brightness.filter);
}
ImageEditor.Mode.Adjust.prototype.updatePreviewImage = function() {
if (!this.previewImageData_ ||
this.viewportGeneration_ != this.getViewport().getCacheGeneration()) {
this.viewportGeneration_ = this.getViewport().getCacheGeneration();
var imageRect = this.getPreviewRect(this.getViewport().getImageClipped());
var screenRect = this.getPreviewRect(this.getViewport().getScreenClipped());
// Copy the visible part of the image at the current screen scale.
var canvas = this.getContent().createBlankCanvas(
screenRect.width, screenRect.height);
var context = canvas.getContext('2d');
Rect.drawImage(context, this.getContent().getCanvas(), null, imageRect);
this.originalImageData =
context.getImageData(0, 0, screenRect.width, screenRect.height);
this.previewImageData_ =
context.getImageData(0, 0, screenRect.width, screenRect.height);
this.previewValid_ = false;
}
ImageEditor.Mode.Brightness.prototype =
if (this.filter_ && !this.previewValid_) {
ImageUtil.trace.resetTimer('preview');
this.filter_(this.previewImageData_, this.originalImageData, 0, 0);
ImageUtil.trace.reportTimer('preview');
this.previewValid_ = true;
}
};
ImageEditor.Mode.Adjust.prototype.draw = function(context) {
this.updatePreviewImage();
var screenClipped = this.getViewport().getScreenClipped();
var previewRect = this.getPreviewRect(screenClipped);
context.putImageData(
this.previewImageData_, previewRect.left, previewRect.top);
if (previewRect.width < screenClipped.width &&
previewRect.height < screenClipped.height) {
// Some part of the original image is not covered by the preview,
// shade it out.
context.globalAlpha = 0.75;
context.fillStyle = '#000000';
context.strokeStyle = '#000000';
Rect.fillBetween(
context, previewRect, this.getViewport().getScreenBounds());
Rect.outline(context, previewRect);
}
};
/*
* Own methods
*/
ImageEditor.Mode.Adjust.prototype.createFilter = function(options) {
return filter.create(this.displayName.toLowerCase(), options);
};
ImageEditor.Mode.Adjust.prototype.getPreviewRect = function(rect) {
if (this.getViewport().getScale() >= 1) {
return rect;
} else {
var bounds = this.getViewport().getImageBounds();
var screen = this.getViewport().getScreenClipped();
screen = screen.inflate(-screen.width / 8, -screen.height / 8);
return rect.inflate(-rect.width / 2, -rect.height / 2).
inflate(Math.min(screen.width, bounds.width) / 2,
Math.min(screen.height, bounds.height) / 2);
}
};
/**
* A base class for color filters that are scale independent (i.e. can
* be applied to a scaled image with basicaly the same effect).
* Displays a histogram.
* @constructor
*/
ImageEditor.Mode.ColorFilter = function() {
ImageEditor.Mode.Adjust.apply(this, arguments);
};
ImageEditor.Mode.ColorFilter.prototype =
{__proto__: ImageEditor.Mode.Adjust.prototype};
ImageEditor.Mode.register(ImageEditor.Mode.Brightness);
ImageEditor.Mode.ColorFilter.prototype.setUp = function() {
ImageEditor.Mode.Adjust.prototype.setUp.apply(this, arguments);
this.histogram_ =
new ImageEditor.Mode.Histogram(this.getViewport(), this.getContent());
};
ImageEditor.Mode.Brightness.UI_RANGE = 100;
ImageEditor.Mode.ColorFilter.prototype.draw = function(context) {
ImageEditor.Mode.Adjust.prototype.draw.apply(this, arguments);
this.histogram_.draw(context);
};
ImageEditor.Mode.Brightness.prototype.createTools = function(toolbar) {
toolbar.addRange(
'brightness',
-ImageEditor.Mode.Brightness.UI_RANGE,
0,
ImageEditor.Mode.Brightness.UI_RANGE);
ImageEditor.Mode.ColorFilter.prototype.getPreviewRect = function(rect) {
return rect;
};
ImageEditor.Mode.Brightness.filter = function(dst, src, options) {
// Translate from -100..100 range to 1/5..5
var factor =
Math.pow(5, options.brightness / ImageEditor.Mode.Brightness.UI_RANGE);
ImageEditor.Mode.ColorFilter.prototype.createFilter = function(options) {
var filterFunc =
ImageEditor.Mode.Adjust.prototype.createFilter.apply(this, arguments);
this.histogram_.update(filterFunc);
return filterFunc;
};
var dstData = dst.data;
var srcData = src.data;
var width = src.width;
var height = src.height;
ImageEditor.Mode.ColorFilter.prototype.rollback = function() {
ImageEditor.Mode.Adjust.prototype.rollback.apply(this, arguments);
this.histogram_.update(null);
};
function scale(value) {
return value * factor;
/**
* A histogram container.
* @constructor
*/
ImageEditor.Mode.Histogram = function(viewport, content) {
this.viewport_ = viewport;
var canvas = content.getCanvas();
var downScale = Math.max(1, Math.sqrt(canvas.width * canvas.height / 10000));
var thumbnail = content.copyCanvas(canvas.width / downScale,
canvas.height / downScale);
var context = thumbnail.getContext('2d');
this.originalImageData_ =
context.getImageData(0, 0, thumbnail.width, thumbnail.height);
this.filteredImageData_ =
context.getImageData(0, 0, thumbnail.width, thumbnail.height);
this.update();
};
ImageEditor.Mode.Histogram.prototype.getData = function() { return this.data_ };
ImageEditor.Mode.Histogram.BUCKET_WIDTH = 8;
ImageEditor.Mode.Histogram.BAR_WIDTH = 2;
ImageEditor.Mode.Histogram.RIGHT = 5;
ImageEditor.Mode.Histogram.TOP = 5;
ImageEditor.Mode.Histogram.prototype.update = function(filterFunc) {
if (filterFunc) {
filterFunc(this.filteredImageData_, this.originalImageData_, 0, 0);
this.data_ = filter.getHistogram(this.filteredImageData_);
} else {
this.data_ = filter.getHistogram(this.originalImageData_);
}
};
ImageEditor.Mode.Histogram.prototype.draw = function(context) {
var screen = this.viewport_.getScreenBounds();
var barCount = 2 + 3 * (256 / ImageEditor.Mode.Histogram.BUCKET_WIDTH);
var width = ImageEditor.Mode.Histogram.BAR_WIDTH * barCount;
var height = Math.round(width / 2);
var rect = new Rect(
screen.left + screen.width - ImageEditor.Mode.Histogram.RIGHT - width,
ImageEditor.Mode.Histogram.TOP,
width,
height);
var values = ImageUtil.precomputeByteFunction(scale, 255);
context.globalAlpha = 1;
context.fillStyle = '#E0E0E0';
context.strokeStyle = '#000000';
context.lineCap = 'square';
Rect.fill(context, rect);
Rect.outline(context, rect);
var index = 0;
for (var y = 0; y != height; y++) {
for (var x = 0; x != width; x++ ) {
dstData[index] = values[srcData[index]]; index++;
dstData[index] = values[srcData[index]]; index++;
dstData[index] = values[srcData[index]]; index++;
dstData[index] = 0xFF; index++;
function drawChannel(channel, style, offsetX, offsetY) {
context.strokeStyle = style;
context.beginPath();
for (var i = 0; i != 256; i += ImageEditor.Mode.Histogram.BUCKET_WIDTH) {
var barHeight = channel[i];
for (var b = 1; b < ImageEditor.Mode.Histogram.BUCKET_WIDTH; b++)
barHeight = Math.max(barHeight, channel[i + b]);
barHeight = Math.min(barHeight, height);
for (var j = 0; j != ImageEditor.Mode.Histogram.BAR_WIDTH; j++) {
context.moveTo(offsetX, offsetY);
context.lineTo(offsetX, offsetY - barHeight);
offsetX++;
}
offsetX += 2 * ImageEditor.Mode.Histogram.BAR_WIDTH;
}
context.closePath();
context.stroke();
}
var offsetX = rect.left + 0.5 + ImageEditor.Mode.Histogram.BAR_WIDTH;
var offsetY = rect.top + rect.height;
drawChannel(this.data_.r, '#F00000', offsetX, offsetY);
offsetX += ImageEditor.Mode.Histogram.BAR_WIDTH;
drawChannel(this.data_.g, '#00F000', offsetX, offsetY);
offsetX += ImageEditor.Mode.Histogram.BAR_WIDTH;
drawChannel(this.data_.b, '#0000F0', offsetX, offsetY);
};
/**
* Exposure/contrast filter.
* @constructor
*/
ImageEditor.Mode.Exposure = function() {
ImageEditor.Mode.ColorFilter.call(this, 'Exposure');
};
ImageEditor.Mode.Exposure.prototype =
{__proto__: ImageEditor.Mode.ColorFilter.prototype};
ImageEditor.Mode.register(ImageEditor.Mode.Exposure);
ImageEditor.Mode.Exposure.prototype.createTools = function(toolbar) {
toolbar.addRange('brightness', -1, 0, 1, 100);
toolbar.addRange('contrast', -1, 0, 1, 100);
};
/**
* Autofix.
* @constructor
*/
ImageEditor.Mode.Autofix = function() {
ImageEditor.Mode.ColorFilter.call(this, 'Autofix');
};
ImageEditor.Mode.Autofix.prototype =
{__proto__: ImageEditor.Mode.ColorFilter.prototype};
ImageEditor.Mode.register(ImageEditor.Mode.Autofix);
ImageEditor.Mode.Autofix.prototype.createTools = function(toolbar) {
var self = this;
toolbar.addButton('Apply', function() {
self.update({histogram: self.histogram_.getData()});
});
};
/**
* Blur filter.
* @constructor
*/
ImageEditor.Mode.Blur = function() {
ImageEditor.Mode.Adjust.call(this, 'Blur');
};
ImageEditor.Mode.Blur.prototype =
{__proto__: ImageEditor.Mode.Adjust.prototype};
ImageEditor.Mode.register(ImageEditor.Mode.Blur);
ImageEditor.Mode.Blur.prototype.createTools = function(toolbar) {
toolbar.addRange('strength', 0, 0, 1, 100);
toolbar.addRange('radius', 1, 1, 3);
};
/**
* Sharpen filter.
* @constructor
*/
ImageEditor.Mode.Sharpen = function() {
ImageEditor.Mode.Adjust.call(this, 'Sharpen');
};
ImageEditor.Mode.Sharpen.prototype =
{__proto__: ImageEditor.Mode.Adjust.prototype};
ImageEditor.Mode.register(ImageEditor.Mode.Sharpen);
ImageEditor.Mode.Sharpen.prototype.createTools = function(toolbar) {
toolbar.addRange('strength', 0, 0, 1, 100);
toolbar.addRange('radius', 1, 1, 3);
};
......@@ -145,7 +145,7 @@ ImageBuffer.prototype.onClick = function (x, y) {
/**
* Searches for a drag handler in the descending Z-order.
* @return {Function} A closure to be called on mouse drag.
* @return {function(number,number)} A function to be called on mouse drag.
*/
ImageBuffer.prototype.getDragHandler = function (x, y) {
for (var i = this.overlays_.length - 1; i >= 0; i--) {
......@@ -191,7 +191,8 @@ ImageBuffer.Margin.prototype.draw = function(context) {
Rect.fillBetween(context,
this.viewport_.getImageBoundsOnScreen(),
this.viewport_.getScreenBounds());
Rect.stroke(context, this.viewport_.getImageBoundsOnScreen());
Rect.outline(context, this.viewport_.getImageBoundsOnScreen());
context.restore();
};
......@@ -252,8 +253,8 @@ ImageBuffer.Content.prototype.getCanvas = function() { return this.canvas_ };
* If the logical width/height are supplied they override the canvas dimensions
* and the canvas contents is scaled when displayed.
* @param {HTMLCanvasElement} canvas
* @param {Number} opt_width Logical width (=canvas.width by default)
* @param {Number} opt_height Logical height (=canvas.height by default)
* @param {number} opt_width Logical width (=canvas.width by default)
* @param {number} opt_height Logical height (=canvas.height by default)
*/
ImageBuffer.Content.prototype.setCanvas = function(
canvas, opt_width, opt_height) {
......@@ -275,8 +276,8 @@ ImageBuffer.Content.prototype.createBlankCanvas = function (width, height) {
};
/**
* @param {Number} opt_width Width of the copy, original width by default.
* @param {Number} opt_height Height of the copy, original height by default.
* @param {number} opt_width Width of the copy, original width by default.
* @param {number} opt_height Height of the copy, original height by default.
* @return {HTMLCanvasElement} A new canvas with a copy of the content.
*/
ImageBuffer.Content.prototype.copyCanvas = function (opt_width, opt_height) {
......@@ -342,27 +343,23 @@ ImageBuffer.Overview.prototype.update = function() {
this.contentGeneration_ = this.content_.getCacheGeneration();
var aspect = imageBounds.width / imageBounds.height;
if (aspect > 1) {
this.bounds_ = new Rect(ImageBuffer.Overview.MAX_SIZE,
ImageBuffer.Overview.MAX_SIZE / aspect);
} else {
this.bounds_ = new Rect(ImageBuffer.Overview.MAX_SIZE * aspect,
ImageBuffer.Overview.MAX_SIZE);
}
this.canvas_ =
this.content_.copyCanvas(this.bounds_.width, this.bounds_.height);
this.canvas_ = this.content_.copyCanvas(
ImageBuffer.Overview.MAX_SIZE * Math.min(aspect, 1),
ImageBuffer.Overview.MAX_SIZE / Math.max(aspect, 1));
}
this.bounds_ = null;
this.clipped_ = null;
if (this.viewport_.isClipped()) {
var screenBounds = this.viewport_.getScreenBounds();
this.bounds_ = this.bounds_.moveTo(
screenBounds.width - ImageBuffer.Overview.RIGHT - this.bounds_.width,
screenBounds.height - ImageBuffer.Overview.BOTTOM -
this.bounds_.height);
this.bounds_ = new Rect(
screenBounds.width - ImageBuffer.Overview.RIGHT - this.canvas_.width,
screenBounds.height - ImageBuffer.Overview.BOTTOM - this.canvas_.height,
this.canvas_.width,
this.canvas_.height);
this.scale_ = this.bounds_.width / imageBounds.width;
......@@ -380,10 +377,6 @@ ImageBuffer.Overview.prototype.draw = function(context) {
// Draw the thumbnail.
Rect.drawImage(context, this.canvas_, this.bounds_);
// Draw the thumbnail border.
context.strokeStyle = '#000000';
Rect.stroke(context, this.bounds_);
// Draw the shadow over the off-screen part of the thumbnail.
context.globalAlpha = 0.3;
context.fillStyle = '#000000';
......@@ -391,14 +384,19 @@ ImageBuffer.Overview.prototype.draw = function(context) {
// Outline the on-screen part of the thumbnail.
context.strokeStyle = '#FFFFFF';
Rect.stroke(context, this.clipped_);
Rect.outline(context, this.clipped_);
context.globalAlpha = 1;
// Draw the thumbnail border.
context.strokeStyle = '#000000';
Rect.outline(context, this.bounds_);
};
ImageBuffer.Overview.prototype.getCursorStyle = function(x, y) {
if (!this.bounds_ || !this.bounds_.inside(x, y)) return null;
// Indicate that the on-screen part is draggable.
if (this.clipped_.inside(x, y)) return 'move';
if (this.clipped_ && this.clipped_.inside(x, y)) return 'move';
// Indicate that the rest of the thumbnail is clickable.
return 'crosshair';
......
......@@ -43,7 +43,7 @@
/* Scaling controls */
.scale-tool {
position: absolute;
width: 275px;
width: 305px;
height: 43px;
right: 0;
bottom: 0;
......@@ -89,6 +89,11 @@
}
.scale-div .scale-up{
right: 63px;
}
.scale-div .scale-1to1{
width: 32px;
right: 29px;
}
......
......@@ -20,6 +20,7 @@
<script type="text/javascript" src="image_editor.js"></script>
<script type="text/javascript" src="image_transform.js"></script>
<script type="text/javascript" src="image_adjust.js"></script>
<script type="text/javascript" src="filter.js"></script>
<script type="text/javascript" src="image_encoder.js"></script>
<script type="text/javascript" src="exif_encoder.js"></script>
......
......@@ -6,8 +6,8 @@
* ImageEditor is the top level object that holds together and connects
* everything needed for image editing.
* @param {HTMLElement} container
* @param {Function} saveCallback
* @param {Function} closeCallback
* @param {function(Blob)} saveCallback
* @param {function()} closeCallback
*/
function ImageEditor(container, saveCallback, closeCallback) {
this.container_ = container;
......@@ -44,8 +44,8 @@ function ImageEditor(container, saveCallback, closeCallback) {
*
* Use this method when image_editor.html is loaded into an iframe.
*
* @param {Function} saveCallback
* @param {Function} closeCallback
* @param {function(Blob)} saveCallback
* @param {function()} closeCallback
* @param {HTMLCanvasElement|HTMLImageElement|String} source
* @param {Object} opt_metadata
* @return {ImageEditor}
......@@ -264,13 +264,14 @@ ImageEditor.ScaleControl = function(parent, viewport) {
var scaleDown = parent.ownerDocument.createElement('button');
scaleDown.className = 'scale-down';
scaleDiv.appendChild(scaleDown);
scaleDown.addEventListener('click', this.onDownButton.bind(this));
scaleDown.addEventListener('click', this.onDownButton.bind(this), false);
scaleDown.textContent = '-';
this.scaleRange_ = parent.ownerDocument.createElement('input');
this.scaleRange_.type = 'range';
this.scaleRange_.max = ImageEditor.ScaleControl.MAX_SCALE;
this.scaleRange_.addEventListener('change', this.onSliderChange.bind(this));
this.scaleRange_.addEventListener(
'change', this.onSliderChange.bind(this), false);
scaleDiv.appendChild(this.scaleRange_);
this.scaleLabel_ = parent.ownerDocument.createElement('span');
......@@ -279,13 +280,19 @@ ImageEditor.ScaleControl = function(parent, viewport) {
var scaleUp = parent.ownerDocument.createElement('button');
scaleUp.className = 'scale-up';
scaleUp.textContent = '+';
scaleUp.addEventListener('click', this.onUpButton.bind(this));
scaleUp.addEventListener('click', this.onUpButton.bind(this), false);
scaleDiv.appendChild(scaleUp);
var scale1to1 = parent.ownerDocument.createElement('button');
scale1to1.className = 'scale-1to1';
scale1to1.textContent = '1:1';
scale1to1.addEventListener('click', this.on1to1Button.bind(this), false);
scaleDiv.appendChild(scale1to1);
var scaleFit = parent.ownerDocument.createElement('button');
scaleFit.className = 'scale-fit';
scaleFit.textContent = '\u2610';
scaleFit.addEventListener('click', this.onFitButton.bind(this));
scaleFit.addEventListener('click', this.onFitButton.bind(this), false);
scaleDiv.appendChild(scaleFit);
};
......@@ -306,7 +313,7 @@ ImageEditor.ScaleControl.FACTOR = 100;
*/
ImageEditor.ScaleControl.prototype.setMinScale = function(scale) {
this.scaleRange_.min = Math.min(
Math.round(scale * ImageEditor.ScaleControl.FACTOR),
Math.round(Math.min(1, scale) * ImageEditor.ScaleControl.FACTOR),
ImageEditor.ScaleControl.MAX_SCALE);
};
......@@ -378,23 +385,28 @@ ImageEditor.ScaleControl.prototype.onFitButton = function () {
this.viewport_.repaint();
};
ImageEditor.ScaleControl.prototype.on1to1Button = function () {
this.viewport_.setScale(1);
this.viewport_.repaint();
};
/**
* A helper object for panning the ImageBuffer.
* @constructor
*/
ImageEditor.MouseControl = function(canvas, buffer) {
this.canvas_ = canvas;
this.buffer_ = buffer;
canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
canvas.addEventListener('mousedown', this.onMouseDown.bind(this), false);
canvas.addEventListener('mouseup', this.onMouseUp.bind(this), false);
canvas.addEventListener('mousemove', this.onMouseMove.bind(this), false);
};
ImageEditor.MouseControl.getPosition = function(e) {
var clientRect = e.target.getBoundingClientRect();
return {
x: e.x - clientRect.left,
y: e.y - clientRect.top
x: e.clientX - clientRect.left,
y: e.clientY - clientRect.top
};
};
......@@ -433,8 +445,8 @@ ImageEditor.MouseControl.prototype.onMouseMove = function(e) {
/**
* A toolbar for the ImageEditor.
* @constructor
*/
ImageEditor.Toolbar = function (parent, updateCallback) {
this.wrapper_ = parent.ownerDocument.createElement('div');
this.wrapper_.className = 'toolbar';
......@@ -464,16 +476,16 @@ ImageEditor.Toolbar.prototype.addLabel = function(text) {
ImageEditor.Toolbar.prototype.addButton = function(text, handler) {
var button = this.create_('button');
button.textContent = text;
button.addEventListener('click', handler);
button.addEventListener('click', handler, false);
return this.add(button);
};
/**
* @param {String} name An option name.
* @param {Number} min Min value of the option.
* @param {Number} value Default value of the option.
* @param {Number} max Max value of the options.
* @param {Number} scale A number to multiply by when setting
* @param {string} name An option name.
* @param {number} min Min value of the option.
* @param {number} value Default value of the option.
* @param {number} max Max value of the options.
* @param {number} scale A number to multiply by when setting
* min/value/max in DOM.
*/
ImageEditor.Toolbar.prototype.addRange = function(
......@@ -507,13 +519,18 @@ ImageEditor.Toolbar.prototype.addRange = function(
range.setValue(value);
};
range.addEventListener('change', function() {
range.addEventListener('change',
function() {
mirror();
self.updateCallback_(self.getOptions());
});
},
false);
range.setValue(value);
var descr = this.create_('span');
descr.textContent = name;
this.add(descr);
this.add(range);
this.add(label);
......
......@@ -68,8 +68,10 @@ ImageEditor.Mode.Rotate.prototype.createTools = function(toolbar) {
this.tiltRange_ =
toolbar.addRange('angle', -maxTilt, 0, maxTilt, 10);
this.tiltRange_.addEventListener('mousedown', this.onTiltStart.bind(this));
this.tiltRange_.addEventListener('mouseup', this.onTiltStop.bind(this));
this.tiltRange_.
addEventListener('mousedown', this.onTiltStart.bind(this), false);
this.tiltRange_.
addEventListener('mouseup', this.onTiltStop.bind(this), false);
};
ImageEditor.Mode.Rotate.prototype.getOriginal = function() {
......@@ -161,23 +163,23 @@ ImageEditor.Mode.Rotate.prototype.draw = function(context) {
context.globalAlpha = 0.4;
context.strokeStyle = "#C0C0C0";
for(var x = Math.floor(screenClipped.left / STEP) * STEP;
context.beginPath();
var top = screenClipped.top + 0.5;
var left = screenClipped.left + 0.5;
for(var x = Math.ceil(screenClipped.left / STEP) * STEP;
x < screenClipped.left + screenClipped.width;
x += STEP) {
var left = Math.max(screenClipped.left, x);
var right = Math.min(screenClipped.left + screenClipped.width, x + STEP);
context.strokeRect(
left, screenClipped.top, right - left, screenClipped.height);
context.moveTo(x + 0.5, top);
context.lineTo(x + 0.5, top + screenClipped.height);
}
for(var y = Math.floor(screenClipped.top / STEP) * STEP;
for(var y = Math.ceil(screenClipped.top / STEP) * STEP;
y < screenClipped.top + screenClipped.height;
y += STEP) {
var top = Math.max(screenClipped.top, y);
var bottom = Math.min(screenClipped.top + screenClipped.height, y + STEP);
context.strokeRect(
screenClipped.left, top, screenClipped.width, bottom - top);
context.moveTo(left, y + 0.5);
context.lineTo(left + screenClipped.width, y + 0.5);
}
context.closePath();
context.stroke();
context.restore();
};
......@@ -351,7 +353,8 @@ ImageEditor.Mode.Crop.prototype.rollback = function() {
ImageEditor.Mode.Crop.prototype.createDefaultCrop = function() {
var rect = new Rect(this.getViewport().getImageClipped());
rect = rect.inflate (-rect.width / 6, -rect.height / 6);
rect = rect.inflate (
-Math.round(rect.width / 6), -Math.round(rect.height / 6));
this.cropRect_ = new DraggableRect(
rect, this.getViewport(), ImageEditor.Mode.Crop.GRAB_RADIUS);
};
......@@ -368,7 +371,6 @@ ImageEditor.Mode.Crop.prototype.draw = function(context) {
context.globalAlpha = 0.25;
context.fillStyle = '#000000';
Rect.fillBetween(context, inner, outer);
Rect.stroke(context, inner);
context.fillStyle = '#FFFFFF';
context.beginPath();
......@@ -380,15 +382,24 @@ ImageEditor.Mode.Crop.prototype.draw = function(context) {
context.arc(inner_right, inner.top, R, 0, Math.PI * 2);
context.moveTo(inner_right, inner_bottom);
context.arc(inner_right, inner_bottom, R, 0, Math.PI * 2);
context.closePath();
context.fill();
context.globalAlpha = 1;
context.strokeStyle = '#808080';
context.strokeRect(inner.left, inner.top, inner.width, inner.height);
context.strokeRect(
inner.left + inner.width / 3, inner.top, inner.width / 3, inner.height);
context.strokeRect(
inner.left, inner.top + inner.height / 3, inner.width, inner.height / 3);
context.globalAlpha = 0.5;
context.strokeStyle = '#FFFFFF';
context.beginPath();
context.closePath();
for (var i = 0; i <= 3; i++) {
var y = inner.top - 0.5 + Math.round((inner.height + 1) * i / 3);
context.moveTo(inner.left, y);
context.lineTo(inner.left + inner.width, y);
var x = inner.left - 0.5 + Math.round((inner.width + 1) * i / 3);
context.moveTo(x, inner.top);
context.lineTo(x, inner.top + inner.height);
}
context.stroke();
};
ImageEditor.Mode.Crop.prototype.getCursorStyle = function(x, y, mouseDown) {
......
......@@ -47,23 +47,6 @@ ImageUtil.between = function(min, value, max) {
return (value - min) * (value - max) <= 0;
};
/**
* Computes the function for every integer value between 0 and max and stores
* the results. Rounds and clips the results to fit the [0..255] range.
* Used to speed up pixel manipulations.
* @param {Function} func Function returning a number.
* @param {Number} max Maximum argument value (inclusive).
* @return {Array<Number>} Computed results
*/
ImageUtil.precomputeByteFunction = function(func, max) {
var results = [];
for (var arg = 0; arg <= max; arg ++) {
results.push(Math.max(0, Math.min(0xFF, Math.round(func(arg)))));
}
return results;
}
/**
* Rectangle class.
*/
......@@ -97,10 +80,10 @@ function Rect(args) {
case 1: {
var source = arguments[0];
if (source.hasOwnProperty('left') && source.hasOwnProperty('top')) {
if ('left' in source && 'top' in source) {
this.left = source.left;
this.top = source.top;
if (source.hasOwnProperty('right') && source.hasOwnProperty('bottom')) {
if ('right' in source && 'bottom' in source) {
this.width = source.right - source.left;
this.height = source.bottom - source.top;
return;
......@@ -109,7 +92,7 @@ function Rect(args) {
this.left = 0;
this.top = 0;
}
if (source.hasOwnProperty('width') && source.hasOwnProperty('height')) {
if ('width' in source && 'height' in source) {
this.width = source.width;
this.height = source.height;
return;
......@@ -204,7 +187,7 @@ Rect.prototype.clamp = function(bounds) {
*/
/**
* Draws the image in context with appropriate scaling.
* Draw the image in context with appropriate scaling.
*/
Rect.drawImage = function(context, image, opt_dstRect, opt_srcRect) {
opt_dstRect = opt_dstRect || new Rect(context.canvas);
......@@ -215,10 +198,18 @@ Rect.drawImage = function(context, image, opt_dstRect, opt_srcRect) {
};
/**
* Strokes the rectangle.
* Draw a box around the rectangle.
*/
Rect.outline = function(context, rect) {
context.strokeRect(
rect.left - 0.5, rect.top - 0.5, rect.width + 1, rect.height + 1);
};
/**
* Fill the rectangle.
*/
Rect.stroke = function(context, rect) {
context.strokeRect(rect.left, rect.top, rect.width, rect.height);
Rect.fill = function(context, rect) {
context.fillRect(rect.left, rect.top, rect.width, rect.height);
};
/**
......
......@@ -21,8 +21,8 @@
.debug-output {
position: absolute;
top: 34px;
right: 1px;
text-align: right;
left: 1px;
text-align: left;
}
.debug-buttons {
......
......@@ -89,12 +89,13 @@ Viewport.prototype.setCenter = function(x, y, ignoreClipping) {
/**
* Return a closure that can be called to pan the image.
* Useful for implementing non-trivial variants of panning (overview etc).
* @param {Number} originalX The x coordinate on the screen canvas that
* @param {number} originalX The x coordinate on the screen canvas that
* corresponds to zero change to offsetX.
* @param {Number} originalY The y coordinate on the screen canvas that
* @param {number} originalY The y coordinate on the screen canvas that
* corresponds to zero change to offsetY.
* @param {Function} scaleFunc returns the current image to screen scale.
* @param {Function} hitFunc returns true if (x,y) is in the valid region.
* @param {function():number} scaleFunc returns the image to screen scale.
* @param {function(number,number):boolean} hitFunc returns true if (x,y) is
* in the valid region.
*/
Viewport.prototype.createOffsetSetter = function (
originalX, originalY, scaleFunc, hitFunc) {
......@@ -213,7 +214,7 @@ Viewport.prototype.isClipped = function () {
* Horizontal margin. Negative if the image is clipped horizontally.
*/
Viewport.prototype.getMarginX_ = function() {
return Math.floor(
return Math.round(
(this.screenBounds_.width - this.imageBounds_.width * this.scale_) / 2);
};
......@@ -221,17 +222,17 @@ Viewport.prototype.getMarginX_ = function() {
* Vertical margin. Negative if the image is clipped vertically.
*/
Viewport.prototype.getMarginY_ = function() {
return Math.floor(
return Math.round(
(this.screenBounds_.height - this.imageBounds_.height * this.scale_) / 2);
};
Viewport.prototype.clampOffsetX_ = function(x) {
var limit = Math.max(0, -this.getMarginX_() / this.getScale());
var limit = Math.round(Math.max(0, -this.getMarginX_() / this.getScale()));
return ImageUtil.clamp(-limit, x, limit);
};
Viewport.prototype.clampOffsetY_ = function(y) {
var limit = Math.max(0, -this.getMarginY_() / this.getScale());
var limit = Math.round(Math.max(0, -this.getMarginY_() / this.getScale()));
return ImageUtil.clamp(-limit, y, limit);
};
......@@ -245,8 +246,8 @@ Viewport.prototype.update = function() {
this.imageOnScreen_ = new Rect(
this.getMarginX_(),
this.getMarginY_(),
Math.floor(this.imageBounds_.width * scale),
Math.floor(this.imageBounds_.height * scale));
Math.round(this.imageBounds_.width * scale),
Math.round(this.imageBounds_.height * scale));
// A visible part of the image in image coordinates.
this.imageClipped_ = new Rect(this.imageBounds_);
......@@ -256,18 +257,20 @@ Viewport.prototype.update = function() {
// Adjust for the offset.
if (this.imageOnScreen_.left < 0) {
this.imageOnScreen_.left += this.clampOffsetX_(this.offsetX_) * scale;
this.imageClipped_.left = -this.imageOnScreen_.left / scale;
this.imageClipped_.width = this.screenBounds_.width / scale;
this.imageOnScreen_.left +=
Math.round(this.clampOffsetX_(this.offsetX_) * scale);
this.imageClipped_.left = Math.round(-this.imageOnScreen_.left / scale);
this.imageClipped_.width = Math.round(this.screenBounds_.width / scale);
} else {
this.screenClipped_.left = this.imageOnScreen_.left;
this.screenClipped_.width = this.imageOnScreen_.width;
}
if (this.imageOnScreen_.top < 0) {
this.imageOnScreen_.top += this.clampOffsetY_(this.offsetY_) * scale;
this.imageClipped_.top = -this.imageOnScreen_.top / scale;
this.imageClipped_.height = this.screenBounds_.height / scale;
this.imageOnScreen_.top +=
Math.round(this.clampOffsetY_(this.offsetY_) * scale);
this.imageClipped_.top = Math.round(-this.imageOnScreen_.top / scale);
this.imageClipped_.height = Math.round(this.screenBounds_.height / scale);
} else {
this.screenClipped_.top = this.imageOnScreen_.top;
this.screenClipped_.height = this.imageOnScreen_.height;
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment