Source: ImageEditor.es.js

import Component from 'metal-component/src/Component';
import Soy from 'metal-soy/src/Soy';

import async from 'metal/src/async/async';
import core from 'metal/src/core';
import dom from 'metal-dom/src/dom';
import { CancellablePromise } from 'metal-promise/src/promise/Promise';
import Dropdown from 'metal-dropdown/src/Dropdown';

import ImageEditorHistoryEntry from './ImageEditorHistoryEntry.es';
import ImageEditorLoading from './ImageEditorLoading.es';

import templates from './ImageEditor.soy';

/**
 * ImageEditor
 *
 * This class bootstraps all the necessary parts of an image editor. It only controls
 * the state and history of the editing process, orchestrating how the different parts
 * of the application work.
 *
 * All image processing is delegated to the different image editor capability implementations. The
 * editor provides:
 * - A common way of exposing the functionality.
 * - Some registration points which can be used by the image editor capability implementors
 * to provide UI controls.
 */
class ImageEditor extends Component {
	/**
	 * @inheritDoc
	 */
	constructor(opt_config) {
		super(opt_config);

		/**
		 * This index points to the current state in the history.
		 *
		 * @type {Number}
		 * @protected
		 */
		this.historyIndex_ = 0;

		/**
		 * History of the different image states during edition. Every
		 * entry entry represents a change to the image on top of the
		 * previous one.
		 * - History entries are objects with
		 *     - url (optional): the url representing the image
		 *     - data: the ImageData object of the image
		 *
		 * @type {Array.<Object>}
		 * @protected
		 */
		this.history_ = [
			new ImageEditorHistoryEntry(
				{
					url: this.image
				}
			)
		];

		// Polyfill svg usage for lexicon icons
		svg4everybody(
			{
				attributeName: 'data-href',
				polyfill: true
			}
		);

		// Load the first entry imageData and render it on the app.
		this.history_[0].getImageData()
			.then((imageData) => {
				async.nextTick(() => {
					this.imageEditorReady = true;

					this.syncImageData_(imageData);
				});
			});
	}

	/**
	 * Accepts the current changes applied by the active control and creates
	 * a new entry in the history stack. Doing this will wipe out any
	 * stale redo states.
	 */
	accept() {
		let selectedControl = this.components[this.id + '_selected_control_' + this.selectedControl.variant];

		this.history_[this.historyIndex_].getImageData()
			.then((imageData) => selectedControl.process(imageData))
			.then((imageData) => this.createHistoryEntry_(imageData))
			.then(() => this.syncHistory_())
			.then(() => {
				this.selectedControl = null;
				this.selectedTool = null;
			});
	}

	/**
	 * Notifies the opener app that the user wants to close the
	 * editor without saving the changes
	 *
	 * @protected
	 */
	close_() {
		Liferay.Util.getWindow().hide();
	}

	/**
	 * Creates a new history entry state.
	 *
	 * @param  {ImageData} imageData The ImageData of the new image.
	 * @protected
	 */
	createHistoryEntry_(imageData) {
		// Push new state and discard stale redo states
		this.historyIndex_++;
		this.history_.length = this.historyIndex_ + 1;
		this.history_[this.historyIndex_] = new ImageEditorHistoryEntry({data: imageData});

		return CancellablePromise.resolve();
	}

	/**
	 * Discards the current changes applied by the active control and reverts
	 * the image to its state before the control activation.
	 */
	discard() {
		this.selectedControl = null;
		this.selectedTool = null;
		this.syncHistory_();
	}

	/**
	 * Retrieves the editor canvas DOM node.
	 *
	 * @return {Element} The canvas element.
	 */
	getImageEditorCanvas() {
		return this.element.querySelector('.lfr-image-editor-image-container canvas');
	}

	/**
	 * Retrieves the Blob representation of the current image.
	 *
	 * @return {CancellablePromise} A promise that will resolve with the image blob.
	 */
	getImageEditorImageBlob() {
		return new CancellablePromise((resolve, reject) => {
			this.getImageEditorImageData()
				.then(imageData => {
					let canvas = document.createElement('canvas');
					canvas.width = imageData.width;
					canvas.height = imageData.height;

					canvas.getContext('2d').putImageData(imageData, 0, 0);

					if (canvas.toBlob) {
						canvas.toBlob(resolve, this.saveMimeType);
					}
					else {
						let data = atob(canvas.toDataURL(this.saveMimeType).split(',')[1]);
						let length = data.length;
						let bytes = new Uint8Array(length);

						for (let i = 0; i < length; i++) {
							bytes[i] = data.charCodeAt(i);
						}

						resolve(new Blob([bytes], {type: this.saveMimeType}));
					}
				});
		});
	}

