Source: ImageEditor.es.js

  1. /**
  2. * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
  3. *
  4. * This library is free software; you can redistribute it and/or modify it under
  5. * the terms of the GNU Lesser General Public License as published by the Free
  6. * Software Foundation; either version 2.1 of the License, or (at your option)
  7. * any later version.
  8. *
  9. * This library is distributed in the hope that it will be useful, but WITHOUT
  10. * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
  12. * details.
  13. */
  14. import './ImageEditorLoading.es';
  15. import 'clay-dropdown';
  16. import {PortletBase} from 'frontend-js-web';
  17. import {async, core} from 'metal';
  18. import dom from 'metal-dom';
  19. import Soy from 'metal-soy';
  20. import templates from './ImageEditor.soy';
  21. import ImageEditorHistoryEntry from './ImageEditorHistoryEntry.es';
  22. /**
  23. * Creates an Image Editor component.
  24. *
  25. * <p>
  26. * This class bootstraps all the necessary parts of an image editor. It only
  27. * controls the state and history of the editing process, orchestrating how the
  28. * different parts of the application work.
  29. * </p>
  30. *
  31. * <p>
  32. * All image processing is delegated to the different image editor capability
  33. * implementations. The editor provides
  34. * </p>
  35. *
  36. * <ul>
  37. * <li>
  38. * A common way of exposing the functionality.
  39. * </li>
  40. * <li>
  41. * Some registration points which can be used by the image editor capability
  42. * implementors to provide UI controls.
  43. * </li>
  44. * </ul>
  45. */
  46. class ImageEditor extends PortletBase {
  47. /**
  48. * @inheritDoc
  49. */
  50. attached() {
  51. this.historyIndex_ = 0;
  52. /**
  53. * History of the different image states during editing. Every entry
  54. * represents a change to the image on top of the previous one. History
  55. * entries are objects with the following attributes:
  56. *
  57. * <ul>
  58. * <li>
  59. * url (optional): the URL representing the image.
  60. * </li>
  61. * <li>
  62. * data: the image data object of the image.
  63. * </li>
  64. * </ul>
  65. * @protected
  66. * @type {Array.<Object>}
  67. */
  68. this.history_ = [
  69. new ImageEditorHistoryEntry({
  70. url: this.image,
  71. }),
  72. ];
  73. // Polyfill svg usage for lexicon icons
  74. if (window.svg4everybody) {
  75. svg4everybody({
  76. polyfill: true,
  77. });
  78. }
  79. // Load the first entry imageData and render it on the app.
  80. this.history_[0].getImageData().then((imageData) => {
  81. async.nextTick(() => {
  82. this.imageEditorReady = true;
  83. this.syncImageData_(imageData);
  84. });
  85. });
  86. }
  87. /**
  88. * Accepts the current changes applied by the active control and creates
  89. * a new entry in the history stack. This wipes out any stale redo states.
  90. */
  91. accept() {
  92. const selectedControl = this.components[
  93. this.id + '_selected_control_' + this.selectedControl.variant
  94. ];
  95. this.history_[this.historyIndex_]
  96. .getImageData()
  97. .then((imageData) => selectedControl.process(imageData))
  98. .then((imageData) => this.createHistoryEntry_(imageData))
  99. .then(() => this.syncHistory_())
  100. .then(() => {
  101. this.selectedControl = null;
  102. this.selectedTool = null;
  103. });
  104. }
  105. /**
  106. * Notifies the opener app that the user wants to close the editor without
  107. * saving the changes.
  108. *
  109. * @protected
  110. */
  111. close_() {
  112. Liferay.Util.getWindow().hide();
  113. }
  114. /**
  115. * Creates a new history entry state.
  116. *
  117. * @param {ImageData} imageData The image data of the new image.
  118. * @protected
  119. */
  120. createHistoryEntry_(imageData) {
  121. this.historyIndex_++;
  122. this.history_.length = this.historyIndex_ + 1;
  123. this.history_[this.historyIndex_] = new ImageEditorHistoryEntry({
  124. data: imageData,
  125. });
  126. return Promise.resolve();
  127. }
  128. /**
  129. * Discards the current changes applied by the active control and reverts
  130. * the image to its state before the control activation.
  131. */
  132. discard() {
  133. this.selectedControl = null;
  134. this.selectedTool = null;
  135. this.syncHistory_();
  136. }
  137. /**
  138. * Retrieves the editor canvas DOM node.
  139. *
  140. * @return {Element} The canvas element
  141. */
  142. getImageEditorCanvas() {
  143. return this.element.querySelector(
  144. '.lfr-image-editor-image-container canvas'
  145. );
  146. }
  147. /**
  148. * Retrieves the blob representation of the current image.
  149. *
  150. * @return {Promise} A promise that resolves with the image blob.
  151. */
  152. getImageEditorImageBlob() {
  153. return new Promise((resolve) => {
  154. this.getImageEditorImageData().then((imageData) => {
  155. const canvas = document.createElement('canvas');
  156. canvas.width = imageData.width;
  157. canvas.height = imageData.height;
  158. canvas.getContext('2d').putImageData(imageData, 0, 0);
  159. if (canvas.toBlob) {
  160. canvas.toBlob(resolve, this.saveMimeType);
  161. }
  162. else {
  163. const data = atob(
  164. canvas.toDataURL(this.saveMimeType).split(',')[1]
  165. );
  166. const length = data.length;
  167. const bytes = new Uint8Array(length);
  168. for (let i = 0; i < length; i++) {
  169. bytes[i] = data.charCodeAt(i);
  170. }
  171. resolve(new Blob([bytes], {type: this.saveMimeType}));
  172. }
  173. });
  174. });
  175. }
  176. /**
  177. * Retrieves the image data representation of the current image.
  178. *
  179. * @return {Promise} A promise that resolves with the image data.
  180. */
  181. getImageEditorImageData() {
  182. return this.history_[this.historyIndex_].getImageData();
  183. }
  184. /**
  185. * Returns a list of all possible image editor capabilities.
  186. *
  187. * @return {Array<{Object}>}
  188. */
  189. getPossibleControls() {
  190. return this.imageEditorCapabilities.tools.reduce(
  191. (prev, curr) => prev.concat(curr.controls),
  192. []
  193. );
  194. }
  195. /**
  196. * Normalizes different MIME types to the most similar MIME type available
  197. * to canvas implementations.
  198. *
  199. * @param {String} mimeType The original MIME type.
  200. * @return {String} The normalized MIME type.
  201. * @see http://kangax.github.io/jstests/toDataUrl_mime_type_test/
  202. */
  203. normalizeCanvasMimeType_(mimeType) {
  204. mimeType = mimeType.toLowerCase();
  205. return mimeType.replace('jpg', 'jpeg');
  206. }
  207. /**
  208. * Notifies the opener app of the result of the save action.
  209. *
  210. * @param {Object} result The server response to the save action.
  211. * @protected
  212. */
  213. notifySaveResult_(result) {
  214. this.components.loading.show = false;
  215. if (result && result.success) {
  216. Liferay.Util.getOpener().Liferay.fire(this.saveEventName, {
  217. data: result,
  218. });
  219. Liferay.Util.getWindow().hide();
  220. }
  221. else if (result.error) {
  222. this.showError_(result.error.message);
  223. }
  224. }
  225. /**
  226. * Updates the image back to a previously undone state in the history.
  227. * Redoing an action recovers the undone image changes and enables the
  228. * undo stack in case the user wants to undo the changes again.
  229. */
  230. redo() {
  231. this.historyIndex_++;
  232. this.syncHistory_();
  233. }
  234. /**
  235. * Selects a control and starts its editing phase with filters.
  236. *
  237. * @param {MouseEvent} event The mouse event.
  238. */
  239. requestImageEditorEditFilters(event) {
  240. const controls = this.getPossibleControls();
  241. const target = event.delegateTarget || event.currentTarget;
  242. const targetControl = target.getAttribute('data-control');
  243. const targetTool = target.getAttribute('data-tool');
  244. this.syncHistory_().then(() => {
  245. this.selectedControl = controls.filter(
  246. (tool) => tool.variant === targetControl
  247. )[0];
  248. this.selectedTool = targetTool;
  249. });
  250. }
  251. /**
  252. * Select a control and starts its editing phase.
  253. *
  254. * @param {MouseEvent} event The mouse event.
  255. */
  256. requestImageEditorEdit(event) {
  257. const controls = this.getPossibleControls();
  258. const target = event.target.element;
  259. const targetControl = event.data.item.variant;
  260. const targetTool = target.getAttribute('data-tool');
  261. this.syncHistory_().then(() => {
  262. this.selectedControl = controls.filter(
  263. (tool) => tool.variant === targetControl
  264. )[0];
  265. this.selectedTool = targetTool;
  266. });
  267. }
  268. /**
  269. * Queues a request for a preview process of the current image by the
  270. * currently selected control.
  271. */
  272. requestImageEditorPreview() {
  273. const selectedControl = this.components[
  274. this.id + '_selected_control_' + this.selectedControl.variant
  275. ];
  276. this.history_[this.historyIndex_]
  277. .getImageData()
  278. .then((imageData) => selectedControl.preview(imageData))
  279. .then((imageData) => this.syncImageData_(imageData));
  280. this.components.loading.show = true;
  281. }
  282. /**
  283. * Discards all changes and restores the original state of the image.
  284. * Unlike the {@link ImageEditor#undo|undo} and
  285. * {@link ImageEditor#redo|redo} methods, this method wipes out the entire
  286. * history.
  287. */
  288. reset() {
  289. this.historyIndex_ = 0;
  290. this.history_.length = 1;
  291. this.syncHistory_();
  292. }
  293. /**
  294. * Tries to save the current image using the provided save URL.
  295. * @param {MouseEvent} event The mouse event that triggers the save action.
  296. * @protected
  297. */
  298. save_(event) {
  299. if (!event.delegateTarget.disabled) {
  300. this.getImageEditorImageBlob()
  301. .then((imageBlob) => this.submitBlob_(imageBlob))
  302. .then((result) => this.notifySaveResult_(result))
  303. .catch((error) => this.showError_(error));
  304. }
  305. }
  306. /**
  307. * Setter function for the <code>saveMimeType</code> state key.
  308. * @param {!String} saveMimeType The optional value for the attribute.
  309. * @protected
  310. * @return {String} The computed value for the attribute.
  311. */
  312. setterSaveMimeTypeFn_(saveMimeType) {
  313. if (!saveMimeType) {
  314. const imageExtensionRegex = /https?:\/\/(?:www\.)?(?:.+)\.(\w+)\/[^?/]+/;
  315. const imageExtension = this.image.match(imageExtensionRegex)[1];
  316. saveMimeType = `image/${imageExtension}`;
  317. }
  318. return this.normalizeCanvasMimeType_(saveMimeType);
  319. }
  320. /**
  321. * Displays an error message in the editor.
  322. * @param {String} message The error message to display.
  323. * @protected
  324. */
  325. showError_({message}) {
  326. this.components.loading.show = false;
  327. Liferay.Util.openToast({
  328. container: this.element,
  329. message,
  330. type: 'danger',
  331. });
  332. }
  333. /**
  334. * Sends a given image blob to the server for processing and storing.
  335. * @param {Blob} imageBlob The image blob to send to the server.
  336. * @protected
  337. * @return {Promise} A promise that follows the XHR submission
  338. * process.
  339. */
  340. submitBlob_(imageBlob) {
  341. const saveFileName = this.saveFileName;
  342. const saveParamName = this.saveParamName;
  343. const saveFileEntryId = this.saveFileEntryId;
  344. const promise = new Promise((resolve, reject) => {
  345. const formData = new FormData();
  346. formData.append(saveParamName, imageBlob, saveFileName);
  347. formData.append('fileEntryId', saveFileEntryId);
  348. this.fetch(this.saveURL, formData)
  349. .then((response) => response.json())
  350. .then(resolve)
  351. .catch((error) => reject(error));
  352. });
  353. this.components.loading.show = true;
  354. return promise;
  355. }
  356. /**
  357. * Syncs the image and history values after changes to the history stack.
  358. * @protected
  359. */
  360. syncHistory_() {
  361. return new Promise((resolve) => {
  362. this.history_[this.historyIndex_]
  363. .getImageData()
  364. .then((imageData) => {
  365. this.syncImageData_(imageData);
  366. this.history = {
  367. canRedo: this.historyIndex_ < this.history_.length - 1,
  368. canReset: this.history_.length > 1,
  369. canUndo: this.historyIndex_ > 0,
  370. };
  371. resolve();
  372. });
  373. });
  374. }
  375. /**
  376. * Updates the image data displayed in the editable area.
  377. * @param {ImageData} imageData The new image data value to display in the
  378. * editor.
  379. * @protected
  380. */
  381. syncImageData_(imageData) {
  382. const width = imageData.width;
  383. const height = imageData.height;
  384. const aspectRatio = width / height;
  385. const offscreenCanvas = document.createElement('canvas');
  386. offscreenCanvas.width = width;
  387. offscreenCanvas.height = height;
  388. const offscreenContext = offscreenCanvas.getContext('2d');
  389. offscreenContext.clearRect(0, 0, width, height);
  390. offscreenContext.putImageData(imageData, 0, 0);
  391. const canvas = this.getImageEditorCanvas();
  392. const boundingBox = dom.closest(this.element, '.portlet-layout');
  393. const availableWidth = boundingBox.offsetWidth;
  394. let dialogFooterHeight = 0;
  395. const dialogFooter = this.element.querySelector('.dialog-footer');
  396. if (dialogFooter) {
  397. dialogFooterHeight = dialogFooter.offsetHeight;
  398. }
  399. const availableHeight =
  400. boundingBox.offsetHeight - 142 - 40 - dialogFooterHeight;
  401. const availableAspectRatio = availableWidth / availableHeight;
  402. if (availableAspectRatio > 1) {
  403. canvas.height = availableHeight;
  404. canvas.width = aspectRatio * availableHeight;
  405. }
  406. else {
  407. canvas.width = availableWidth;
  408. canvas.height = availableWidth / aspectRatio;
  409. }
  410. const context = canvas.getContext('2d');
  411. context.clearRect(0, 0, canvas.width, canvas.height);
  412. context.drawImage(
  413. offscreenCanvas,
  414. 0,
  415. 0,
  416. width,
  417. height,
  418. 0,
  419. 0,
  420. canvas.width,
  421. canvas.height
  422. );
  423. canvas.style.width = canvas.width + 'px';
  424. canvas.style.height = canvas.height + 'px';
  425. this.components.loading.show = false;
  426. }
  427. /**
  428. * Reverts the image to the previous state in the history. Undoing an action
  429. * brings back the previous version of the image and enables the redo stack
  430. * in case the user wants to reapply the change again.
  431. */
  432. undo() {
  433. this.historyIndex_--;
  434. this.syncHistory_();
  435. }
  436. }
  437. /**
  438. * State definition.
  439. * @static
  440. * @type {!Object}
  441. */
  442. ImageEditor.STATE = {
  443. /**
  444. * Whether the editor is ready for user interaction.
  445. * @type {Object}
  446. */
  447. imageEditorReady: {
  448. validator: core.isBoolean,
  449. value: false,
  450. },
  451. /**
  452. * Event to dispatch when editing is complete.
  453. * @type {String}
  454. */
  455. saveEventName: {
  456. validator: core.isString,
  457. },
  458. /**
  459. * ID of the saved image to send to the server for the save action.
  460. * @type {String}
  461. */
  462. saveFileEntryId: {
  463. validator: core.isString,
  464. },
  465. /**
  466. * Name of the saved image to send to the server for the save action.
  467. * @type {String}
  468. */
  469. saveFileName: {
  470. validator: core.isString,
  471. },
  472. /**
  473. * MIME type of the saved image. If not explicitly set, the image MIME type
  474. * is inferred from the image URL.
  475. * @type {String}
  476. */
  477. saveMimeType: {
  478. setter: 'setterSaveMimeTypeFn_',
  479. validator: core.isString,
  480. },
  481. /**
  482. * Name of the param that specifies where to send the image to the server
  483. * for the save action.
  484. * @type {String}
  485. */
  486. saveParamName: {
  487. validator: core.isString,
  488. },
  489. /**
  490. * URL to save the image changes.
  491. * @type {String}
  492. */
  493. saveURL: {
  494. validator: core.isString,
  495. },
  496. };
  497. Soy.register(ImageEditor, templates);
  498. export default ImageEditor;