[BUGFIX] Fix broken acceptance tests
[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 coverAreaSelector: string = '.t3js-cropper-cover-area';
154 private cropInfoSelector: string = '.t3js-cropper-info-crop';
155 private focusAreaSelector: string = '#t3js-cropper-focus-area';
156 private focusArea: any;
157 private cropBox: JQuery;
158 private cropper: any;
159 private currentCropVariant: CropVariant;
160 private data: Object;
161 private defaultFocusArea: Area = {
162 height: 1 / 3,
163 width: 1 / 3,
164 x: 0,
165 y: 0,
166 };
167 private defaultOpts: Object = {
168 autoCrop: true,
169 autoCropArea: '0.7',
170 dragMode: 'crop',
171 guides: true,
172 responsive: true,
173 viewMode: 1,
174 zoomable: false,
175 };
176
177 constructor() {
178 // Silence is golden
179 $(window).resize((): void => {
180 if (this.cropper) {
181 this.cropper.cropper('destroy');
182 }
183 });
184 this.resizeEnd((): void => {
185 if (this.cropper) {
186 this.init();
187 }
188 });
189 }
190
191 /**
192 * @method initializeTrigger
193 * @desc Assign a handler to .t3js-image-manipulation-trigger.
194 * Show the modal and kick-off image manipulation
195 * @public
196 */
197 public initializeTrigger(): void {
198 const triggerHandler: Function = (e: JQueryEventObject): void => {
199 e.preventDefault();
200 this.trigger = $(e.currentTarget);
201 this.show();
202 };
203 $('.t3js-image-manipulation-trigger').off('click').click(triggerHandler);
204 }
205
206 /**
207 * Initialize the cropper modal
208 */
209 private initializeCropperModal(): void {
210 const image: JQuery = this.currentModal.find('#t3js-crop-image');
211 ImagesLoaded(image, (): void => {
212 const modal: JQuery = this.currentModal.find('.modal-dialog');
213 modal.css({marginLeft: 'auto', marginRight: 'auto'});
214 modal.addClass('modal-image-manipulation modal-resize');
215 Modal.center();
216 setTimeout((): void => {
217 this.init();
218 }, 100);
219 });
220 }
221
222 private show(): void {
223 const modalTitle: string = this.trigger.data('modalTitle');
224 const imageUri: string = this.trigger.data('url');
225 const initCropperModal: Function = this.initializeCropperModal.bind(this);
226
227 /**
228 * Open modal with image to crop
229 */
230 this.currentModal = Modal.loadUrl(
231 modalTitle,
232 Severity.notice,
233 [],
234 imageUri,
235 initCropperModal,
236 '.modal-content'
237 );
238 this.currentModal.addClass('modal-dark');
239 }
240
241 private init(): void {
242 const image: JQuery = this.currentModal.find('#t3js-crop-image');
243 const imageHeight: number = $(image).height();
244 const imageWidth: number = $(image).width();
245 const data: string = this.trigger.attr('data-crop-variants');
246
247 if (!data) {
248 throw new TypeError('ImageManipulation: No cropVariants data found for image');
249 }
250
251 // If we have data already set we assume an internal reinit eg. after resizing
252 this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
253 // Initialize our class members
254 this.currentModal.find('.cropper-image-container').css({height: imageHeight, width: imageWidth});
255 this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
256 this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
257 this.cropInfo = this.currentModal.find(this.cropInfoSelector);
258 this.saveButton = this.currentModal.find('[data-method=save]');
259 this.previewButton = this.currentModal.find('[data-method=preview]');
260 this.dismissButton = this.currentModal.find('[data-method=dismiss]');
261 this.resetButton = this.currentModal.find('[data-method=reset]');
262 this.cropperCanvas = this.currentModal.find('#js-crop-canvas');
263 this.aspectRatioTrigger = this.currentModal.find('[data-method=setAspectRatio]');
264 this.currentCropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
265
266 /**
267 * Assign EventListener to cropVariantTriggers
268 */
269 this.cropVariantTriggers.on('click', (e: JQueryEventObject): void => {
270
271 /**
272 * Is the current cropVariantTrigger is active, bail out.
273 * Bootstrap doesn't provide this functionality when collapsing the Collaps panels
274 */
275 if ($(e.currentTarget).hasClass('is-active')) {
276 e.stopPropagation();
277 e.preventDefault();
278 return;
279 }
280
281 this.activeCropVariantTrigger.removeClass('is-active');
282 $(e.currentTarget).addClass('is-active');
283 this.activeCropVariantTrigger = $(e.currentTarget);
284 let cropVariant: CropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
285 const imageData: CropperImageData = this.cropper.cropper('getImageData');
286 cropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
287 this.currentCropVariant = $.extend(true, {}, cropVariant);
288 this.update(cropVariant);
289 });
290
291 /**
292 * Assign EventListener to aspectRatioTrigger
293 */
294 this.aspectRatioTrigger.on('click', (e: JQueryEventObject): void => {
295 const ratioId: string = $(e.currentTarget).attr('data-option');
296 const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
297 const ratio: Ratio = temp.allowedAspectRatios[ratioId];
298 this.updateAspectRatio(ratio);
299 // Set data explicitly or updateAspectRatio upscales the crop
300 this.setCropArea(temp.cropArea);
301 this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
302 this.update(this.currentCropVariant);
303 });
304
305 /**
306 * Assign EventListener to saveButton
307 */
308 this.saveButton.on('click', (): void => {
309 this.save(this.data);
310 });
311
312 /**
313 * Assign EventListener to previewButton if preview url exists
314 */
315 if (this.trigger.attr('data-preview-url')) {
316 this.previewButton.on('click', (): void => {
317 this.openPreview(this.data);
318 });
319 } else {
320 this.previewButton.hide();
321 }
322
323 /**
324 * Assign EventListener to dismissButton
325 */
326 this.dismissButton.on('click', (): void => {
327 this.destroy();
328 });
329
330 /**
331 * Assign EventListener to resetButton
332 */
333 this.resetButton.on('click', (e: JQueryEventObject): void => {
334 const imageData: CropperImageData = this.cropper.cropper('getImageData');
335 const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
336 e.preventDefault();
337 e.stopPropagation();
338 if (!resetCropVariantString) {
339 throw new TypeError('TYPO3 Cropper: No cropVariant data attribute found on reset element.');
340 }
341 const resetCropVariant: CropVariant = JSON.parse(resetCropVariantString);
342 const absoluteCropArea: Area = this.convertRelativeToAbsoluteCropArea(resetCropVariant.cropArea, imageData);
343 this.currentCropVariant = $.extend(true, {}, resetCropVariant, {cropArea: absoluteCropArea});
344 this.update(this.currentCropVariant);
345 });
346
347 // If we start without an cropArea, maximize the cropper
348 if (ImageManipulation.isEmptyArea(this.currentCropVariant.cropArea)) {
349 this.defaultOpts = $.extend({
350 autoCropArea: 1,
351 }, this.defaultOpts);
352 }
353
354 /**
355 * Initialise the cropper
356 *
357 * Note: We use the extraneous jQuery object here, as CropperJS won't work inside the <iframe>
358 * The top.require is now inlined @see ImageManipulationElemen.php:143
359 * TODO: Find a better solution for cross iframe communications
360 */
361 this.cropper = (<any> top.TYPO3.jQuery(image)).cropper($.extend(this.defaultOpts, {
362 built: this.cropBuiltHandler,
363 crop: this.cropMoveHandler,
364 cropend: this.cropEndHandler,
365 cropstart: this.cropStartHandler,
366 data: this.currentCropVariant.cropArea,
367 }));
368 }
369
370 private cropBuiltHandler = (): void => {
371 const imageData: CropperImageData = this.cropper.cropper('getImageData');
372 this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
373 this.currentCropVariant.cropArea,
374 imageData
375 );
376 this.cropBox = this.currentModal.find('.cropper-crop-box');
377
378 this.setCropArea(this.currentCropVariant.cropArea);
379
380 // Check if new cropVariant has coverAreas
381 if (this.currentCropVariant.coverAreas) {
382 // Init or reinit focusArea
383 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
384 }
385 // Check if new cropVariant has focusArea
386 if (this.currentCropVariant.focusArea) {
387 // Init or reinit focusArea
388 if (ImageManipulation.isEmptyArea(this.currentCropVariant.focusArea)) {
389 // If an empty focusArea is set initialise it with the default
390 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
391 }
392 this.initFocusArea(this.cropBox);
393 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
394 }
395
396 if (this.currentCropVariant.selectedRatio) {
397 this.updateAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
398 // Set data explicitly or updateAspectRatio up-scales the crop
399 this.setCropArea(this.currentCropVariant.cropArea);
400 this.currentModal.find(`[data-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
401 }
402 this.cropperCanvas.addClass('is-visible');
403 };
404
405 private cropMoveHandler = (e: CropperEvent): void => {
406 this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
407 height: Math.floor(e.height),
408 width: Math.floor(e.width),
409 x: Math.floor(e.x),
410 y: Math.floor(e.y),
411 });
412 this.updatePreviewThumbnail(this.currentCropVariant);
413 this.updateCropVariantData(this.currentCropVariant);
414 this.cropInfo.text(`${this.currentCropVariant.cropArea.width}×${this.currentCropVariant.cropArea.height} px`);
415 };
416
417 private cropStartHandler = (): void => {
418 if (this.currentCropVariant.focusArea) {
419 this.focusArea.draggable('option', 'disabled', true);
420 this.focusArea.resizable('option', 'disabled', true);
421 }
422 };
423
424 /**
425 *
426 */
427 private cropEndHandler = (): void => {
428 if (this.currentCropVariant.focusArea) {
429 this.focusArea.draggable('option', 'disabled', false);
430 this.focusArea.resizable('option', 'disabled', false);
431 }
432 };
433
434 /**
435 * @method update
436 * @desc Update current cropArea position and size when changing cropVariants
437 * @param {CropVariant} cropVariant - The new cropVariant to update the UI with
438 */
439 private update(cropVariant: CropVariant): void {
440 const temp: CropVariant = $.extend(true, {}, cropVariant);
441 const selectedRatio: Ratio = cropVariant.allowedAspectRatios[cropVariant.selectedRatio];
442 this.currentModal.find('[data-option]').removeClass('active');
443 this.currentModal.find(`[data-option="${cropVariant.selectedRatio}"]`).addClass('active');
444 /**
445 * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
446 */
447 this.updateAspectRatio(selectedRatio);
448 this.setCropArea(temp.cropArea);
449 this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
450 this.cropBox.find(this.coverAreaSelector).remove();
451
452 // If the current container has a focus area element, deregister and cleanup prior to initialization
453 if (this.cropBox.has(this.focusAreaSelector).length) {
454 this.focusArea.resizable('destroy').draggable('destroy');
455 this.focusArea.remove();
456 }
457
458 // Check if new cropVariant has focusArea
459 if (cropVariant.focusArea) {
460 // Init or reinit focusArea
461 if (ImageManipulation.isEmptyArea(cropVariant.focusArea)) {
462 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
463 }
464 this.initFocusArea(this.cropBox);
465 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
466 }
467
468 // Check if new cropVariant has coverAreas
469 if (cropVariant.coverAreas) {
470 // Init or reinit focusArea
471 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
472 }
473 this.updatePreviewThumbnail(this.currentCropVariant);
474 }
475
476 /**
477 * @method initFocusArea
478 * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
479 * @param container: JQuery
480 */
481 private initFocusArea(container: JQuery): void {
482 this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
483 container.append(this.focusArea);
484 this.focusArea
485 .draggable({
486 containment: container,
487 create: (): void => {
488 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
489 },
490 drag: (): void => {
491 const {left, top}: Offset = container.offset();
492 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
493 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
494
495 focusArea.x = (fLeft - left) / container.width();
496 focusArea.y = (fTop - top) / container.height();
497 this.updatePreviewThumbnail(this.currentCropVariant);
498 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
499 this.focusArea.addClass('has-nodrop');
500 } else {
501 this.focusArea.removeClass('has-nodrop');
502 }
503 },
504 revert: (): boolean => {
505 const revertDelay: number = 250;
506 const {left, top}: Offset = container.offset();
507 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
508 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
509
510 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
511 this.focusArea.removeClass('has-nodrop');
512 ImageManipulation.wait((): void => {
513 focusArea.x = (fLeft - left) / container.width();
514 focusArea.y = (fTop - top) / container.height();
515 this.updateCropVariantData(this.currentCropVariant);
516 }, revertDelay);
517 return true;
518 }
519 },
520 revertDuration: 200,
521 stop: (): void => {
522 const {left, top}: Offset = container.offset();
523 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
524 const {focusArea}: {focusArea?: Area} = this.currentCropVariant;
525
526 focusArea.x = (fLeft - left) / container.width();
527 focusArea.y = (fTop - top) / container.height();
528
529 this.scaleAndMoveFocusArea(focusArea);
530 },
531 })
532 .resizable({
533 containment: container,
534 handles: 'all',
535 resize: (): void => {
536 const {left, top}: Offset = container.offset();
537 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
538 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
539
540 focusArea.height = this.focusArea.height() / container.height();
541 focusArea.width = this.focusArea.width() / container.width();
542 focusArea.x = (fLeft - left) / container.width();
543 focusArea.y = (fTop - top) / container.height();
544 this.updatePreviewThumbnail(this.currentCropVariant);
545
546 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
547 this.focusArea.addClass('has-nodrop');
548 } else {
549 this.focusArea.removeClass('has-nodrop');
550 }
551
552 },
553 stop: (event: any, ui: any): void => {
554 const revertDelay: number = 250;
555 const {left, top}: Offset = container.offset();
556 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
557 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
558
559 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
560 ui.element.animate($.extend(ui.originalPosition, ui.originalSize), revertDelay, (): void => {
561
562 focusArea.height = this.focusArea.height() / container.height();
563 focusArea.height = this.focusArea.height() / container.height();
564 focusArea.width = this.focusArea.width() / container.width();
565 focusArea.x = (fLeft - left) / container.width();
566 focusArea.y = (fTop - top) / container.height();
567
568 this.scaleAndMoveFocusArea(focusArea);
569 this.focusArea.removeClass('has-nodrop');
570 });
571 } else {
572 this.scaleAndMoveFocusArea(focusArea);
573 }
574 },
575 });
576 }
577
578 /**
579 * @method initCoverAreas
580 * @desc Initialise cover areas inside the cropper container
581 * @param {JQuery} container - The container element to append the cover areas
582 * @param {Array<Area>} coverAreas - An array of areas to construxt the cover area elements from
583 */
584 private initCoverAreas(container: JQuery, coverAreas: Area[]): void {
585 coverAreas.forEach((coverArea: Area): void => {
586 let coverAreaCanvas: JQuery = $('<div class="cropper-cover-area t3js-cropper-cover-area"></div>');
587 container.append(coverAreaCanvas);
588 coverAreaCanvas.css({
589 height: ImageManipulation.toCssPercent(coverArea.height),
590 left: ImageManipulation.toCssPercent(coverArea.x),
591 top: ImageManipulation.toCssPercent(coverArea.y),
592 width: ImageManipulation.toCssPercent(coverArea.width),
593 });
594 });
595 }
596
597 /**
598 * @method updatePreviewThumbnail
599 * @desc Sync the croping (and focus area) to the preview thumbnail
600 * @param {CropVariant} cropVariant
601 */
602 private updatePreviewThumbnail(cropVariant: CropVariant): void {
603 let styles: any;
604 const cropperPreviewThumbnailCrop: JQuery =
605 this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
606 const cropperPreviewThumbnailImage: JQuery =
607 this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
608 const cropperPreviewThumbnailFocus: JQuery =
609 this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
610 const imageData: CropperImageData = this.cropper.cropper('getImageData');
611
612 // Update the position/dimension of the crop area in the preview
613 cropperPreviewThumbnailCrop.css({
614 height: ImageManipulation.toCssPercent(cropVariant.cropArea.height / imageData.naturalHeight),
615 left: ImageManipulation.toCssPercent(cropVariant.cropArea.x / imageData.naturalWidth),
616 top: ImageManipulation.toCssPercent(cropVariant.cropArea.y / imageData.naturalHeight),
617 width: ImageManipulation.toCssPercent(cropVariant.cropArea.width / imageData.naturalWidth),
618 });
619
620 // Show and update focusArea in the preview only if we really have one configured
621 if (cropVariant.focusArea) {
622 cropperPreviewThumbnailFocus.css({
623 height: ImageManipulation.toCssPercent(cropVariant.focusArea.height),
624 left: ImageManipulation.toCssPercent(cropVariant.focusArea.x),
625 top: ImageManipulation.toCssPercent(cropVariant.focusArea.y),
626 width: ImageManipulation.toCssPercent(cropVariant.focusArea.width),
627 });
628 }
629
630 // Destruct the preview container's CSS properties
631 styles = cropperPreviewThumbnailCrop.css([
632 'width', 'height', 'left', 'top',
633 ]);
634
635 /**
636 * Apply negative margins on the previewThumbnailImage to make the illusion of an offset
637 */
638 cropperPreviewThumbnailImage.css({
639 height: `${parseFloat(styles.height) * (1 / (cropVariant.cropArea.height / imageData.naturalHeight))}px`,
640 margin: `${-1 * parseFloat(styles.left)}px`,
641 marginTop: `${-1 * parseFloat(styles.top)}px`,
642 width: `${parseFloat(styles.width) * (1 / (cropVariant.cropArea.width / imageData.naturalWidth))}px`,
643 });
644 }
645
646 /**
647 * @method scaleAndMoveFocusArea
648 * @desc Calculation logic for moving the focus area given the
649 * specified constrains of a crop and an optional cover area
650 * @param {Area} focusArea - The translation data
651 */
652 private scaleAndMoveFocusArea(focusArea: Area): void {
653 this.focusArea.css({
654 height: ImageManipulation.toCssPercent(focusArea.height),
655 left: ImageManipulation.toCssPercent(focusArea.x),
656 top: ImageManipulation.toCssPercent(focusArea.y),
657 width: ImageManipulation.toCssPercent(focusArea.width),
658 });
659 this.currentCropVariant.focusArea = focusArea;
660 this.updatePreviewThumbnail(this.currentCropVariant);
661 this.updateCropVariantData(this.currentCropVariant);
662 }
663
664 /**
665 * @method updateCropVariantData
666 * @desc Immutably updates the currently selected cropVariant data
667 * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
668 */
669 private updateCropVariantData(currentCropVariant: CropVariant): void {
670 const imageData: CropperImageData = this.cropper.cropper('getImageData');
671 const absoluteCropArea: Area = this.convertAbsoluteToRelativeCropArea(currentCropVariant.cropArea, imageData);
672 this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, {cropArea: absoluteCropArea});
673 }
674
675 /**
676 * @method updateAspectRatio
677 * @desc Updates the aspect ratio in the cropper
678 * @param {ratio} ratio ratio set in the cropper
679 */
680 private updateAspectRatio(ratio: Ratio): void {
681 this.cropper.cropper('setAspectRatio', ratio.value);
682 }
683
684 /**
685 * @method setCropArea
686 * @desc Updates the crop area in the cropper. The cropper will respect the selected ratio
687 * @param {cropArea} cropArea ratio set in the cropper
688 */
689 private setCropArea(cropArea: Area): void {
690 this.cropper.cropper('setData', {
691 height: cropArea.height,
692 width: cropArea.width,
693 x: cropArea.x,
694 y: cropArea.y,
695 });
696 }
697
698 /**
699 * @method checkFocusAndCoverAreas
700 * @desc Checks is one focus area and one or more cover areas overlap
701 * @param focusArea
702 * @param coverAreas
703 * @return {boolean}
704 */
705 private checkFocusAndCoverAreasCollision(focusArea: Area, coverAreas: Area[]): boolean {
706 return coverAreas
707 .some((coverArea: Area): boolean => {
708 // noinspection OverlyComplexBooleanExpressionJS
709 if (focusArea.x < coverArea.x + coverArea.width &&
710 focusArea.x + focusArea.width > coverArea.x &&
711 focusArea.y < coverArea.y + coverArea.height &&
712 focusArea.height + focusArea.y > coverArea.y) {
713 return true;
714 }
715 });
716 }
717
718 /**
719 * @param cropArea
720 * @param imageData
721 * @return {{height: number, width: number, x: number, y: number}}
722 */
723 private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: CropperImageData): Area {
724 const {height, width, x, y}: Area = cropArea;
725 return {
726 height: height / imageData.naturalHeight,
727 width: width / imageData.naturalWidth,
728 x: x / imageData.naturalWidth,
729 y: y / imageData.naturalHeight,
730 };
731 }
732
733 /**
734 * @param cropArea
735 * @param imageData
736 * @return {{height: number, width: number, x: number, y: number}}
737 */
738 private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: CropperImageData): Area {
739 const {height, width, x, y}: Area = cropArea;
740 return {
741 height: height * imageData.naturalHeight,
742 width: width * imageData.naturalWidth,
743 x: x * imageData.naturalWidth,
744 y: y * imageData.naturalHeight,
745 };
746 }
747
748 private setPreviewImage(data: Object): void {
749 let $image: any = this.cropper;
750 let imageData: CropperImageData = $image.cropper('getImageData');
751 Object.keys(data).forEach((cropVariantId: string) => {
752 const cropVariant: CropVariant = data[cropVariantId];
753 const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
754
755 let $preview: JQuery = this.trigger
756 .closest('.form-group')
757 .find(`.t3js-image-manipulation-preview[data-crop-variant-id="${cropVariantId}"]`);
758
759 if ($preview.length === 0) {
760 return;
761 }
762
763 let previewWidth: number = $preview.data('preview-width');
764 let previewHeight: number = $preview.data('preview-height');
765
766 // Adjust aspect ratio of preview width/height
767 let aspectRatio: number = cropData.width / cropData.height;
768 let tmpHeight: number = previewWidth / aspectRatio;
769 if (tmpHeight > previewHeight) {
770 previewWidth = previewHeight * aspectRatio;
771 } else {
772 previewHeight = tmpHeight;
773 }
774 // preview should never be up-scaled
775 if (previewWidth > cropData.width) {
776 previewWidth = cropData.width;
777 previewHeight = cropData.height;
778 }
779
780 let ratio: number = previewWidth / cropData.width;
781
782 let $viewBox: JQuery = $('<div />').html('<img src="' + $image.attr('src') + '">');
783 $viewBox.addClass('cropper-preview-container');
784 $preview.empty().append($viewBox);
785 $viewBox.wrap('<span class="thumbnail thumbnail-status"></span>');
786
787 $viewBox.width(previewWidth).height(previewHeight).find('img').css({
788 height: imageData.naturalHeight * ratio,
789 left: -cropData.x * ratio,
790 top: -cropData.y * ratio,
791 width: imageData.naturalWidth * ratio,
792 });
793 });
794 };
795
796 /**
797 * @method openPreview
798 * @desc open a preview
799 * @param {object} data - The whole data object containing all the cropVariants
800 * @private
801 */
802 private openPreview(data: Object): void {
803 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
804 let previewUrl: string = this.trigger.attr('data-preview-url');
805 previewUrl = previewUrl + '&cropVariants=' + encodeURIComponent(cropVariants);
806 window.open(previewUrl, 'TYPO3ImageManipulationPreview');
807 }
808
809 /**
810 * @method save
811 * @desc Saves the edited cropVariants to a hidden field
812 * @param {object} data - The whole data object containing all the cropVariants
813 * @private
814 */
815 private save(data: Object): void {
816 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
817 const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
818 this.trigger.attr('data-crop-variants', JSON.stringify(data));
819 this.setPreviewImage(data);
820 hiddenField.val(cropVariants);
821 this.destroy();
822 }
823
824 /**
825 * @method destroy
826 * @desc Destroy the ImageManipulation including cropper and alike
827 * @private
828 */
829 private destroy(): void {
830 if (this.currentModal) {
831 this.currentModal.modal('hide');
832 this.cropper.cropper('destroy');
833 this.currentModal = null;
834 }
835 }
836
837 private resizeEnd(fn: Function): void {
838 let timer: number;
839 $(window).on('resize', (): void => {
840 clearTimeout(timer);
841 timer = setTimeout((): void => {
842 fn();
843 }, 450);
844 });
845 }
846 }
847
848 export = new ImageManipulation();