	/**
	 * Retrieves the ImageData representation of the current image.
	 *
	 * @return {CancellablePromise} A promise that will resolve with the image data.
	 */
	getImageEditorImageData() {
		return this.history_[this.historyIndex_].getImageData();
	}

	/**
	 * Normalizes different mime types to the most similar mime type
	 * available to canvas implementations.
	 *
	 * @see http://kangax.github.io/jstests/toDataUrl_mime_type_test/
	 *
	 * @param  {String} mimeType Original mime type
	 * @return {String} The normalized mime type
	 */
	normalizeCanvasMimeType_(mimeType) {
		mimeType = mimeType.toLowerCase();

		return mimeType.replace('jpg', 'jpeg');
	}

	/**
	 * Notifies the opener app of the result of the save action
	 *
	 * @param  {Object} result The server response to the save action
	 * @protected
	 */
	notifySaveResult_(result) {
		this.components.loading.show = false;

		if (result && result.success) {
			Liferay.Util.getOpener().Liferay.fire(
				this.saveEventName,
				{
					data: result
				}
			);

			Liferay.Util.getWindow().hide();
		}
		else if (result.error) {
			this.showError_(result.error.message);
		}
	}

	/**
	 * Updates the image back to a previously undone state in the history.
	 * Redoing an action recovers the undone image changes and enables the
	 * undo stack in case the user wants to undo the changes again.
	 */
	redo() {
		this.historyIndex_++;
		this.syncHistory_();
	}

	/**
	 * Selects a control and starts the edition phase for it.
	 *
	 * @param  {MouseEvent} event
	 */
	requestImageEditorEdit(event) {
		let controls = this.imageEditorCapabilities.tools.reduce(
			(prev, curr) => prev.concat(curr.controls), []);

		let target = event.delegateTarget || event.currentTarget;
		let targetControl = target.getAttribute('data-control');
		let targetTool = target.getAttribute('data-tool');

		this.syncHistory_()
			.then(() => {
				this.selectedControl = controls.filter(tool => tool.variant === targetControl)[0];
				this.selectedTool = targetTool;
			});
	}

	/**
	 * Queues a request for a preview process of the current image by the
	 * currently selected control.
	 */
	requestImageEditorPreview() {
		let selectedControl = this.components[this.id + '_selected_control_' + this.selectedControl.variant];

		this.history_[this.historyIndex_].getImageData()
			.then((imageData) => selectedControl.preview(imageData))
			.then((imageData) => this.syncImageData_(imageData));

		this.components.loading.show = true;
	}

	/**
	 * Discards all changes and restores the original state of the image.
	 * Unlike the undo/redo methods, reset will wipe out all the history.
	 */
	reset() {
		this.historyIndex_ = 0;
		this.history_.length = 1;
		this.syncHistory_();
	}

	/**
	 * Tries to save the current image using the provided save url.
	 *
	 * @param {MouseEvent} event The MouseEvent that triggered the save action
	 * @protected
	 */
	save_(event) {
		if (!event.delegateTarget.disabled) {
			this.getImageEditorImageBlob()
				.then((imageBlob) => this.submitBlob_(imageBlob))
				.then((result) => this.notifySaveResult_(result))
				.catch((error) => this.showError_(error));
		}
	}

	/**
	 * Setter function for the `saveMimeType` state key
	 *
	 * @param  {!String} saveMimeType The optional passed value for the attribute
	 * @return {String} The computed value for the attribute
	 * @protected
	 */
	setterSaveMimeTypeFn_(saveMimeType) {
		if (!saveMimeType) {
			const imageExtensionRegex = /\.(\w+)\/[^?\/]+/;
			const imageExtension = this.image.match(imageExtensionRegex)[1];

			saveMimeType = `image/${imageExtension}`;
		}

		return this.normalizeCanvasMimeType_(saveMimeType);
	}

	/**
	 * Shows an error message in the editor
	 *
	 * @param  {String} message The error message to show
	 * @protected
	 */
	showError_(message) {
		this.components.loading.show = false;

		AUI().use('liferay-alert', () => {
			new Liferay.Alert(
				{
					delay: {
						hide: 2000,
						show: 0
					},
					duration: 3000,
					icon: 'exclamation-circle',
					message: message.message,
					type: 'danger'
				}
			).render(this.element);
		});
	}

