/**
* @file Allows uploading, cropping (with automatic resizing) and exporting
* of images.
* @author Billy Brown
* @license MIT
* @version 2.1.0
*/
/** Class used for uploading images. */
class Uploader {
/**
* <p>Creates an Uploader instance with parameters passed as an object.</p>
* <p>Available parameters are:</p>
* <ul>
* <li>exceptions {function}: the exceptions handler to use, function that takes a string.</li>
* <li>input {HTMLElement} (required): the file input element. Instantiation fails if not provided.</li>
* <li>types {array}: the file types accepted by the uploader.</li>
* </ul>
*
* @example
* var uploader = new Uploader({
* input: document.querySelector('.js-fileinput'),
* types: [ 'gif', 'jpg', 'jpeg', 'png' ]
* });
* *
* @param {object} options the parameters to be passed for instantiation
*/
constructor(options) {
if (!options.input) {
throw '[Uploader] Missing input file element.';
}
this.fileInput = options.input;
this.types = options.types || [ 'gif', 'jpg', 'jpeg', 'png' ];
}
/**
* Listen for an image file to be uploaded, then validate it and resolve with the image data.
*/
listen(resolve, reject) {
this.fileInput.onchange = (e) => {
// Do not submit the form
e.preventDefault();
// Make sure one file was selected
if (!this.fileInput.files || this.fileInput.files.length !== 1) {
reject('[Uploader:listen] Select only one file.');
}
let file = this.fileInput.files[0];
let reader = new FileReader();
// Make sure the file is of the correct type
if (!this.validFileType(file.type)) {
reject(`[Uploader:listen] Invalid file type: ${file.type}`);
} else {
// Read the image as base64 data
reader.readAsDataURL(file);
// When loaded, return the file data
reader.onload = (e) => resolve(e.target.result);
}
};
}
/** @private */
validFileType(filename) {
// Get the second part of the MIME type
let extension = filename.split('/').pop().toLowerCase();
// See if it is in the array of allowed types
return this.types.includes(extension);
}
}
function squareContains(square, coordinate) {
return coordinate.x >= square.pos.x
&& coordinate.x <= square.pos.x + square.size.x
&& coordinate.y >= square.pos.y
&& coordinate.y <= square.pos.y + square.size.y;
}
/** Class for cropping an image. */
class Cropper {
/**
* <p>Creates a Cropper instance with parameters passed as an object.</p>
* <p>Available parameters are:</p>
* <ul>
* <li>size {object} (required): the dimensions of the cropped, resized image. Must have 'width' and 'height' fields. </li>
* <li>limit {integer}: the longest side that the cropping area will be limited to, resizing any larger images.</li>
* <li>canvas {HTMLElement} (required): the cropping canvas element. Instantiation fails if not provided.</li>
* <li>preview {HTMLElement} (required): the preview canvas element. Instantiation fails if not provided.</li>
* <li>drawCrop {function} (optional): a function for drawing the cropping area given the canvas context and crop object.</li>
* </ul>
*
* <code>drawCrop</code> has the following prototype: <code>drawCrop({canvas context}, {crop object})</code>.
*
* The <code>crop</code> object has the form:
* <pre><code>
* crop = {
* size: { x: Number, y: Number },
* pos: { x: Number, y: Number },
* handleSize: Number
* }
* </pre><code>
*
* @example
* var editor = new Cropper({
* size: { width: 128, height: 128 },
* limit: 600,
* canvas: document.querySelector('.js-editorcanvas'),
* preview: document.querySelector('.js-previewcanvas'),
* drawCrop: (context, crop) => {
* // Draw code here
* }
* });
*
* @param {object} options the parameters to be passed for instantiation
*/
constructor(options) {
// Check the inputs
if (!options.size) { throw 'Size field in options is required.'; }
if (!options.canvas) { throw 'Image canvas element in options is required.'; }
if (!options.preview) { throw 'Preview canvas element in options is required'; }
// Hold on to the values
this.imageCanvas = options.canvas;
this.previewCanvas = options.preview;
this.c = this.imageCanvas.getContext("2d");
// Images larger than options.limit are resized
this.limit = options.limit || 600; // default to 600px
// Create the cropping square with the handle's size
this.crop = {
size: { x: options.size.width, y: options.size.height },
pos: { x: 0, y: 0 },
handleSize: 10
};
// Set the crop area draw function
this.drawCropWindow = options.drawCrop || this.defaultDrawCropWindow;
// Set the preview canvas size
this.previewCanvas.width = options.size.width;
this.previewCanvas.height = options.size.height;
// Bind the methods, ready to be added and removed as events
this.boundDrag = this.drag.bind(this);
this.boundClickStop = this.clickStop.bind(this);
}
/**
* Set the source image data for the cropper and get it running.
*
* @param {String} source the source of the image to crop.
*/
setImageSource(source) {
this.image = new Image();
this.image.src = source;
this.image.onload = (e) => {
// Perform an initial render
this.render();
// Listen for events on the canvas when the image is ready
this.imageCanvas.onmousedown = this.clickStart.bind(this);
}
}
/**
* Export the result to a given image tag.
*
* @param {HTMLElement} img the image tag to export the result to.
*/
export(img) {
img.setAttribute('src', this.previewCanvas.toDataURL());
}
/** @private */
render() {
this.c.clearRect(0, 0, this.imageCanvas.width, this.imageCanvas.height);
this.displayImage();
this.preview();
this.drawCropWindow(this.c, this.crop);
}
/** @private */
clickStart(e) {
// Get the click position and hold onto it for the expected mousemove
const position = { x: e.offsetX, y: e.offsetY };
this.lastEvent = {
position: position,
resizing: this.isResizing(position),
moving: this.isMoving(position)
};
// Listen for mouse movement and mouse release
this.imageCanvas.addEventListener('mousemove', this.boundDrag);
this.imageCanvas.addEventListener('mouseup', this.boundClickStop);
}
/** @private */
clickStop(e) {
// Stop listening for mouse movement and mouse release
this.imageCanvas.removeEventListener("mousemove", this.boundDrag);
this.imageCanvas.removeEventListener("mouseup", this.boundClickStop);
}
/** @private */
isResizing(coord) {
const size = this.crop.handleSize;
const handle = {
pos: {
x: this.crop.pos.x + this.crop.size.x - size / 2,
y: this.crop.pos.y + this.crop.size.y - size / 2
},
size: { x: size, y: size }
};
return squareContains(handle, coord);
}
/** @private */
isMoving(coord) {
return squareContains(this.crop, coord);
}
/** @private */
drag(e) {
const position = {
x: e.offsetX,
y: e.offsetY
};
// Calculate the distance that the mouse has travelled
const dx = position.x - this.lastEvent.position.x;
const dy = position.y - this.lastEvent.position.y;
// Determine whether we are resizing, moving, or nothing
if (this.lastEvent.resizing) {
this.resize(dx, dy);
} else if (this.lastEvent.moving) {
this.move(dx, dy);
}
// Update the last position
this.lastEvent.position = position;
this.render();
}
/** @private */
resize(dx, dy) {
let handle = {
x: this.crop.pos.x + this.crop.size.x,
y: this.crop.pos.y + this.crop.size.y
};
// Maintain the aspect ratio
const amount = Math.abs(dx) > Math.abs(dy) ? dx : dy;
// Make sure that the handle remains within image bounds
if (this.inBounds(handle.x + amount, handle.y + amount)) {
this.crop.size.x += amount;
this.crop.size.y += amount;
}
}
/** @private */
move(dx, dy) {
// Get the opposing coordinates
const tl = {
x: this.crop.pos.x,
y: this.crop.pos.y
};
const br = {
x: this.crop.pos.x + this.crop.size.x,
y: this.crop.pos.y + this.crop.size.y
};
// Make sure they are in bounds
if (this.inBounds(tl.x + dx, tl.y + dy) &&
this.inBounds(br.x + dx, tl.y + dy) &&
this.inBounds(br.x + dx, br.y + dy) &&
this.inBounds(tl.x + dx, br.y + dy)) {
this.crop.pos.x += dx;
this.crop.pos.y += dy;
}
}
/** @private */
displayImage() {
// Resize the original to the maximum allowed size
const ratio = this.limit / Math.max(this.image.width, this.image.height);
this.image.width *= ratio;
this.image.height *= ratio;
// Fit the image to the canvas
this.imageCanvas.width = this.image.width;
this.imageCanvas.height = this.image.height;
this.c.drawImage(this.image, 0, 0, this.image.width, this.image.height);
}
/** @private */
defaultDrawCropWindow(context, crop) {
const pos = crop.pos;
const size = crop.size;
const radius = crop.handleSize / 2;
context.strokeStyle = 'red';
context.fillStyle = 'red';
// Draw the crop window outline
context.strokeRect(pos.x, pos.y, size.x, size.y);
// Draw the draggable handle
const path = new Path2D();
path.arc(pos.x + size.x, pos.y + size.y, radius, 0, Math.PI * 2, true);
context.fill(path);
}
/** @private */
preview() {
const pos = this.crop.pos;
const size = this.crop.size;
// Fetch the image data from the canvas
const imageData = this.c.getImageData(pos.x, pos.y, size.x, size.y);
if (!imageData) {
return false;
}
// Prepare and clear the preview canvas
const ctx = this.previewCanvas.getContext('2d');
ctx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
// Draw the image to the preview canvas, resizing it to fit
ctx.drawImage(this.imageCanvas,
// Top left corner coordinates of image
pos.x, pos.y,
// Width and height of image
size.x, size.y,
// Top left corner coordinates of result in canvas
0, 0,
// Width and height of result in canvas
this.previewCanvas.width, this.previewCanvas.height);
}
/** @private */
inBounds(x, y) {
return squareContains({
pos: { x: 0, y: 0 },
size: {
x: this.imageCanvas.width,
y: this.imageCanvas.height
}
}, { x: x, y: y });
}
}