956aaa65402d39eb29df5a79d54946467fff831c
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Private / TypeScript / ImageManipulation.ts
1 /*
2 * This file is part of the TYPO3 CMS project.
3 *
4 * It is free software; you can redistribute it and/or modify it under
5 * the terms of the GNU General Public License, either version 2
6 * of the License, or any later version.
7 *
8 * For the full copyright and license information, please read the
9 * LICENSE.txt file that was distributed with this source code.
10 *
11 * The TYPO3 project - inspiring people to share!
12 */
13
14 /// <amd-dependency path='TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min' name='ImagesLoaded'>
15 /// <amd-dependency path='TYPO3/CMS/Backend/Modal' name='Modal'>
16 /// <amd-dependency path='TYPO3/CMS/Backend/Severity' name='Severity'>
17
18 import $ = require('jquery');
19 import 'jquery-ui/draggable';
20 import 'jquery-ui/resizable';
21 declare const Modal: any;
22 declare const Severity: any;
23 declare const ImagesLoaded: any;
24
25 declare global {
26 interface Window {
27 TYPO3: any;
28 }
29 }
30
31 type Area = {
32 x: number;
33 y: number;
34 height: number;
35 width: number;
36 }
37
38 type Ratio = {
39 id: string;
40 title: string;
41 value: number;
42 }
43
44 type CropVariant = {
45 title: string;
46 id: string;
47 selectedRatio: string;
48 cropArea?: Area;
49 focusArea?: Area;
50 coverAreas?: Area[];
51 allowedAspectRatios: Ratio[];
52 }
53
54 type Offset = {
55 left: number;
56 top: number;
57 };
58
59 interface CropperEvent {
60 x: number;
61 y: number;
62 width: number;
63 height: number;
64 rotate: number;
65 scaleX: number;
66 scaleY: number;
67 }
68
69 interface CropperImageData {
70 left: number;
71 top: number;
72 width: number;
73 height: number;
74 naturalWidth: number;
75 naturalHeight: number;
76 aspectRatio: number;
77 rotate: number;
78 scaleX: number;
79 scaleY: number;
80 }
81
82 /**
83 * Module: TYPO3/CMS/Backend/ImageManipulation
84 * Contains all logic for the image crop GUI including setting focusAreas
85 * @exports TYPO3/CMS/Backend/ImageManipulation
86 */
87 class ImageManipulation {
88 /**
89 * @method isCropAreaEmpty
90 * @desc Checks if an area is set or pristine
91 * @param {Area} area - The area to check
92 * @return {boolean}
93 * @static
94 */
95 public static isEmptyArea(area: Area): boolean {
96 return $.isEmptyObject(area);
97 }
98
99 /**
100 * @method wait
101 * @desc window.setTimeout shim
102 * @param {Function} fn - The function to execute
103 * @param {number} ms - The time in [ms] to wait until execution
104 * @return {boolean}
105 * @public
106 * @static
107 */
108 public static wait(fn: Function, ms: number): void {
109 window.setTimeout(fn, ms);
110 }
111
112 /**
113 * @method toCssPercent
114 * @desc Takes a number, and converts it to CSS percentage length
115 * @param {number} num - The number to convert
116 * @return {string}
117 * @public
118 * @static
119 */
120 public static toCssPercent(num: number): string {
121 return `${num * 100}%`;
122 }
123
124 /**
125 * @method serializeCropVariants
126 * @desc Serializes crop variants for persistence or preview
127 * @param {Object} cropVariants
128 * @returns string
129 */
130 private static serializeCropVariants(cropVariants: Object): string {
131 const omitUnused: any = (key: any, value: any): any =>
132 (
133 key === 'id'
134 || key === 'title'
135 || key === 'allowedAspectRatios'
136 || key === 'coverAreas'
137 ) ? undefined : value;
138
139 return JSON.stringify(cropVariants, omitUnused);
140 }
141
142 private trigger: JQuery;
143 private currentModal: JQuery;
144 private cropVariantTriggers: JQuery;
145 private activeCropVariantTrigger: JQuery;
146 private saveButton: JQuery;
147 private previewButton: JQuery;
148 private dismissButton: JQuery;
149 private resetButton: JQuery;
150 private aspectRatioTrigger: JQuery;
151 private cropperCanvas: JQuery;
152 private cropInfo: JQuery;
153 private cropImageContainerSelector: string = '#t3js-crop-image-container';
154 private cropImageSelector: string = '#t3js-crop-image';
155 private coverAreaSelector: string = '.t3js-cropper-cover-area';
156 private cropInfoSelector: string = '.t3js-cropper-info-crop';
157 private focusAreaSelector: string = '#t3js-cropper-focus-area';
158 private focusArea: any;
159 private cropBox: JQuery;
160 private cropper: any;
161 private currentCropVariant: CropVariant;
162 private data: Object;
163 private defaultFocusArea: Area = {
164 height: 1 / 3,
165 width: 1 / 3,
166 x: 0,
167 y: 0,
168 };
169 private defaultOpts: Object = {
170 autoCrop: true,
171 autoCropArea: '0.7',
172 dragMode: 'crop',
173 guides: true,
174 responsive: true,
175 viewMode: 1,
176 zoomable: false,
177 };
178 private resizeTimeout: number = 450;
179
180 constructor() {
181 // Silence is golden
182 $(window).resize((): void => {
183 if (this.cropper) {
184 this.cropper.cropper('destroy');
185 }
186 });
187 this.resizeEnd((): void => {
188 if (this.cropper) {
189 this.init();
190 }
191 });
192 }
193
194 /**
195 * @method initializeTrigger
196 * @desc Assign a handler to .t3js-image-manipulation-trigger.
197 * Show the modal and kick-off image manipulation
198 * @public
199 */
200 public initializeTrigger(): void {
201 const triggerHandler: Function = (e: JQueryEventObject): void => {
202 e.preventDefault();
203 this.trigger = $(e.currentTarget);
204 this.show();
205 };
206 $('.t3js-image-manipulation-trigger').off('click').click(triggerHandler);
207 }
208
209 /**
210 * @method initializeCropperModal
211 * @desc Initialize the cropper modal and dispatch the cropper init
212 * @private
213 */
214 private initializeCropperModal(): void {
215 const image: JQuery = this.currentModal.find(this.cropImageSelector);
216 ImagesLoaded(image, (): void => {
217 const modal: JQuery = this.currentModal.find('.modal-dialog');
218 modal.css({marginLeft: 'auto', marginRight: 'auto'});
219 modal.addClass('modal-image-manipulation modal-resize');
220 Modal.center();
221 setTimeout((): void => {
222 this.init();
223 }, 100);
224 });
225 }
226
227 /**
228 * @method show
229 * @desc Load the image and setup the modal UI
230 * @private
231 */
232 private show(): void {
233 const modalTitle: string = this.trigger.data('modalTitle');
234 const imageUri: string = this.trigger.data('url');
235 const initCropperModal: Function = this.initializeCropperModal.bind(this);
236
237 /**
238 * Open modal with image to crop
239 */
240 this.currentModal = Modal.loadUrl(
241 modalTitle,
242 Severity.notice,
243 [],
244 imageUri,
245 initCropperModal,
246 '.modal-content'
247 );
248 this.currentModal.addClass('modal-dark');
249 this.currentModal.on('hide.bs.modal', (e: JQueryEventObject): void => {
250 this.destroy();
251 });
252 // Do not dismiss the modal when clicking beside it to avoid data loss
253 this.currentModal.data('bs.modal').options.backdrop = 'static';
254 }
255
256 /**
257 * @method init
258 * @desc Initializes the cropper UI and sets up all the event indings for the UI
259 * @private
260 */
261 private init(): void {
262 const image: JQuery = this.currentModal.find(this.cropImageSelector);
263 const imageHeight: number = $(image).height();
264 const imageWidth: number = $(image).width();
265 const data: string = this.trigger.attr('data-crop-variants');
266
267 if (!data) {
268 throw new TypeError('ImageManipulation: No cropVariants data found for image');
269 }
270
271 // If we have data already set we assume an internal reinit eg. after resizing
272 this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
273 // Initialize our class members
274 this.currentModal.find(this.cropImageContainerSelector).css({height: imageHeight, width: imageWidth});
275 this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
276 this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
277 this.cropInfo = this.currentModal.find(this.cropInfoSelector);
278 this.saveButton = this.currentModal.find('[data-method=save]');
279 this.previewButton = this.currentModal.find('[data-method=preview]');
280 this.dismissButton = this.currentModal.find('[data-method=dismiss]');
281 this.resetButton = this.currentModal.find('[data-method=reset]');
282 this.cropperCanvas = this.currentModal.find('#js-crop-canvas');
283 this.aspectRatioTrigger = this.currentModal.find('[data-method=setAspectRatio]');
284 this.currentCropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
285
286 /**
287 * Assign EventListener to cropVariantTriggers
288 */
289 this.cropVariantTriggers.off('click').on('click', (e: JQueryEventObject): void => {
290
291 /**
292 * Is the current cropVariantTrigger is active, bail out.
293 * Bootstrap doesn't provide this functionality when collapsing the Collaps panels
294 */
295 if ($(e.currentTarget).hasClass('is-active')) {
296 e.stopPropagation();
297 e.preventDefault();
298 return;
299 }
300
301 this.activeCropVariantTrigger.removeClass('is-active');
302 $(e.currentTarget).addClass('is-active');
303 this.activeCropVariantTrigger = $(e.currentTarget);
304 let cropVariant: CropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
305 const imageData: CropperImageData = this.cropper.cropper('getImageData');
306 cropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
307 this.currentCropVariant = $.extend(true, {}, cropVariant);
308 this.update(cropVariant);
309 });
310
311 /**
312 * Assign EventListener to aspectRatioTrigger
313 */
314 this.aspectRatioTrigger.off('click').on('click', (e: JQueryEventObject): void => {
315 const ratioId: string = $(e.currentTarget).attr('data-option');
316 const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
317 const ratio: Ratio = temp.allowedAspectRatios[ratioId];
318 this.setAspectRatio(ratio);
319 // Set data explicitly or setAspectRatio upscales the crop
320 this.setCropArea(temp.cropArea);
321 this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
322 this.update(this.currentCropVariant);
323 });
324
325 /**
326 * Assign EventListener to saveButton
327 */
328 this.saveButton.off('click').on('click', (): void => {
329 this.save(this.data);
330 });
331
332 /**
333 * Assign EventListener to previewButton if preview url exists
334 */
335 if (this.trigger.attr('data-preview-url')) {
336 this.previewButton.off('click').on('click', (): void => {
337 this.openPreview(this.data);
338 });
339 } else {
340 this.previewButton.hide();
341 }
342
343 /**
344 * Assign EventListener to dismissButton
345 */
346 this.dismissButton.off('click').on('click', (): void => {
347 this.currentModal.modal('hide');
348 });
349
350 /**
351 * Assign EventListener to resetButton
352 */
353 this.resetButton.off('click').on('click', (e: JQueryEventObject): void => {
354 const imageData: CropperImageData = this.cropper.cropper('getImageData');
355 const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
356 e.preventDefault();
357 e.stopPropagation();
358 if (!resetCropVariantString) {
359 throw new TypeError('TYPO3 Cropper: No cropVariant data attribute found on reset element.');
360 }
361 const resetCropVariant: CropVariant = JSON.parse(resetCropVariantString);
362 const absoluteCropArea: Area = this.convertRelativeToAbsoluteCropArea(resetCropVariant.cropArea, imageData);
363 this.currentCropVariant = $.extend(true, {}, resetCropVariant, {cropArea: absoluteCropArea});
364 this.update(this.currentCropVariant);
365 });
366
367 // If we start without an cropArea, maximize the cropper
368 if (ImageManipulation.isEmptyArea(this.currentCropVariant.cropArea)) {
369 this.defaultOpts = $.extend({
370 autoCropArea: 1,
371 }, this.defaultOpts);
372 }
373
374 /**
375 * Initialise the cropper
376 *
377 * Note: We use the extraneous jQuery object here, as CropperJS won't work inside the <iframe>
378 * The top.require is now inlined @see ImageManipulationElemen.php:143
379 * TODO: Find a better solution for cross iframe communications
380 */
381 this.cropper = (<any> top.TYPO3.jQuery(image)).cropper($.extend(this.defaultOpts, {
382 built: this.cropBuiltHandler,
383 crop: this.cropMoveHandler,
384 cropend: this.cropEndHandler,
385 cropstart: this.cropStartHandler,
386 data: this.currentCropVariant.cropArea,
387 }));
388 }
389
390 /**
391 * @method cropBuiltHandler
392 * @desc Internal cropper handler. Called when the cropper has been instantiated
393 * @private
394 */
395 private cropBuiltHandler = (): void => {
396 const imageData: CropperImageData = this.cropper.cropper('getImageData');
397
398 // Iterate over the crop variants and set up their respective preview
399 this.cropVariantTriggers.each((index: number, elem: Element): void => {
400 const cropVariantId: string = $(elem).attr('data-crop-variant-id');
401 const cropArea: Area = this.convertRelativeToAbsoluteCropArea(
402 this.data[cropVariantId].cropArea,
403 imageData
404 );
405 const variant: CropVariant = $.extend(true, {}, this.data[cropVariantId], {cropArea});
406 this.updatePreviewThumbnail(variant, $(elem));
407 });
408
409 this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
410 this.currentCropVariant.cropArea,
411 imageData
412 );
413 // Can't use .t3js-* as selector because it is an extraneous selector
414 this.cropBox = this.currentModal.find('.cropper-crop-box');
415
416 this.setCropArea(this.currentCropVariant.cropArea);
417
418 // Check if new cropVariant has coverAreas
419 if (this.currentCropVariant.coverAreas) {
420 // Init or reinit focusArea
421 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
422 }
423 // Check if new cropVariant has focusArea
424 if (this.currentCropVariant.focusArea) {
425 // Init or reinit focusArea
426 if (ImageManipulation.isEmptyArea(this.currentCropVariant.focusArea)) {
427 // If an empty focusArea is set initialise it with the default
428 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
429 }
430 this.initFocusArea(this.cropBox);
431 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
432 }
433
434 if (this.currentCropVariant.selectedRatio) {
435 this.setAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
436 // Set data explicitly or setAspectRatio up-scales the crop
437 this.setCropArea(this.currentCropVariant.cropArea);
438 this.currentModal.find(`[data-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
439 }
440
441 this.cropperCanvas.addClass('is-visible');
442 };
443
444 /**
445 * @method cropMoveHandler
446 * @desc Internal cropper handler. Called when the cropping area is moving
447 * @private
448 */
449 private cropMoveHandler = (e: CropperEvent): void => {
450 this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
451 height: Math.floor(e.height),
452 width: Math.floor(e.width),
453 x: Math.floor(e.x),
454 y: Math.floor(e.y),
455 });
456 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
457 this.updateCropVariantData(this.currentCropVariant);
458 this.cropInfo.text(`${this.currentCropVariant.cropArea.width}×${this.currentCropVariant.cropArea.height} px`);
459 };
460
461 /**
462 * @method cropStartHandler
463 * @desc Internal cropper handler. Called when the cropping starts moving
464 * @private
465 */
466 private cropStartHandler = (): void => {
467 if (this.currentCropVariant.focusArea) {
468 this.focusArea.draggable('option', 'disabled', true);
469 this.focusArea.resizable('option', 'disabled', true);
470 }
471 };
472
473 /**
474 * @method cropEndHandler
475 * @desc Internal cropper handler. Called when the cropping ends moving
476 * @private
477 */
478 private cropEndHandler = (): void => {
479 if (this.currentCropVariant.focusArea) {
480 this.focusArea.draggable('option', 'disabled', false);
481 this.focusArea.resizable('option', 'disabled', false);
482 }
483 };
484
485 /**
486 * @method update
487 * @desc Update current cropArea position and size when changing cropVariants
488 * @param {CropVariant} cropVariant - The new cropVariant to update the UI with
489 */
490 private update(cropVariant: CropVariant): void {
491 const temp: CropVariant = $.extend(true, {}, cropVariant);
492 const selectedRatio: Ratio = cropVariant.allowedAspectRatios[cropVariant.selectedRatio];
493 this.currentModal.find('[data-option]').removeClass('active');
494 this.currentModal.find(`[data-option="${cropVariant.selectedRatio}"]`).addClass('active');
495 /**
496 * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
497 */
498 this.setAspectRatio(selectedRatio);
499 this.setCropArea(temp.cropArea);
500 this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
501 this.cropBox.find(this.coverAreaSelector).remove();
502
503 // If the current container has a focus area element, deregister and cleanup prior to initialization
504 if (this.cropBox.has(this.focusAreaSelector).length) {
505 this.focusArea.resizable('destroy').draggable('destroy');
506 this.focusArea.remove();
507 }
508
509 // Check if new cropVariant has focusArea
510 if (cropVariant.focusArea) {
511 // Init or reinit focusArea
512 if (ImageManipulation.isEmptyArea(cropVariant.focusArea)) {
513 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
514 }
515 this.initFocusArea(this.cropBox);
516 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
517 }
518
519 // Check if new cropVariant has coverAreas
520 if (cropVariant.coverAreas) {
521 // Init or reinit focusArea
522 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
523 }
524 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
525 }
526
527 /**
528 * @method initFocusArea
529 * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
530 * @param {JQuery} container
531 * @private
532 */
533 private initFocusArea(container: JQuery): void {
534 this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
535 container.append(this.focusArea);
536 this.focusArea
537 .draggable({
538 containment: container,
539 create: (): void => {
540 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
541 },
542 drag: (): void => {
543 const {left, top}: Offset = container.offset();
544 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
545 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
546
547 focusArea.x = (fLeft - left) / container.width();
548 focusArea.y = (fTop - top) / container.height();
549 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
550 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
551 this.focusArea.addClass('has-nodrop');
552 } else {
553 this.focusArea.removeClass('has-nodrop');
554 }
555 },
556 revert: (): boolean => {
557 const revertDelay: number = 250;
558 const {left, top}: Offset = container.offset();
559 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
560 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
561
562 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
563 this.focusArea.removeClass('has-nodrop');
564 ImageManipulation.wait((): void => {
565 focusArea.x = (fLeft - left) / container.width();
566 focusArea.y = (fTop - top) / container.height();
567 this.updateCropVariantData(this.currentCropVariant);
568 }, revertDelay);
569 return true;
570 }
571 },
572 revertDuration: 200,
573 stop: (): void => {
574 const {left, top}: Offset = container.offset();
575 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
576 const {focusArea}: {focusArea?: Area} = this.currentCropVariant;
577
578 focusArea.x = (fLeft - left) / container.width();
579 focusArea.y = (fTop - top) / container.height();
580
581 this.scaleAndMoveFocusArea(focusArea);
582 },
583 })
584 .resizable({
585 containment: container,
586 handles: 'all',
587 resize: (): void => {
588 const {left, top}: Offset = container.offset();
589 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
590 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
591
592 focusArea.height = this.focusArea.height() / container.height();
593 focusArea.width = this.focusArea.width() / container.width();
594 focusArea.x = (fLeft - left) / container.width();
595 focusArea.y = (fTop - top) / container.height();
596 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
597
598 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
599 this.focusArea.addClass('has-nodrop');
600 } else {
601 this.focusArea.removeClass('has-nodrop');
602 }
603
604 },
605 stop: (event: any, ui: any): void => {
606 const revertDelay: number = 250;
607 const {left, top}: Offset = container.offset();
608 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
609 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
610
611 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
612 ui.element.animate($.extend(ui.originalPosition, ui.originalSize), revertDelay, (): void => {
613
614 focusArea.height = this.focusArea.height() / container.height();
615 focusArea.height = this.focusArea.height() / container.height();
616 focusArea.width = this.focusArea.width() / container.width();
617 focusArea.x = (fLeft - left) / container.width();
618 focusArea.y = (fTop - top) / container.height();
619
620 this.scaleAndMoveFocusArea(focusArea);
621 this.focusArea.removeClass('has-nodrop');
622 });
623 } else {
624 this.scaleAndMoveFocusArea(focusArea);
625 }
626 },
627 });
628 }
629
630 /**
631 * @method initCoverAreas
632 * @desc Initialise cover areas inside the cropper container
633 * @param {JQuery} container - The container element to append the cover areas
634 * @param {Array<Area>} coverAreas - An array of areas to construxt the cover area elements from
635 */
636 private initCoverAreas(container: JQuery, coverAreas: Area[]): void {
637 coverAreas.forEach((coverArea: Area): void => {
638 let coverAreaCanvas: JQuery = $('<div class="cropper-cover-area t3js-cropper-cover-area"></div>');
639 container.append(coverAreaCanvas);
640 coverAreaCanvas.css({
641 height: ImageManipulation.toCssPercent(coverArea.height),
642 left: ImageManipulation.toCssPercent(coverArea.x),
643 top: ImageManipulation.toCssPercent(coverArea.y),
644 width: ImageManipulation.toCssPercent(coverArea.width),
645 });
646 });
647 }
648
649 /**
650 * @method updatePreviewThumbnail
651 * @desc Sync the croping (and focus area) to the preview thumbnail
652 * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
653 * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
654 * @private
655 */
656 private updatePreviewThumbnail(cropVariant: CropVariant, cropVariantTrigger: JQuery): void {
657 let styles: any;
658 const cropperPreviewThumbnailCrop: JQuery =
659 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
660 const cropperPreviewThumbnailImage: JQuery =
661 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
662 const cropperPreviewThumbnailFocus: JQuery =
663 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
664 const imageData: CropperImageData = this.cropper.cropper('getImageData');
665
666 // Update the position/dimension of the crop area in the preview
667 cropperPreviewThumbnailCrop.css({
668 height: ImageManipulation.toCssPercent(cropVariant.cropArea.height / imageData.naturalHeight),
669 left: ImageManipulation.toCssPercent(cropVariant.cropArea.x / imageData.naturalWidth),
670 top: ImageManipulation.toCssPercent(cropVariant.cropArea.y / imageData.naturalHeight),
671 width: ImageManipulation.toCssPercent(cropVariant.cropArea.width / imageData.naturalWidth),
672 });
673
674 // Show and update focusArea in the preview only if we really have one configured
675 if (cropVariant.focusArea) {
676 cropperPreviewThumbnailFocus.css({
677 height: ImageManipulation.toCssPercent(cropVariant.focusArea.height),
678 left: ImageManipulation.toCssPercent(cropVariant.focusArea.x),
679 top: ImageManipulation.toCssPercent(cropVariant.focusArea.y),
680 width: ImageManipulation.toCssPercent(cropVariant.focusArea.width),
681 });
682 }
683
684 // Destruct the preview container's CSS properties
685 styles = cropperPreviewThumbnailCrop.css([
686 'width', 'height', 'left', 'top',
687 ]);
688
689 /**
690 * Apply negative margins on the previewThumbnailImage to make the illusion of an offset
691 */
692 cropperPreviewThumbnailImage.css({
693 height: `${parseFloat(styles.height) * (1 / (cropVariant.cropArea.height / imageData.naturalHeight))}px`,
694 margin: `${-1 * parseFloat(styles.left)}px`,
695 marginTop: `${-1 * parseFloat(styles.top)}px`,
696 width: `${parseFloat(styles.width) * (1 / (cropVariant.cropArea.width / imageData.naturalWidth))}px`,
697 });
698 }
699
700 /**
701 * @method scaleAndMoveFocusArea
702 * @desc Calculation logic for moving the focus area given the
703 * specified constrains of a crop and an optional cover area
704 * @param {Area} focusArea - The translation data
705 */
706 private scaleAndMoveFocusArea(focusArea: Area): void {
707 this.focusArea.css({
708 height: ImageManipulation.toCssPercent(focusArea.height),
709 left: ImageManipulation.toCssPercent(focusArea.x),
710 top: ImageManipulation.toCssPercent(focusArea.y),
711 width: ImageManipulation.toCssPercent(focusArea.width),
712 });
713 this.currentCropVariant.focusArea = focusArea;
714 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
715 this.updateCropVariantData(this.currentCropVariant);
716 }
717
718 /**
719 * @method updateCropVariantData
720 * @desc Immutably updates the currently selected cropVariant data
721 * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
722 * @private
723 */
724 private updateCropVariantData(currentCropVariant: CropVariant): void {
725 const imageData: CropperImageData = this.cropper.cropper('getImageData');
726 const absoluteCropArea: Area = this.convertAbsoluteToRelativeCropArea(currentCropVariant.cropArea, imageData);
727 this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, {cropArea: absoluteCropArea});
728 }
729
730 /**
731 * @method setAspectRatio
732 * @desc Sets the cropper to a specific ratio
733 * @param {ratio} ratio - The ratio value to apply
734 * @private
735 */
736 private setAspectRatio(ratio: Ratio): void {
737 this.cropper.cropper('setAspectRatio', ratio.value);
738 }
739
740 /**
741 * @method setCropArea
742 * @desc Sets the cropper to a specific crop area
743 * @param {cropArea} cropArea - The crop area to apply
744 * @private
745 */
746 private setCropArea(cropArea: Area): void {
747 const currentRatio: Ratio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
748 if (currentRatio.value === 0) {
749 this.cropper.cropper('setData', {
750 height: cropArea.height,
751 width: cropArea.width,
752 x: cropArea.x,
753 y: cropArea.y,
754 });
755 } else {
756 this.cropper.cropper('setData', {
757 height: cropArea.height,
758 x: cropArea.x,
759 y: cropArea.y,
760 });
761 }
762 }
763
764 /**
765 * @method checkFocusAndCoverAreas
766 * @desc Checks is one focus area and one or more cover areas overlap
767 * @param focusArea
768 * @param coverAreas
769 * @return {boolean}
770 */
771 private checkFocusAndCoverAreasCollision(focusArea: Area, coverAreas: Area[]): boolean {
772 return coverAreas
773 .some((coverArea: Area): boolean => {
774 // noinspection OverlyComplexBooleanExpressionJS
775 if (focusArea.x < coverArea.x + coverArea.width &&
776 focusArea.x + focusArea.width > coverArea.x &&
777 focusArea.y < coverArea.y + coverArea.height &&
778 focusArea.height + focusArea.y > coverArea.y) {
779 return true;
780 }
781 });
782 }
783
784 /**
785 * @method convertAbsoluteToRelativeCropArea
786 * @desc Converts a crop area from absolute pixel-based into relative length values
787 * @param {Area} cropArea - The crop area to convert from
788 * @param {CropperImageData} imageData - The image data
789 * @return {Area}
790 */
791 private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: CropperImageData): Area {
792 const {height, width, x, y}: Area = cropArea;
793 return {
794 height: height / imageData.naturalHeight,
795 width: width / imageData.naturalWidth,
796 x: x / imageData.naturalWidth,
797 y: y / imageData.naturalHeight,
798 };
799 }
800
801 /**
802 * @method convertRelativeToAbsoluteCropArea
803 * @desc Converts a crop area from relative into absolute pixel-based length values
804 * @param {Area} cropArea - The crop area to convert from
805 * @param {CropperImageData} imageData - The image data
806 * @return {{height: number, width: number, x: number, y: number}}
807 */
808 private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: CropperImageData): Area {
809 const {height, width, x, y}: Area = cropArea;
810 return {
811 height: height * imageData.naturalHeight,
812 width: width * imageData.naturalWidth,
813 x: x * imageData.naturalWidth,
814 y: y * imageData.naturalHeight,
815 };
816 }
817
818 /**
819 * @method setPreviewImages
820 * @desc Updates the preview images in the editing section with the respective crop variants
821 * @param {Object} data - The internal crop variants state
822 */
823 private setPreviewImages(data: Object): void {
824 let $image: any = this.cropper;
825 let imageData: CropperImageData = $image.cropper('getImageData');
826
827 // Iterate over the crop variants and set up their respective preview
828 Object.keys(data).forEach((cropVariantId: string) => {
829 const cropVariant: CropVariant = data[cropVariantId];
830 const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
831
832 let $preview: JQuery = this.trigger
833 .closest('.form-group')
834 .find(`.t3js-image-manipulation-preview[data-crop-variant-id="${cropVariantId}"]`);
835
836 if ($preview.length === 0) {
837 return;
838 }
839
840 let previewWidth: number = $preview.data('preview-width');
841 let previewHeight: number = $preview.data('preview-height');
842
843 // Adjust aspect ratio of preview width/height
844 let aspectRatio: number = cropData.width / cropData.height;
845 let tmpHeight: number = previewWidth / aspectRatio;
846 if (tmpHeight > previewHeight) {
847 previewWidth = previewHeight * aspectRatio;
848 } else {
849 previewHeight = tmpHeight;
850 }
851 // preview should never be up-scaled
852 if (previewWidth > cropData.width) {
853 previewWidth = cropData.width;
854 previewHeight = cropData.height;
855 }
856
857 let ratio: number = previewWidth / cropData.width;
858
859 let $viewBox: JQuery = $('<div />').html('<img src="' + $image.attr('src') + '">');
860 $viewBox.addClass('cropper-preview-container');
861 $preview.empty().append($viewBox);
862 $viewBox.wrap('<span class="thumbnail thumbnail-status"></span>');
863
864 $viewBox.width(previewWidth).height(previewHeight).find('img').css({
865 height: imageData.naturalHeight * ratio,
866 left: -cropData.x * ratio,
867 top: -cropData.y * ratio,
868 width: imageData.naturalWidth * ratio,
869 });
870 });
871 };
872
873 /**
874 * @method openPreview
875 * @desc Opens a preview view with the crop variants
876 * @param {object} data - The whole data object containing all the cropVariants
877 * @private
878 */
879 private openPreview(data: Object): void {
880 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
881 let previewUrl: string = this.trigger.attr('data-preview-url');
882 previewUrl = previewUrl + '&cropVariants=' + encodeURIComponent(cropVariants);
883 window.open(previewUrl, 'TYPO3ImageManipulationPreview');
884 }
885
886 /**
887 * @method save
888 * @desc Saves the edited cropVariants to a hidden field
889 * @param {object} data - The whole data object containing all the cropVariants
890 * @private
891 */
892 private save(data: Object): void {
893 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
894 const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
895 this.trigger.attr('data-crop-variants', JSON.stringify(data));
896 this.setPreviewImages(data);
897 hiddenField.val(cropVariants);
898 this.currentModal.modal('hide');
899 }
900
901 /**
902 * @method destroy
903 * @desc Destroy the ImageManipulation including cropper and alike
904 * @private
905 */
906 private destroy(): void {
907 if (this.currentModal) {
908 this.cropper.cropper('destroy');
909 this.cropper = null;
910 this.currentModal = null;
911 this.data = null;
912 }
913 }
914
915 /**
916 * @method resizeEnd
917 * @desc Calls a function when the cropper has been resized
918 * @param {Function} fn - The function to call on resize completion
919 * @private
920 */
921 private resizeEnd(fn: Function): void {
922 let timer: number;
923 $(window).on('resize', (): void => {
924 clearTimeout(timer);
925 timer = setTimeout((): void => {
926 fn();
927 }, this.resizeTimeout);
928 });
929 }
930 }
931
932 export = new ImageManipulation();