	/**
	 * Sends a given image blob to the server for processing
	 * and storing.
	 *
	 * @param  {Blob} imageBlob The image blob to send to the server
	 * @return {CancellablePromise} A promise that follows the xhr submission process
	 * @protected
	 */
	submitBlob_(imageBlob) {
		let saveFileName = this.saveFileName;
		let saveParamName = this.saveParamName;

		let promise = new CancellablePromise((resolve, reject) => {
			let formData = new FormData();

			formData.append(saveParamName, imageBlob, saveFileName);

			let requestConfig = {
				contentType: false,
				data: formData,
				dataType: "json",
				processData: false,
				type: 'POST',
				url: this.saveURL
			};

			AUI.$.ajax(requestConfig)
				.done(resolve)
				.fail((jqXHR, status, error) => reject(error));
		});

		this.components.loading.show = true;

		return promise;
	}

	/**
	 * Syncs the image and history values after changes to the
	 * history stack.
	 *
	 * @protected
	 */
	syncHistory_() {
		return new CancellablePromise((resolve, reject) => {
			this.history_[this.historyIndex_].getImageData()
				.then((imageData) => {
					this.syncImageData_(imageData);

					this.history = {
						canRedo: this.historyIndex_ < this.history_.length - 1,
						canReset: this.history_.length > 1,
						canUndo: this.historyIndex_ > 0
					};

					resolve();
				});
		});
	}

	/**
	 * Updates the image data showed in the editable area
	 *
	 * @param  {ImageData} imageData The new ImageData value to show on the editor
	 * @protected
	 */
	syncImageData_(imageData) {
		let width = imageData.width;
		let height = imageData.height;

		let aspectRatio = width / height;

		let offscreenCanvas = document.createElement('canvas');
		offscreenCanvas.width = width;
		offscreenCanvas.height = height;

		let offscreenContext = offscreenCanvas.getContext('2d');
		offscreenContext.clearRect(0, 0, width, height);
		offscreenContext.putImageData(imageData, 0, 0);

		let canvas = this.getImageEditorCanvas();

		let boundingBox = dom.closest(this.element, '.portlet-layout');
		let availableWidth = boundingBox.offsetWidth;

		let dialogFooterHeight = 0;
		let dialogFooter = this.element.querySelector('.dialog-footer');

		if (dialogFooter) {
			dialogFooterHeight = dialogFooter.offsetHeight;
		}

		let availableHeight = boundingBox.offsetHeight - 142 - 40 - dialogFooterHeight;
		let availableAspectRatio = availableWidth / availableHeight;

		if (availableAspectRatio > 1) {
			canvas.height = availableHeight;
			canvas.width = aspectRatio * availableHeight;
		} else {
			canvas.width = availableWidth;
			canvas.height = availableWidth / aspectRatio;
		}

		let context = canvas.getContext('2d');
		context.clearRect(0, 0, canvas.width, canvas.height);
		context.drawImage(offscreenCanvas, 0, 0, width, height, 0, 0, canvas.width, canvas.height);

		canvas.style.width = canvas.width + 'px';
		canvas.style.height = canvas.height + 'px';

		this.components.loading.show = false;
	}

	/**
	 * Reverts the image to the previous state in the history. Undoing
	 * an action brings back the previous version of the image and enables
	 * the redo stack in case the user wants to reapply the change again.
	 */
	undo() {
		this.historyIndex_--;
		this.syncHistory_();
	}
}

/**
 * State definition.
 * @type {!Object}
 * @static
 */
ImageEditor.STATE = {
	/**
	 * Indicates that the editor is ready for user interaction
	 * @type {Object}
	 */
	imageEditorReady: {
		validator: core.isBoolean,
		value: false
	},

	/**
	 * Event to dispatch when the edition has been completed
	 * @type {String}
	 */
	saveEventName: {
		validator: core.isString
	},

	/**
	 * Name of the saved image that should be sent
	 * to the server for the save action
	 * @type {String}
	 */
	saveFileName: {
		validator: core.isString
	},

	/**
	 * Mime type of the saved image. If not explicitly set,
	 * the image mime type will be infered from the image url.
	 * @type {String}
	 */
	saveMimeType: {
		setter: 'setterSaveMimeTypeFn_',
		validator: core.isString
	},

	/**
	 * Name of the param where the image should be sent
	 * to the server for the save action
	 * @type {String}
	 */
	saveParamName: {
		validator: core.isString
	},

	/**
	 * Url to save the image changes
	 * @type {String}
	 */
	saveURL: {
		validator: core.isString
	}
};

// Register component
Soy.register(ImageEditor, templates);

export default ImageEditor;