[BUGFIX] Eliminate gremlins in image cropper 42/51642/10
authorHelmut Hummel <typo3@helhum.io>
Tue, 7 Feb 2017 20:12:16 +0000 (21:12 +0100)
committerGeorg Ringer <georg.ringer@gmail.com>
Mon, 13 Feb 2017 08:00:46 +0000 (09:00 +0100)
Refactor rendering to use two templates, a layout
and a partial to be more flexible.

Now also force the crop to be applied, even when
the editor does not open the image manipulation.

This is important when only one aspect ratio is
allowed to not allow the editor to save an invalid
state.

Eliminate several quirks in the TypeScript code,
among them:

* reset state when closing the cropper
* reset state when modal is dismissed
* correctly initialize all previews
* show correct size of crop area for big images
* avoid accidental dismiss of the modal

Last but not least add the accidentally removed form engine
wizard html fields again.

Resolves: #79764
Resolves: #79731
Resolves: #79753
Resolves: #79674

Releases: master
Change-Id: I0a24d6418d6263b00c3fbf31901fd7c67e9fc97e
Reviewed-on: https://review.typo3.org/51642
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Frans Saris <franssaris@gmail.com>
Tested-by: Frans Saris <franssaris@gmail.com>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
15 files changed:
Build/Resources/Public/Less/TYPO3/_element_cropper.less
typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php
typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php
typo3/sysext/backend/Resources/Private/Layouts/ImageManipulation.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Partials/ImageManipulation/ModalTitle.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html [deleted file]
typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/TypeScript/ImageManipulation.ts
typo3/sysext/backend/Resources/Public/JavaScript/ImageManipulation.js
typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php
typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php
typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php
typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php
typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/AreaTest.php [new file with mode: 0644]

index 0404c01..853a93b 100644 (file)
                max-width: none !important;
                max-height: none !important;
        }
-}
\ No newline at end of file
+}
index 903affa..3d2b9ff 100644 (file)
@@ -120,7 +120,9 @@ class ImageManipulationElement extends AbstractFormElement
         parent::__construct($nodeFactory, $data);
         // Would be great, if we could inject the view here, but since the constructor is in the interface, we can't
         $this->templateView = GeneralUtility::makeInstance(StandaloneView::class);
-        $this->templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html'));
+        $this->templateView->setLayoutRootPaths([GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Layouts/')]);
+        $this->templateView->setPartialRootPaths([GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Partials/ImageManipulation/')]);
+        $this->templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html'));
         $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
     }
 
@@ -136,29 +138,30 @@ class ImageManipulationElement extends AbstractFormElement
         $parameterArray = $this->data['parameterArray'];
         $config = $this->populateConfiguration($parameterArray['fieldConf']['config']);
 
-        if ($config['readOnly']) {
-            $options = [];
-            $options['parameterArray'] = [
-                'fieldConf' => [
-                    'config' => $parameterArray['fieldConf']['config'],
-                ],
-                'itemFormElValue' => $parameterArray['itemFormElValue'],
-            ];
-            $options['renderType'] = 'none';
-
-            // Early return in case the field is set to read only
-            return $this->nodeFactory->create($options)->render();
-        }
-
         $file = $this->getFile($this->data['databaseRow'], $config['file_field']);
         if (!$file) {
             // Early return in case we do not find a file
             return $resultArray;
         }
 
-        $config = $this->processConfiguration($config, $parameterArray['itemFormElValue'] ?? '{}');
+        $config = $this->processConfiguration($config, $parameterArray['itemFormElValue'], $file);
+
+        $fieldInformationResult = $this->renderFieldInformation();
+        $fieldInformationHtml = $fieldInformationResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
+
+        $fieldControlResult = $this->renderFieldControl();
+        $fieldControlHtml = $fieldControlResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
+
+        $fieldWizardResult = $this->renderFieldWizard();
+        $fieldWizardHtml = $fieldWizardResult['html'];
+        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
 
         $arguments = [
+            'fieldInformation' => $fieldInformationHtml,
+            'fieldControl' => $fieldControlHtml,
+            'fieldWizard' => $fieldWizardHtml,
             'isAllowedFileExtension' => in_array(strtolower($file->getExtension()), GeneralUtility::trimExplode(',', strtolower($config['allowedExtensions'])), true),
             'image' => $file,
             'formEngine' => [
@@ -182,7 +185,8 @@ class ImageManipulationElement extends AbstractFormElement
                 $arguments['formEngine']['validation'] = $this->getValidationDataAsJsonString(['required' => true]);
             }
         }
-        $resultArray['html'] = $this->templateView->renderSection('Element', $arguments);
+        $this->templateView->assignMultiple($arguments);
+        $resultArray['html'] = $this->templateView->render();
 
         return $resultArray;
     }
@@ -271,13 +275,18 @@ class ImageManipulationElement extends AbstractFormElement
     /**
      * @param array $config
      * @param string $elementValue
+     * @param File $file
      * @return array
      * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
      */
-    protected function processConfiguration(array $config, string $elementValue)
+    protected function processConfiguration(array $config, string &$elementValue, File $file)
     {
         $cropVariantCollection = CropVariantCollection::create($elementValue, $config['cropVariants']);
+        if (empty($config['readOnly'])) {
+            $cropVariantCollection = $cropVariantCollection->applyRatioRestrictionToSelectedCropArea($file);
+        }
         $config['cropVariants'] = $cropVariantCollection->asArray();
+        $elementValue = (string)$cropVariantCollection;
         $config['allowedExtensions'] = implode(', ', GeneralUtility::trimExplode(',', $config['allowedExtensions'], true));
         return $config;
     }
index a176c5e..112d956 100644 (file)
@@ -40,7 +40,9 @@ class ImageManipulationWizard
     {
         if (!$templateView) {
             $templateView = GeneralUtility::makeInstance(StandaloneView::class);
-            $templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html'));
+            $templateView->setLayoutRootPaths([GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Layouts/')]);
+            $templateView->setPartialRootPaths([GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Partials/ImageManipulation/')]);
+            $templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html'));
         }
         $this->templateView = $templateView;
     }
@@ -68,7 +70,7 @@ class ImageManipulationWizard
                 'image' => $image,
                 'cropVariants' => $queryParams['cropVariants']
             ];
-            $content = $this->templateView->renderSection('Cropper', $viewData);
+            $content = $this->templateView->renderSection('Main', $viewData);
             $response->getBody()->write($content);
 
             return $response;
diff --git a/typo3/sysext/backend/Resources/Private/Layouts/ImageManipulation.html b/typo3/sysext/backend/Resources/Private/Layouts/ImageManipulation.html
new file mode 100644 (file)
index 0000000..05351ec
--- /dev/null
@@ -0,0 +1,16 @@
+<div class="t3js-formengine-field-item">
+  {fieldInformation -> f:format.raw()}
+  <div class="form-wizards-wrap">
+      <div class="form-wizards-element">
+          <f:render section="Main" />
+      </div>
+     <div class="form-wizards-items-aside">
+         <div class="btn-group">
+             {fieldControl -> f:format.raw()}
+         </div>
+     </div>
+      <div class="form-wizards-items-bottom">
+          {fieldWizard -> f:format.raw()}
+      </div>
+  </div>
+</div>
diff --git a/typo3/sysext/backend/Resources/Private/Partials/ImageManipulation/ModalTitle.html b/typo3/sysext/backend/Resources/Private/Partials/ImageManipulation/ModalTitle.html
new file mode 100644 (file)
index 0000000..f251983
--- /dev/null
@@ -0,0 +1,9 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers">
+
+       <f:section name="Main">
+               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-manipulation" />
+               : <f:if condition="{image.properties.title}"><f:then>{image.properties.title} &mdash; {image.name}</f:then><f:else>{image.name}</f:else></f:if>
+               ({image.properties.width} × {image.properties.height})
+       </f:section>
+
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html
deleted file mode 100644 (file)
index 7a2a03d..0000000
+++ /dev/null
@@ -1,183 +0,0 @@
-<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
-       xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers">
-
-       <f:section name="Element">
-               <div class="media">
-                       <f:if condition="{isAllowedFileExtension}">
-                               <f:then>
-                                       <div class="media-left">
-                                               <f:for each="{config.cropVariants}" as="cropVariant">
-                                                       <div class="t3js-image-manipulation-preview media-object" data-preview-width="150" data-preview-height="200" data-crop-variant-id="{cropVariant.id}">
-                                                               <f:image image="{image}" crop="{formEngine.field.value}" cropVariant="{cropVariant.id}" maxWidth="150" maxHeight="200" class="thumbnail thumbnail-status" additionalAttributes="{data-crop-variant: '{cropVariant -> f:format.json()}', data-crop-variant-id: cropVariant.id}" />
-                                                       </div>
-                                               </f:for>
-                                       </div>
-                                       <div class="media-body">
-                                               <input type="hidden" id="{formEngine.field.id}" name="{formEngine.field.name}" value="{formEngine.field.value}" data-formengine-validation-rules="{formEngine.validation}" />
-                                               <button class="btn btn-default t3js-image-manipulation-trigger"
-                                                                               data-url="{wizardUri}"
-                                                                               data-preview-url="{previewUrl}"
-                                                                               data-severity="notice"
-                                                                               data-modal-title="{f:render(section: 'ModalTitle', arguments: _all)}"
-                                                                               data-image-uid="{image.uid}"
-                                                                               data-crop-variants="{config.cropVariants -> f:format.json()}"
-                                                                               data-file-field="{config.file_field}"
-                                                                               data-field="{formEngine.field.id}">
-                                                       <span class="t3-icon fa fa-crop"></span><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.open-editor" />
-                                               </button>
-                                               <f:if condition="{crop}" >
-                                                       <div class="table-fit-block table-spacer-wrap">
-                                                               <table class="table table-no-borders t3js-image-manipulation-info">
-                                                                       <tr>
-                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width" /></td>
-                                                                               <td class="t3js-image-manipulation-info-crop-width">{crop.width}px</td>
-                                                                       </tr>
-                                                                       <tr>
-                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height" /></td>
-                                                                               <td class="t3js-image-manipulation-info-crop-height">{crop.height}px</td>
-                                                                       </tr>
-                                                               </table>
-                                                       </div>
-                                               </f:if>
-                                       </div>
-                               </f:then>
-                               <f:else>
-                                       <div class="media-body">
-                                       <p><em>
-                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.supported-types-message" />
-                                               <br/>
-                                               {config.allowedExtensions -> f:format.case(mode: 'upper')}
-                                       </em></p>
-                                       </div>
-                               </f:else>
-                       </f:if>
-               </div>
-       </f:section>
-       <f:section name="Cropper">
-               <f:if condition="{image.properties.width}">
-                       <f:then>
-                               <div class="modal-header">
-                                       <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
-                                       </button>
-                                       <h4 class="modal-title">
-                                               {f:render(section: 'ModalTitle', arguments: _all)}
-                                       </h4>
-                               </div>
-                               <div class="cropper modal-panel">
-                                       <div class="modal-panel-body">
-                                               <div class="cropper-image-container">
-                                                       <img id="t3js-crop-image" class="cropper-image-container-image"
-                                                                        src="{f:uri.image(image:image, maxWidth:'1000', maxHeight: '700')}"
-                                                                        data-original-width="{image.properties.width}" data-original-height="{image.properties.height}"/>
-                                               </div>
-                                       </div>
-                                       <div class="modal-panel-sidebar modal-panel-sidebar-right">
-                                               <div class="modal-body">
-                                                       <div class="panel-group" id="accordion-cropper-variants" role="tablist" aria-multiselectable="true">
-                                                               <f:for each="{cropVariants}" as="cropVariant" iteration="cropVariantIterator">
-                                                                       <div class="panel panel-default">
-                                                                               <div class="panel-heading" role="tab" id="cropper-accordion-heading-{cropVariantIterator.cycle}">
-                                                                                       <h4 class="panel-title">
-                                                                                               <a role="button" data-toggle="collapse" data-parent="#accordion-cropper-variants"
-                                                                                                        href="#cropper-collapse-{cropVariantIterator.cycle}"
-                                                                                                        aria-expanded="{f:if(condition:cropVariantIterator.isFirst, then:'true', else:'false')}"
-                                                                                                        aria-controls="cropper-collapse-{cropVariantIterator.cycle}"
-                                                                                                        class="t3js-crop-variant-trigger {f:if(condition:cropVariantIterator.isFirst, then:'is-active', else:'collapsed')}"
-                                                                                                        data-crop-variant-id="{cropVariant.id}"
-                                                                                                        data-crop-variant>
-                                                                                               <span><i class="fa fa-chevron-{f:if(condition:cropVariantIterator.isFirst, then:'up', else:'down')}"
-                                                                                                                                aria-hidden="true"></i> {cropVariant.title -> f:translate(id: cropVariant.title)}</span>
-                                                                                                       <div
-                                                                                                               class="cropper-preview-thumbnail {f:if(condition:'{image.properties.width}>{image.properties.height}', then:'wide', else: 'tall')}">
-                                                                                                               <img class="cropper-preview-thumbnail-image"
-                                                                                                                                src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}">
-                                                                                                               <div class="cropper-preview-thumbnail-crop-area t3js-cropper-preview-thumbnail-crop-area">
-                                                                                                                       <img src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}"
-                                                                                                                                        class="cropper-preview-thumbnail-crop-image t3js-cropper-preview-thumbnail-crop-image">
-                                                                                                                       <div class="cropper-preview-thumbnail-focus-area t3js-cropper-preview-thumbnail-focus-area"></div>
-                                                                                                               </div>
-                                                                                                       </div>
-                                                                                               </a>
-                                                                                       </h4>
-                                                                               </div>
-                                                                               <div id="cropper-collapse-{cropVariantIterator.cycle}"
-                                                                                                class="panel-collapse collapse {f:if(condition:cropVariantIterator.isFirst, then:'in')}"
-                                                                                                role="tabpanel"
-                                                                                                aria-labelledby="cropper-accordion-heading-{cropVariantIterator.cycle}">
-                                                                                       <div class="panel-body">
-                                                                                               <form class="form">
-                                                                                                       <div class="form-group">
-                                                                                                               <f:if condition="{cropVariant.allowedAspectRatios}">
-                                                                                                                       <label for="ratio-{cropVariantIterator.cycle}">
-                                                                                                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.aspect-ratio"/>
-                                                                                                                       </label>
-                                                                                                                       <div id="ratio-{cropVariantIterator.cycle}" class="ratio-buttons t3js-ratio-buttons"
-                                                                                                                                        data-toggle="buttons">
-                                                                                                                               <f:for each="{cropVariant.allowedAspectRatios}" as="ratio" iteration="ratioIterator">
-                                                                                                                                       <label class="btn btn-secondary" data-method="setAspectRatio" data-option="{ratio.id}" title="{f:translate(id:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.set-aspect-ratio')}">
-                                                                                                                                               <input
-                                                                                                                                                       class="sr-only" id="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}"
-                                                                                                                                                       name="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}" value="{cropVariant.id}"
-                                                                                                                                                       type="radio">
-                                                                                                                                               <span>{ratio.title -> f:translate(id: ratio.title)}</span> <i class="fa fa-check"></i></label>
-                                                                                                                               </f:for>
-                                                                                                                       </div>
-                                                                                                               </f:if>
-                                                                                                               <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.selection" /></label>
-                                                                                                               <div class="table-fit-block">
-                                                                                                                       <table class="table table-no-borders table-transparent">
-                                                                                                                               <tr>
-                                                                                                                                       <td class="t3js-cropper-info-crop"></td>
-                                                                                                                               </tr>
-                                                                                                                       </table>
-                                                                                                               </div>
-                                                                                                               <button class="btn btn-secondary" data-method="reset" data-crop-variant="{cropVariant -> f:format.json()}"
-                                                                                                                                               title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}">
-                                                                                                                       <i class="fa fa-refresh"></i>
-                                                                                                                       {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}
-                                                                                                               </button>
-                                                                                                       </div>
-                                                                                               </form>
-                                                                                       </div>
-                                                                               </div>
-                                                                       </div>
-                                                               </f:for>
-                                                       </div>
-                                               </div>
-                                       </div>
-                               </div>
-                               <div class="modal-footer">
-                                       <button class="btn btn-default pull-left" data-method="preview" title="Preview"><i
-                                               class="fa fa-eye"></i>
-                                               Preview
-                                       </button>
-                                       <button class="btn btn-default" data-method="dismiss"
-                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}">
-                                               <i class="fa fa-remove"></i>
-                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}
-                                       </button>
-                                       <button class="btn btn-primary" data-method="save"
-                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}">
-                                               <i class="fa fa-check"></i>
-                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}
-                                       </button>
-                               </div>
-                       </f:then>
-                       <f:else>
-                               <div class="alert alert-danger">
-                                       <h4 class="alert-title">
-                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found"/>
-                                       </h4>
-                                       <p class="alert-message">
-                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found-message"/>
-                                       </p>
-                               </div>
-                       </f:else>
-               </f:if>
-       </f:section>
-       <f:section name="ModalTitle">
-               {f:translate(id: 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-manipulation')}
-               : {f:if(condition:image.properties.title, then:image.properties.title, else:image.name)}
-               ({image.properties.width} × {image.properties.height})
-       </f:section>
-</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html
new file mode 100644 (file)
index 0000000..61d526f
--- /dev/null
@@ -0,0 +1,61 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+       xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers">
+       <f:layout name="ImageManipulation" />
+
+       <f:section name="Main">
+               <div class="media">
+                       <f:if condition="{isAllowedFileExtension}">
+                               <f:then>
+                                       <div class="media-left">
+                                               <f:for each="{config.cropVariants}" as="cropVariant">
+                                                       <div class="t3js-image-manipulation-preview media-object" data-preview-width="150" data-preview-height="200" data-crop-variant-id="{cropVariant.id}">
+                                                               <f:image image="{image}" crop="{formEngine.field.value}" cropVariant="{cropVariant.id}" maxWidth="150" maxHeight="200" class="thumbnail thumbnail-status" additionalAttributes="{data-crop-variant: '{cropVariant -> f:format.json()}', data-crop-variant-id: cropVariant.id}" />
+                                                       </div>
+                                               </f:for>
+                                       </div>
+                                       <f:if condition="{config.readOnly}">
+                                               <f:else>
+                                                       <div class="media-body">
+                                                               <input type="hidden" id="{formEngine.field.id}" name="{formEngine.field.name}" value="{formEngine.field.value}" data-formengine-validation-rules="{formEngine.validation}" />
+                                                               <button class="btn btn-default t3js-image-manipulation-trigger"
+                                                                                               data-url="{wizardUri}"
+                                                                                               data-preview-url="{previewUrl}"
+                                                                                               data-severity="notice"
+                                                                                               data-modal-title="{f:render(partial: 'ModalTitle', section:'Main', arguments: _all)}"
+                                                                                               data-image-uid="{image.uid}"
+                                                                                               data-crop-variants="{config.cropVariants -> f:format.json()}"
+                                                                                               data-file-field="{config.file_field}"
+                                                                                               data-field="{formEngine.field.id}">
+                                                                       <span class="t3-icon fa fa-crop"></span><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.open-editor" />
+                                                               </button>
+                                                               <f:if condition="{crop}" >
+                                                                       <div class="table-fit-block table-spacer-wrap">
+                                                                               <table class="table table-no-borders t3js-image-manipulation-info">
+                                                                                       <tr>
+                                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width" /></td>
+                                                                                               <td class="t3js-image-manipulation-info-crop-width">{crop.width}px</td>
+                                                                                       </tr>
+                                                                                       <tr>
+                                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height" /></td>
+                                                                                               <td class="t3js-image-manipulation-info-crop-height">{crop.height}px</td>
+                                                                                       </tr>
+                                                                               </table>
+                                                                       </div>
+                                                               </f:if>
+                                                       </div>
+                                               </f:else>
+                                       </f:if>
+                               </f:then>
+                               <f:else>
+                                       <div class="media-body">
+                                       <p><em>
+                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.supported-types-message" />
+                                               <br/>
+                                               {config.allowedExtensions -> f:format.case(mode: 'upper')}
+                                       </em></p>
+                                       </div>
+                               </f:else>
+                       </f:if>
+               </div>
+       </f:section>
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html
new file mode 100644 (file)
index 0000000..fd9e97a
--- /dev/null
@@ -0,0 +1,124 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+       xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers">
+
+       <f:section name="Main">
+               <f:if condition="{image.properties.width}">
+                       <f:then>
+                               <div class="modal-header">
+                                       <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
+                                       </button>
+                                       <h4 class="modal-title">
+                                               {f:render(partial: 'ModalTitle', section:'Main', arguments: _all)}
+                                       </h4>
+                               </div>
+                               <div class="cropper modal-panel">
+                                       <div class="modal-panel-body">
+                                               <div id="t3js-crop-image-container" class="cropper-image-container">
+                                                       <f:image image="{image}" id="t3js-crop-image" class="cropper-image-container-image" additionalAttributes="{data-original-width: image.properties.width, data-original-height: image.properties.height}" />
+                                               </div>
+                                       </div>
+                                       <div class="modal-panel-sidebar modal-panel-sidebar-right">
+                                               <div class="modal-body">
+                                                       <div class="panel-group" id="accordion-cropper-variants" role="tablist" aria-multiselectable="true">
+                                                               <f:for each="{cropVariants}" as="cropVariant" iteration="cropVariantIterator">
+                                                                       <div class="panel panel-default">
+                                                                               <div class="panel-heading" role="tab" id="cropper-accordion-heading-{cropVariantIterator.cycle}">
+                                                                                       <h4 class="panel-title">
+                                                                                               <a role="button" data-toggle="collapse" data-parent="#accordion-cropper-variants"
+                                                                                                        href="#cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                        aria-expanded="{f:if(condition:cropVariantIterator.isFirst, then:'true', else:'false')}"
+                                                                                                        aria-controls="cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                        class="t3js-crop-variant-trigger {f:if(condition:cropVariantIterator.isFirst, then:'is-active', else:'collapsed')}"
+                                                                                                        data-crop-variant-id="{cropVariant.id}"
+                                                                                                        data-crop-variant>
+                                                                                               <span><i class="fa fa-chevron-{f:if(condition:cropVariantIterator.isFirst, then:'up', else:'down')}"
+                                                                                                                                aria-hidden="true"></i> {cropVariant.title -> f:translate(id: cropVariant.title)}</span>
+                                                                                                       <div
+                                                                                                               class="cropper-preview-thumbnail {f:if(condition:'{image.properties.width}>{image.properties.height}', then:'wide', else: 'tall')}">
+                                                                                                               <img class="cropper-preview-thumbnail-image"
+                                                                                                                                src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}">
+                                                                                                               <div class="cropper-preview-thumbnail-crop-area t3js-cropper-preview-thumbnail-crop-area">
+                                                                                                                       <img src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}"
+                                                                                                                                        class="cropper-preview-thumbnail-crop-image t3js-cropper-preview-thumbnail-crop-image">
+                                                                                                                       <div class="cropper-preview-thumbnail-focus-area t3js-cropper-preview-thumbnail-focus-area"></div>
+                                                                                                               </div>
+                                                                                                       </div>
+                                                                                               </a>
+                                                                                       </h4>
+                                                                               </div>
+                                                                               <div id="cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                class="panel-collapse collapse {f:if(condition:cropVariantIterator.isFirst, then:'in')}"
+                                                                                                role="tabpanel"
+                                                                                                aria-labelledby="cropper-accordion-heading-{cropVariantIterator.cycle}">
+                                                                                       <div class="panel-body">
+                                                                                               <form class="form">
+                                                                                                       <div class="form-group">
+                                                                                                               <f:if condition="{cropVariant.allowedAspectRatios}">
+                                                                                                                       <label for="ratio-{cropVariantIterator.cycle}">
+                                                                                                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.aspect-ratio"/>
+                                                                                                                       </label>
+                                                                                                                       <div id="ratio-{cropVariantIterator.cycle}" class="ratio-buttons t3js-ratio-buttons"
+                                                                                                                                        data-toggle="buttons">
+                                                                                                                               <f:for each="{cropVariant.allowedAspectRatios}" as="ratio" iteration="ratioIterator">
+                                                                                                                                       <label class="btn btn-secondary" data-method="setAspectRatio" data-option="{ratio.id}" title="{f:translate(id:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.set-aspect-ratio')}">
+                                                                                                                                               <input
+                                                                                                                                                       class="sr-only" id="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}"
+                                                                                                                                                       name="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}" value="{cropVariant.id}"
+                                                                                                                                                       type="radio">
+                                                                                                                                               <span>{ratio.title -> f:translate(id: ratio.title)}</span> <i class="fa fa-check"></i></label>
+                                                                                                                               </f:for>
+                                                                                                                       </div>
+                                                                                                               </f:if>
+                                                                                                               <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.selection" /></label>
+                                                                                                               <div class="table-fit-block">
+                                                                                                                       <table class="table table-no-borders table-transparent">
+                                                                                                                               <tr>
+                                                                                                                                       <td class="t3js-cropper-info-crop"></td>
+                                                                                                                               </tr>
+                                                                                                                       </table>
+                                                                                                               </div>
+                                                                                                               <button class="btn btn-secondary" data-method="reset" data-crop-variant="{cropVariant -> f:format.json()}"
+                                                                                                                                               title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}">
+                                                                                                                       <i class="fa fa-refresh"></i>
+                                                                                                                       {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}
+                                                                                                               </button>
+                                                                                                       </div>
+                                                                                               </form>
+                                                                                       </div>
+                                                                               </div>
+                                                                       </div>
+                                                               </f:for>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+                               <div class="modal-footer">
+                                       <button class="btn btn-default pull-left" data-method="preview" title="Preview"><i
+                                               class="fa fa-eye"></i>
+                                               Preview
+                                       </button>
+                                       <button class="btn btn-default" data-method="dismiss"
+                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}">
+                                               <i class="fa fa-remove"></i>
+                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}
+                                       </button>
+                                       <button class="btn btn-primary" data-method="save"
+                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}">
+                                               <i class="fa fa-check"></i>
+                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}
+                                       </button>
+                               </div>
+                       </f:then>
+                       <f:else>
+                               <div class="alert alert-danger">
+                                       <h4 class="alert-title">
+                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found"/>
+                                       </h4>
+                                       <p class="alert-message">
+                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found-message"/>
+                                       </p>
+                               </div>
+                       </f:else>
+               </f:if>
+       </f:section>
+</html>
index 722a0b3..956aaa6 100644 (file)
@@ -150,6 +150,8 @@ class ImageManipulation {
   private aspectRatioTrigger: JQuery;
   private cropperCanvas: JQuery;
   private cropInfo: JQuery;
+  private cropImageContainerSelector: string = '#t3js-crop-image-container';
+  private cropImageSelector: string = '#t3js-crop-image';
   private coverAreaSelector: string = '.t3js-cropper-cover-area';
   private cropInfoSelector: string = '.t3js-cropper-info-crop';
   private focusAreaSelector: string = '#t3js-cropper-focus-area';
@@ -173,6 +175,7 @@ class ImageManipulation {
     viewMode: 1,
     zoomable: false,
   };
+  private resizeTimeout: number = 450;
 
   constructor() {
     // Silence is golden
@@ -204,10 +207,12 @@ class ImageManipulation {
   }
 
   /**
-   * Initialize the cropper modal
+   * @method initializeCropperModal
+   * @desc Initialize the cropper modal and dispatch the cropper init
+   * @private
    */
   private initializeCropperModal(): void {
-    const image: JQuery = this.currentModal.find('#t3js-crop-image');
+    const image: JQuery = this.currentModal.find(this.cropImageSelector);
     ImagesLoaded(image, (): void => {
       const modal: JQuery = this.currentModal.find('.modal-dialog');
       modal.css({marginLeft: 'auto', marginRight: 'auto'});
@@ -219,6 +224,11 @@ class ImageManipulation {
     });
   }
 
+  /**
+   * @method show
+   * @desc Load the image and setup the modal UI
+   * @private
+   */
   private show(): void {
     const modalTitle: string = this.trigger.data('modalTitle');
     const imageUri: string = this.trigger.data('url');
@@ -236,10 +246,20 @@ class ImageManipulation {
       '.modal-content'
     );
     this.currentModal.addClass('modal-dark');
+    this.currentModal.on('hide.bs.modal', (e: JQueryEventObject): void => {
+      this.destroy();
+    });
+    // Do not dismiss the modal when clicking beside it to avoid data loss
+    this.currentModal.data('bs.modal').options.backdrop = 'static';
   }
 
+  /**
+   * @method init
+   * @desc Initializes the cropper UI and sets up all the event indings for the UI
+   * @private
+   */
   private init(): void {
-    const image: JQuery = this.currentModal.find('#t3js-crop-image');
+    const image: JQuery = this.currentModal.find(this.cropImageSelector);
     const imageHeight: number = $(image).height();
     const imageWidth: number = $(image).width();
     const data: string = this.trigger.attr('data-crop-variants');
@@ -251,7 +271,7 @@ class ImageManipulation {
     // If we have data already set we assume an internal reinit eg. after resizing
     this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
     // Initialize our class members
-    this.currentModal.find('.cropper-image-container').css({height: imageHeight, width: imageWidth});
+    this.currentModal.find(this.cropImageContainerSelector).css({height: imageHeight, width: imageWidth});
     this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
     this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
     this.cropInfo = this.currentModal.find(this.cropInfoSelector);
@@ -266,7 +286,7 @@ class ImageManipulation {
     /**
      * Assign EventListener to cropVariantTriggers
      */
-    this.cropVariantTriggers.on('click', (e: JQueryEventObject): void => {
+    this.cropVariantTriggers.off('click').on('click', (e: JQueryEventObject): void => {
 
       /**
        * Is the current cropVariantTrigger is active, bail out.
@@ -291,12 +311,12 @@ class ImageManipulation {
     /**
      * Assign EventListener to aspectRatioTrigger
      */
-    this.aspectRatioTrigger.on('click', (e: JQueryEventObject): void => {
+    this.aspectRatioTrigger.off('click').on('click', (e: JQueryEventObject): void => {
       const ratioId: string = $(e.currentTarget).attr('data-option');
       const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
       const ratio: Ratio = temp.allowedAspectRatios[ratioId];
-      this.updateAspectRatio(ratio);
-      // Set data explicitly or updateAspectRatio upscales the crop
+      this.setAspectRatio(ratio);
+      // Set data explicitly or setAspectRatio upscales the crop
       this.setCropArea(temp.cropArea);
       this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
       this.update(this.currentCropVariant);
@@ -305,7 +325,7 @@ class ImageManipulation {
     /**
      * Assign EventListener to saveButton
      */
-    this.saveButton.on('click', (): void => {
+    this.saveButton.off('click').on('click', (): void => {
       this.save(this.data);
     });
 
@@ -313,7 +333,7 @@ class ImageManipulation {
      * Assign EventListener to previewButton if preview url exists
      */
     if (this.trigger.attr('data-preview-url')) {
-      this.previewButton.on('click', (): void => {
+      this.previewButton.off('click').on('click', (): void => {
         this.openPreview(this.data);
       });
     } else {
@@ -323,14 +343,14 @@ class ImageManipulation {
     /**
      * Assign EventListener to dismissButton
      */
-    this.dismissButton.on('click', (): void => {
-      this.destroy();
+    this.dismissButton.off('click').on('click', (): void => {
+      this.currentModal.modal('hide');
     });
 
     /**
      * Assign EventListener to resetButton
      */
-    this.resetButton.on('click', (e: JQueryEventObject): void => {
+    this.resetButton.off('click').on('click', (e: JQueryEventObject): void => {
       const imageData: CropperImageData = this.cropper.cropper('getImageData');
       const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
       e.preventDefault();
@@ -367,12 +387,30 @@ class ImageManipulation {
     }));
   }
 
+  /**
+   * @method cropBuiltHandler
+   * @desc Internal cropper handler. Called when the cropper has been instantiated
+   * @private
+   */
   private cropBuiltHandler = (): void => {
     const imageData: CropperImageData = this.cropper.cropper('getImageData');
+
+    // Iterate over the crop variants and set up their respective preview
+    this.cropVariantTriggers.each((index: number, elem: Element): void => {
+      const cropVariantId: string = $(elem).attr('data-crop-variant-id');
+      const cropArea: Area = this.convertRelativeToAbsoluteCropArea(
+        this.data[cropVariantId].cropArea,
+        imageData
+      );
+      const variant: CropVariant = $.extend(true, {}, this.data[cropVariantId], {cropArea});
+      this.updatePreviewThumbnail(variant, $(elem));
+    });
+
     this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
       this.currentCropVariant.cropArea,
       imageData
     );
+    // Can't use .t3js-* as selector because it is an extraneous selector
     this.cropBox = this.currentModal.find('.cropper-crop-box');
 
     this.setCropArea(this.currentCropVariant.cropArea);
@@ -394,14 +432,20 @@ class ImageManipulation {
     }
 
     if (this.currentCropVariant.selectedRatio) {
-      this.updateAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
-      // Set data explicitly or updateAspectRatio up-scales the crop
+      this.setAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
+      // Set data explicitly or setAspectRatio up-scales the crop
       this.setCropArea(this.currentCropVariant.cropArea);
       this.currentModal.find(`[data-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
     }
+
     this.cropperCanvas.addClass('is-visible');
   };
 
+  /**
+   * @method cropMoveHandler
+   * @desc Internal cropper handler. Called when the cropping area is moving
+   * @private
+   */
   private cropMoveHandler = (e: CropperEvent): void => {
     this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
       height: Math.floor(e.height),
@@ -409,11 +453,16 @@ class ImageManipulation {
       x: Math.floor(e.x),
       y: Math.floor(e.y),
     });
-    this.updatePreviewThumbnail(this.currentCropVariant);
+    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
     this.updateCropVariantData(this.currentCropVariant);
     this.cropInfo.text(`${this.currentCropVariant.cropArea.width}×${this.currentCropVariant.cropArea.height} px`);
   };
 
+  /**
+   * @method cropStartHandler
+   * @desc Internal cropper handler. Called when the cropping starts moving
+   * @private
+   */
   private cropStartHandler = (): void => {
     if (this.currentCropVariant.focusArea) {
       this.focusArea.draggable('option', 'disabled', true);
@@ -422,7 +471,9 @@ class ImageManipulation {
   };
 
   /**
-   *
+   * @method cropEndHandler
+   * @desc Internal cropper handler. Called when the cropping ends moving
+   * @private
    */
   private cropEndHandler = (): void => {
     if (this.currentCropVariant.focusArea) {
@@ -444,7 +495,7 @@ class ImageManipulation {
     /**
      * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
      */
-    this.updateAspectRatio(selectedRatio);
+    this.setAspectRatio(selectedRatio);
     this.setCropArea(temp.cropArea);
     this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
     this.cropBox.find(this.coverAreaSelector).remove();
@@ -470,13 +521,14 @@ class ImageManipulation {
       // Init or reinit focusArea
       this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
     }
-    this.updatePreviewThumbnail(this.currentCropVariant);
+    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
   }
 
   /**
    * @method initFocusArea
    * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
-   * @param container: JQuery
+   * @param {JQuery} container
+   * @private
    */
   private initFocusArea(container: JQuery): void {
     this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
@@ -494,7 +546,7 @@ class ImageManipulation {
 
           focusArea.x = (fLeft - left) / container.width();
           focusArea.y = (fTop - top) / container.height();
-          this.updatePreviewThumbnail(this.currentCropVariant);
+          this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
           if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
             this.focusArea.addClass('has-nodrop');
           } else {
@@ -541,7 +593,7 @@ class ImageManipulation {
           focusArea.width = this.focusArea.width() / container.width();
           focusArea.x = (fLeft - left) / container.width();
           focusArea.y = (fTop - top) / container.height();
-          this.updatePreviewThumbnail(this.currentCropVariant);
+          this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
 
           if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
             this.focusArea.addClass('has-nodrop');
@@ -597,16 +649,18 @@ class ImageManipulation {
   /**
    * @method updatePreviewThumbnail
    * @desc Sync the croping (and focus area) to the preview thumbnail
-   * @param {CropVariant} cropVariant
+   * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
+   * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
+   * @private
    */
-  private updatePreviewThumbnail(cropVariant: CropVariant): void {
+  private updatePreviewThumbnail(cropVariant: CropVariant, cropVariantTrigger: JQuery): void {
     let styles: any;
     const cropperPreviewThumbnailCrop: JQuery =
-      this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
+      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
     const cropperPreviewThumbnailImage: JQuery =
-      this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
+      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
     const cropperPreviewThumbnailFocus: JQuery =
-      this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
+      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
     const imageData: CropperImageData = this.cropper.cropper('getImageData');
 
     // Update the position/dimension of the crop area in the preview
@@ -657,7 +711,7 @@ class ImageManipulation {
       width: ImageManipulation.toCssPercent(focusArea.width),
     });
     this.currentCropVariant.focusArea = focusArea;
-    this.updatePreviewThumbnail(this.currentCropVariant);
+    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
     this.updateCropVariantData(this.currentCropVariant);
   }
 
@@ -665,6 +719,7 @@ class ImageManipulation {
    * @method updateCropVariantData
    * @desc Immutably updates the currently selected cropVariant data
    * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
+   * @private
    */
   private updateCropVariantData(currentCropVariant: CropVariant): void {
     const imageData: CropperImageData = this.cropper.cropper('getImageData');
@@ -673,26 +728,37 @@ class ImageManipulation {
   }
 
   /**
-   * @method updateAspectRatio
-   * @desc Updates the aspect ratio in the cropper
-   * @param {ratio} ratio ratio set in the cropper
+   * @method setAspectRatio
+   * @desc Sets the cropper to a specific ratio
+   * @param {ratio} ratio - The ratio value to apply
+   * @private
    */
-  private updateAspectRatio(ratio: Ratio): void {
+  private setAspectRatio(ratio: Ratio): void {
     this.cropper.cropper('setAspectRatio', ratio.value);
   }
 
   /**
    * @method setCropArea
-   * @desc Updates the crop area in the cropper. The cropper will respect the selected ratio
-   * @param {cropArea} cropArea ratio set in the cropper
+   * @desc Sets the cropper to a specific crop area
+   * @param {cropArea} cropArea - The crop area to apply
+   * @private
    */
   private setCropArea(cropArea: Area): void {
-    this.cropper.cropper('setData', {
-      height: cropArea.height,
-      width: cropArea.width,
-      x: cropArea.x,
-      y: cropArea.y,
-    });
+    const currentRatio: Ratio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
+    if (currentRatio.value === 0) {
+      this.cropper.cropper('setData', {
+        height: cropArea.height,
+        width: cropArea.width,
+        x: cropArea.x,
+        y: cropArea.y,
+      });
+    } else {
+      this.cropper.cropper('setData', {
+        height: cropArea.height,
+        x: cropArea.x,
+        y: cropArea.y,
+      });
+    }
   }
 
   /**
@@ -716,9 +782,11 @@ class ImageManipulation {
   }
 
   /**
-   * @param cropArea
-   * @param imageData
-   * @return {{height: number, width: number, x: number, y: number}}
+   * @method convertAbsoluteToRelativeCropArea
+   * @desc Converts a crop area from absolute pixel-based into relative length values
+   * @param {Area} cropArea - The crop area to convert from
+   * @param {CropperImageData} imageData - The image data
+   * @return {Area}
    */
   private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: CropperImageData): Area {
     const {height, width, x, y}: Area = cropArea;
@@ -731,8 +799,10 @@ class ImageManipulation {
   }
 
   /**
-   * @param cropArea
-   * @param imageData
+   * @method convertRelativeToAbsoluteCropArea
+   * @desc Converts a crop area from relative into absolute pixel-based length values
+   * @param {Area} cropArea - The crop area to convert from
+   * @param {CropperImageData} imageData - The image data
    * @return {{height: number, width: number, x: number, y: number}}
    */
   private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: CropperImageData): Area {
@@ -745,9 +815,16 @@ class ImageManipulation {
     };
   }
 
-  private setPreviewImage(data: Object): void {
+  /**
+   * @method setPreviewImages
+   * @desc Updates the preview images in the editing section with the respective crop variants
+   * @param {Object} data - The internal crop variants state
+   */
+  private setPreviewImages(data: Object): void {
     let $image: any = this.cropper;
     let imageData: CropperImageData = $image.cropper('getImageData');
+
+    // Iterate over the crop variants and set up their respective preview
     Object.keys(data).forEach((cropVariantId: string) => {
       const cropVariant: CropVariant = data[cropVariantId];
       const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
@@ -795,7 +872,7 @@ class ImageManipulation {
 
   /**
    * @method openPreview
-   * @desc open a preview
+   * @desc Opens a preview view with the crop variants
    * @param {object} data - The whole data object containing all the cropVariants
    * @private
    */
@@ -816,9 +893,9 @@ class ImageManipulation {
     const cropVariants: string = ImageManipulation.serializeCropVariants(data);
     const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
     this.trigger.attr('data-crop-variants', JSON.stringify(data));
-    this.setPreviewImage(data);
+    this.setPreviewImages(data);
     hiddenField.val(cropVariants);
-    this.destroy();
+    this.currentModal.modal('hide');
   }
 
   /**
@@ -828,19 +905,26 @@ class ImageManipulation {
    */
   private destroy(): void {
     if (this.currentModal) {
-      this.currentModal.modal('hide');
       this.cropper.cropper('destroy');
+      this.cropper = null;
       this.currentModal = null;
+      this.data = null;
     }
   }
 
+  /**
+   * @method resizeEnd
+   * @desc Calls a function when the cropper has been resized
+   * @param {Function} fn - The function to call on resize completion
+   * @private
+   */
   private resizeEnd(fn: Function): void {
     let timer: number;
     $(window).on('resize', (): void => {
       clearTimeout(timer);
       timer = setTimeout((): void => {
         fn();
-      }, 450);
+      }, this.resizeTimeout);
     });
   }
 }
index fd6907c..25ea332 100644 (file)
@@ -20,6 +20,8 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
     var ImageManipulation = (function () {
         function ImageManipulation() {
             var _this = this;
+            this.cropImageContainerSelector = '#t3js-crop-image-container';
+            this.cropImageSelector = '#t3js-crop-image';
             this.coverAreaSelector = '.t3js-cropper-cover-area';
             this.cropInfoSelector = '.t3js-cropper-info-crop';
             this.focusAreaSelector = '#t3js-cropper-focus-area';
@@ -38,9 +40,23 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 viewMode: 1,
                 zoomable: false,
             };
+            this.resizeTimeout = 450;
+            /**
+             * @method cropBuiltHandler
+             * @desc Internal cropper handler. Called when the cropper has been instantiated
+             * @private
+             */
             this.cropBuiltHandler = function () {
                 var imageData = _this.cropper.cropper('getImageData');
+                // Iterate over the crop variants and set up their respective preview
+                _this.cropVariantTriggers.each(function (index, elem) {
+                    var cropVariantId = $(elem).attr('data-crop-variant-id');
+                    var cropArea = _this.convertRelativeToAbsoluteCropArea(_this.data[cropVariantId].cropArea, imageData);
+                    var variant = $.extend(true, {}, _this.data[cropVariantId], { cropArea: cropArea });
+                    _this.updatePreviewThumbnail(variant, $(elem));
+                });
                 _this.currentCropVariant.cropArea = _this.convertRelativeToAbsoluteCropArea(_this.currentCropVariant.cropArea, imageData);
+                // Can't use .t3js-* as selector because it is an extraneous selector
                 _this.cropBox = _this.currentModal.find('.cropper-crop-box');
                 _this.setCropArea(_this.currentCropVariant.cropArea);
                 // Check if new cropVariant has coverAreas
@@ -59,13 +75,18 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                     _this.scaleAndMoveFocusArea(_this.currentCropVariant.focusArea);
                 }
                 if (_this.currentCropVariant.selectedRatio) {
-                    _this.updateAspectRatio(_this.currentCropVariant.allowedAspectRatios[_this.currentCropVariant.selectedRatio]);
-                    // Set data explicitly or updateAspectRatio up-scales the crop
+                    _this.setAspectRatio(_this.currentCropVariant.allowedAspectRatios[_this.currentCropVariant.selectedRatio]);
+                    // Set data explicitly or setAspectRatio up-scales the crop
                     _this.setCropArea(_this.currentCropVariant.cropArea);
                     _this.currentModal.find("[data-option='" + _this.currentCropVariant.selectedRatio + "']").addClass('active');
                 }
                 _this.cropperCanvas.addClass('is-visible');
             };
+            /**
+             * @method cropMoveHandler
+             * @desc Internal cropper handler. Called when the cropping area is moving
+             * @private
+             */
             this.cropMoveHandler = function (e) {
                 _this.currentCropVariant.cropArea = $.extend(true, _this.currentCropVariant.cropArea, {
                     height: Math.floor(e.height),
@@ -73,10 +94,15 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                     x: Math.floor(e.x),
                     y: Math.floor(e.y),
                 });
-                _this.updatePreviewThumbnail(_this.currentCropVariant);
+                _this.updatePreviewThumbnail(_this.currentCropVariant, _this.activeCropVariantTrigger);
                 _this.updateCropVariantData(_this.currentCropVariant);
                 _this.cropInfo.text(_this.currentCropVariant.cropArea.width + "\u00D7" + _this.currentCropVariant.cropArea.height + " px");
             };
+            /**
+             * @method cropStartHandler
+             * @desc Internal cropper handler. Called when the cropping starts moving
+             * @private
+             */
             this.cropStartHandler = function () {
                 if (_this.currentCropVariant.focusArea) {
                     _this.focusArea.draggable('option', 'disabled', true);
@@ -84,7 +110,9 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 }
             };
             /**
-             *
+             * @method cropEndHandler
+             * @desc Internal cropper handler. Called when the cropping ends moving
+             * @private
              */
             this.cropEndHandler = function () {
                 if (_this.currentCropVariant.focusArea) {
@@ -168,11 +196,13 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             $('.t3js-image-manipulation-trigger').off('click').click(triggerHandler);
         };
         /**
-         * Initialize the cropper modal
+         * @method initializeCropperModal
+         * @desc Initialize the cropper modal and dispatch the cropper init
+         * @private
          */
         ImageManipulation.prototype.initializeCropperModal = function () {
             var _this = this;
-            var image = this.currentModal.find('#t3js-crop-image');
+            var image = this.currentModal.find(this.cropImageSelector);
             ImagesLoaded(image, function () {
                 var modal = _this.currentModal.find('.modal-dialog');
                 modal.css({ marginLeft: 'auto', marginRight: 'auto' });
@@ -183,7 +213,13 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 }, 100);
             });
         };
+        /**
+         * @method show
+         * @desc Load the image and setup the modal UI
+         * @private
+         */
         ImageManipulation.prototype.show = function () {
+            var _this = this;
             var modalTitle = this.trigger.data('modalTitle');
             var imageUri = this.trigger.data('url');
             var initCropperModal = this.initializeCropperModal.bind(this);
@@ -192,10 +228,20 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
              */
             this.currentModal = Modal.loadUrl(modalTitle, Severity.notice, [], imageUri, initCropperModal, '.modal-content');
             this.currentModal.addClass('modal-dark');
+            this.currentModal.on('hide.bs.modal', function (e) {
+                _this.destroy();
+            });
+            // Do not dismiss the modal when clicking beside it to avoid data loss
+            this.currentModal.data('bs.modal').options.backdrop = 'static';
         };
+        /**
+         * @method init
+         * @desc Initializes the cropper UI and sets up all the event indings for the UI
+         * @private
+         */
         ImageManipulation.prototype.init = function () {
             var _this = this;
-            var image = this.currentModal.find('#t3js-crop-image');
+            var image = this.currentModal.find(this.cropImageSelector);
             var imageHeight = $(image).height();
             var imageWidth = $(image).width();
             var data = this.trigger.attr('data-crop-variants');
@@ -205,7 +251,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             // If we have data already set we assume an internal reinit eg. after resizing
             this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
             // Initialize our class members
-            this.currentModal.find('.cropper-image-container').css({ height: imageHeight, width: imageWidth });
+            this.currentModal.find(this.cropImageContainerSelector).css({ height: imageHeight, width: imageWidth });
             this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
             this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
             this.cropInfo = this.currentModal.find(this.cropInfoSelector);
@@ -219,7 +265,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             /**
              * Assign EventListener to cropVariantTriggers
              */
-            this.cropVariantTriggers.on('click', function (e) {
+            this.cropVariantTriggers.off('click').on('click', function (e) {
                 /**
                  * Is the current cropVariantTrigger is active, bail out.
                  * Bootstrap doesn't provide this functionality when collapsing the Collaps panels
@@ -241,12 +287,12 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             /**
              * Assign EventListener to aspectRatioTrigger
              */
-            this.aspectRatioTrigger.on('click', function (e) {
+            this.aspectRatioTrigger.off('click').on('click', function (e) {
                 var ratioId = $(e.currentTarget).attr('data-option');
                 var temp = $.extend(true, {}, _this.currentCropVariant);
                 var ratio = temp.allowedAspectRatios[ratioId];
-                _this.updateAspectRatio(ratio);
-                // Set data explicitly or updateAspectRatio upscales the crop
+                _this.setAspectRatio(ratio);
+                // Set data explicitly or setAspectRatio upscales the crop
                 _this.setCropArea(temp.cropArea);
                 _this.currentCropVariant = $.extend(true, {}, temp, { selectedRatio: ratioId });
                 _this.update(_this.currentCropVariant);
@@ -254,14 +300,14 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             /**
              * Assign EventListener to saveButton
              */
-            this.saveButton.on('click', function () {
+            this.saveButton.off('click').on('click', function () {
                 _this.save(_this.data);
             });
             /**
              * Assign EventListener to previewButton if preview url exists
              */
             if (this.trigger.attr('data-preview-url')) {
-                this.previewButton.on('click', function () {
+                this.previewButton.off('click').on('click', function () {
                     _this.openPreview(_this.data);
                 });
             }
@@ -271,13 +317,13 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             /**
              * Assign EventListener to dismissButton
              */
-            this.dismissButton.on('click', function () {
-                _this.destroy();
+            this.dismissButton.off('click').on('click', function () {
+                _this.currentModal.modal('hide');
             });
             /**
              * Assign EventListener to resetButton
              */
-            this.resetButton.on('click', function (e) {
+            this.resetButton.off('click').on('click', function (e) {
                 var imageData = _this.cropper.cropper('getImageData');
                 var resetCropVariantString = $(e.currentTarget).attr('data-crop-variant');
                 e.preventDefault();
@@ -324,7 +370,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             /**
              * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
              */
-            this.updateAspectRatio(selectedRatio);
+            this.setAspectRatio(selectedRatio);
             this.setCropArea(temp.cropArea);
             this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
             this.cropBox.find(this.coverAreaSelector).remove();
@@ -347,12 +393,13 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 // Init or reinit focusArea
                 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
             }
-            this.updatePreviewThumbnail(this.currentCropVariant);
+            this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
         };
         /**
          * @method initFocusArea
          * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
-         * @param container: JQuery
+         * @param {JQuery} container
+         * @private
          */
         ImageManipulation.prototype.initFocusArea = function (container) {
             var _this = this;
@@ -370,7 +417,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                     var _c = _this.currentCropVariant, focusArea = _c.focusArea, coverAreas = _c.coverAreas;
                     focusArea.x = (fLeft - left) / container.width();
                     focusArea.y = (fTop - top) / container.height();
-                    _this.updatePreviewThumbnail(_this.currentCropVariant);
+                    _this.updatePreviewThumbnail(_this.currentCropVariant, _this.activeCropVariantTrigger);
                     if (_this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
                         _this.focusArea.addClass('has-nodrop');
                     }
@@ -414,7 +461,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                     focusArea.width = _this.focusArea.width() / container.width();
                     focusArea.x = (fLeft - left) / container.width();
                     focusArea.y = (fTop - top) / container.height();
-                    _this.updatePreviewThumbnail(_this.currentCropVariant);
+                    _this.updatePreviewThumbnail(_this.currentCropVariant, _this.activeCropVariantTrigger);
                     if (_this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
                         _this.focusArea.addClass('has-nodrop');
                     }
@@ -465,13 +512,15 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
         /**
          * @method updatePreviewThumbnail
          * @desc Sync the croping (and focus area) to the preview thumbnail
-         * @param {CropVariant} cropVariant
+         * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
+         * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
+         * @private
          */
-        ImageManipulation.prototype.updatePreviewThumbnail = function (cropVariant) {
+        ImageManipulation.prototype.updatePreviewThumbnail = function (cropVariant, cropVariantTrigger) {
             var styles;
-            var cropperPreviewThumbnailCrop = this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
-            var cropperPreviewThumbnailImage = this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
-            var cropperPreviewThumbnailFocus = this.activeCropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
+            var cropperPreviewThumbnailCrop = cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
+            var cropperPreviewThumbnailImage = cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
+            var cropperPreviewThumbnailFocus = cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
             var imageData = this.cropper.cropper('getImageData');
             // Update the position/dimension of the crop area in the preview
             cropperPreviewThumbnailCrop.css({
@@ -517,13 +566,14 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 width: ImageManipulation.toCssPercent(focusArea.width),
             });
             this.currentCropVariant.focusArea = focusArea;
-            this.updatePreviewThumbnail(this.currentCropVariant);
+            this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
             this.updateCropVariantData(this.currentCropVariant);
         };
         /**
          * @method updateCropVariantData
          * @desc Immutably updates the currently selected cropVariant data
          * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
+         * @private
          */
         ImageManipulation.prototype.updateCropVariantData = function (currentCropVariant) {
             var imageData = this.cropper.cropper('getImageData');
@@ -531,25 +581,37 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, { cropArea: absoluteCropArea });
         };
         /**
-         * @method updateAspectRatio
-         * @desc Updates the aspect ratio in the cropper
-         * @param {ratio} ratio ratio set in the cropper
+         * @method setAspectRatio
+         * @desc Sets the cropper to a specific ratio
+         * @param {ratio} ratio - The ratio value to apply
+         * @private
          */
-        ImageManipulation.prototype.updateAspectRatio = function (ratio) {
+        ImageManipulation.prototype.setAspectRatio = function (ratio) {
             this.cropper.cropper('setAspectRatio', ratio.value);
         };
         /**
          * @method setCropArea
-         * @desc Updates the crop area in the cropper. The cropper will respect the selected ratio
-         * @param {cropArea} cropArea ratio set in the cropper
+         * @desc Sets the cropper to a specific crop area
+         * @param {cropArea} cropArea - The crop area to apply
+         * @private
          */
         ImageManipulation.prototype.setCropArea = function (cropArea) {
-            this.cropper.cropper('setData', {
-                height: cropArea.height,
-                width: cropArea.width,
-                x: cropArea.x,
-                y: cropArea.y,
-            });
+            var currentRatio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
+            if (currentRatio.value === 0) {
+                this.cropper.cropper('setData', {
+                    height: cropArea.height,
+                    width: cropArea.width,
+                    x: cropArea.x,
+                    y: cropArea.y,
+                });
+            }
+            else {
+                this.cropper.cropper('setData', {
+                    height: cropArea.height,
+                    x: cropArea.x,
+                    y: cropArea.y,
+                });
+            }
         };
         /**
          * @method checkFocusAndCoverAreas
@@ -571,9 +633,11 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             });
         };
         /**
-         * @param cropArea
-         * @param imageData
-         * @return {{height: number, width: number, x: number, y: number}}
+         * @method convertAbsoluteToRelativeCropArea
+         * @desc Converts a crop area from absolute pixel-based into relative length values
+         * @param {Area} cropArea - The crop area to convert from
+         * @param {CropperImageData} imageData - The image data
+         * @return {Area}
          */
         ImageManipulation.prototype.convertAbsoluteToRelativeCropArea = function (cropArea, imageData) {
             var height = cropArea.height, width = cropArea.width, x = cropArea.x, y = cropArea.y;
@@ -585,8 +649,10 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             };
         };
         /**
-         * @param cropArea
-         * @param imageData
+         * @method convertRelativeToAbsoluteCropArea
+         * @desc Converts a crop area from relative into absolute pixel-based length values
+         * @param {Area} cropArea - The crop area to convert from
+         * @param {CropperImageData} imageData - The image data
          * @return {{height: number, width: number, x: number, y: number}}
          */
         ImageManipulation.prototype.convertRelativeToAbsoluteCropArea = function (cropArea, imageData) {
@@ -598,10 +664,16 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
                 y: y * imageData.naturalHeight,
             };
         };
-        ImageManipulation.prototype.setPreviewImage = function (data) {
+        /**
+         * @method setPreviewImages
+         * @desc Updates the preview images in the editing section with the respective crop variants
+         * @param {Object} data - The internal crop variants state
+         */
+        ImageManipulation.prototype.setPreviewImages = function (data) {
             var _this = this;
             var $image = this.cropper;
             var imageData = $image.cropper('getImageData');
+            // Iterate over the crop variants and set up their respective preview
             Object.keys(data).forEach(function (cropVariantId) {
                 var cropVariant = data[cropVariantId];
                 var cropData = _this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
@@ -643,7 +715,7 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
         ;
         /**
          * @method openPreview
-         * @desc open a preview
+         * @desc Opens a preview view with the crop variants
          * @param {object} data - The whole data object containing all the cropVariants
          * @private
          */
@@ -663,9 +735,9 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
             var cropVariants = ImageManipulation.serializeCropVariants(data);
             var hiddenField = $("#" + this.trigger.attr('data-field'));
             this.trigger.attr('data-crop-variants', JSON.stringify(data));
-            this.setPreviewImage(data);
+            this.setPreviewImages(data);
             hiddenField.val(cropVariants);
-            this.destroy();
+            this.currentModal.modal('hide');
         };
         /**
          * @method destroy
@@ -674,18 +746,26 @@ define(["require", "exports", "TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min", "T
          */
         ImageManipulation.prototype.destroy = function () {
             if (this.currentModal) {
-                this.currentModal.modal('hide');
                 this.cropper.cropper('destroy');
+                this.cropper = null;
                 this.currentModal = null;
+                this.data = null;
             }
         };
+        /**
+         * @method resizeEnd
+         * @desc Calls a function when the cropper has been resized
+         * @param {Function} fn - The function to call on resize completion
+         * @private
+         */
         ImageManipulation.prototype.resizeEnd = function (fn) {
+            var _this = this;
             var timer;
             $(window).on('resize', function () {
                 clearTimeout(timer);
                 timer = setTimeout(function () {
                     fn();
-                }, 450);
+                }, _this.resizeTimeout);
             });
         };
         return ImageManipulation;
index e74af9b..54388dd 100644 (file)
@@ -137,6 +137,27 @@ class Area
     }
 
     /**
+     * @param Ratio $ratio
+     * @return Area
+     */
+    public function applyRatioRestriction(Ratio $ratio): Area
+    {
+        if ($ratio->isFree()) {
+            return $this;
+        }
+        $expectedRatio = $ratio->getRatioValue();
+        $newArea = clone $this;
+        if ($newArea->height * $expectedRatio > $newArea->width) {
+            $newArea->height = $newArea->width / $expectedRatio;
+            $newArea->y += ($this->height - $newArea->height) / 2;
+        } else {
+            $newArea->width = $newArea->height * $expectedRatio;
+            $newArea->x += ($this->width - $newArea->width) / 2;
+        }
+        return $newArea;
+    }
+
+    /**
      * @return bool
      */
     public function isEmpty()
index f84ec4b..1645148 100644 (file)
@@ -15,6 +15,8 @@ namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Resource\FileInterface;
+
 class CropVariant
 {
     /**
@@ -76,8 +78,12 @@ class CropVariant
         $this->cropArea = $cropArea;
         if ($allowedAspectRatios) {
             $this->setAllowedAspectRatios(...$allowedAspectRatios);
+            if ($selectedRatio && isset($this->allowedAspectRatios[$selectedRatio])) {
+                $this->selectedRatio = $selectedRatio;
+            } else {
+                $this->selectedRatio = current($this->allowedAspectRatios)->getId();
+            }
         }
-        $this->selectedRatio = $selectedRatio;
         $this->focusArea = $focusArea;
         if ($coverAreas !== null) {
             $this->setCoverAreas(...$coverAreas);
@@ -159,6 +165,22 @@ class CropVariant
     }
 
     /**
+     * @param FileInterface $file
+     * @return CropVariant
+     */
+    public function applyRatioRestrictionToSelectedCropArea(FileInterface $file): CropVariant
+    {
+        if (!$this->selectedRatio) {
+            return $this;
+        }
+        $newVariant = clone $this;
+        $newArea = $this->cropArea->makeAbsoluteBasedOnFile($file);
+        $newArea = $newArea->applyRatioRestriction($this->allowedAspectRatios[$this->selectedRatio]);
+        $newVariant->cropArea = $newArea->makeRelativeBasedOnFile($file);
+        return $newVariant;
+    }
+
+    /**
      * @param Ratio[] $ratios
      * @throws InvalidConfigurationException
      */
index c60b868..7327a68 100644 (file)
@@ -15,6 +15,8 @@ namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Resource\FileInterface;
+
 class CropVariantCollection
 {
     /**
@@ -38,17 +40,17 @@ class CropVariantCollection
      */
     public static function create(string $jsonString, array $tcaConfig = []): CropVariantCollection
     {
-        if (empty($jsonString) && empty($tcaConfig)) {
+        $persistedCollectionConfig = empty($jsonString) ? [] : json_decode($jsonString, true);
+        if (empty($persistedCollectionConfig) && empty($tcaConfig)) {
             return self::createEmpty();
         }
-        $persistedCollectionConfig = json_decode($jsonString, true);
-        if (!is_array($persistedCollectionConfig)) {
-            $persistedCollectionConfig = [];
-        }
         try {
             if ($tcaConfig === []) {
-                $tcaConfig = $persistedCollectionConfig;
+                $tcaConfig = (array)$persistedCollectionConfig;
             } else {
+                if (!is_array($persistedCollectionConfig)) {
+                    $persistedCollectionConfig = [];
+                }
                 // Merge selected areas with crop tool configuration
                 reset($persistedCollectionConfig);
                 foreach ($tcaConfig as $id => &$cropVariantConfig) {
@@ -92,6 +94,34 @@ class CropVariantCollection
     }
 
     /**
+     * @param FileInterface $file
+     * @return CropVariantCollection
+     */
+    public function applyRatioRestrictionToSelectedCropArea(FileInterface $file): CropVariantCollection
+    {
+        $newCollection = clone $this;
+        foreach ($this->cropVariants as $id => $cropVariant) {
+            $newCollection->cropVariants[$id] = $cropVariant->applyRatioRestrictionToSelectedCropArea($file);
+        }
+        return $newCollection;
+    }
+
+    public function __toString()
+    {
+        $filterNonPersistentKeys = function ($key) {
+            if (in_array($key, ['id', 'title', 'allowedAspectRatios', 'coverAreas'], true)) {
+                return false;
+            }
+            return true;
+        };
+        $cropVariantsAsArray = [];
+        foreach ($this->cropVariants as $id => $cropVariant) {
+            $cropVariantsAsArray[$id] = array_filter($cropVariant->asArray(), $filterNonPersistentKeys, ARRAY_FILTER_USE_KEY);
+        }
+        return json_encode($cropVariantsAsArray);
+    }
+
+    /**
      * @param string $id
      * @return Area
      */
index e88f56a..d13b41b 100644 (file)
@@ -79,4 +79,20 @@ class Ratio
             'value' => $this->value,
         ];
     }
+
+    /**
+     * @return float
+     */
+    public function getRatioValue(): float
+    {
+        return $this->value;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isFree(): bool
+    {
+        return $this->value === 0.0;
+    }
 }
diff --git a/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/AreaTest.php b/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/AreaTest.php
new file mode 100644 (file)
index 0000000..b3c0be4
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Imaging\ImageManipulation;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\Ratio;
+use TYPO3\CMS\Core\Resource\File;
+use TYPO3\CMS\Core\Resource\ResourceStorage;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+class AreaTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function makeRelativeToFileReducesSizes()
+    {
+        $imageArea = new Area(50.0, 50.0, 100.0, 100.0);
+        $imageFixture = new File(
+            [],
+            $this->getMockBuilder(ResourceStorage::class)->disableOriginalConstructor()->getMock(),
+            ['width' => 100, 'height' => 200]
+        );
+        $relativeArea = $imageArea->makeRelativeBasedOnFile($imageFixture);
+        $expectedResult = [
+            'x' => 0.5,
+            'y' => 0.25,
+            'width' => 1.0,
+            'height' => 0.5,
+        ];
+        $this->assertSame($expectedResult, $relativeArea->asArray());
+    }
+
+    public function applyRatioRestrictsAreaToRespectRatioDataProvider()
+    {
+        return [
+            [
+                [0.0, 0.0, 1, 1],
+                4 / 3
+            ],
+            [
+                [0.0, 0.0, 1, 1],
+                3 / 4
+            ],
+            [
+                [0.1, 0.1, 0.2, 0.4],
+                4 / 3,
+            ],
+            [
+                [0.1, 0.1, 0.4, 0.2],
+                1.0
+            ],
+        ];
+    }
+
+    /**
+     * @param array $areaSize
+     * @param $ratio
+     * @test
+     * @dataProvider applyRatioRestrictsAreaToRespectRatioDataProvider
+     */
+    public function applyRatioRestrictsAreaToRespectRatio(array $areaSize, $ratio)
+    {
+        $area = new Area(...$areaSize);
+        $ratioFixture = new Ratio('dummy', 'dummy', $ratio);
+        $areaData = $area->applyRatioRestriction($ratioFixture)->asArray();
+        $this->assertSame($areaData['width'] / $areaData['height'], $ratio);
+    }
+
+    /**
+     * @test
+     */
+    public function applyRatioDoesNothingForFreeRatio()
+    {
+        $area = new Area(...[0.1, 0.1, 0.2, 0.4]);
+        $ratioFixture = new Ratio('dummy', 'dummy', 0.0);
+        $croppedArea = $area->applyRatioRestriction($ratioFixture);
+        $this->assertSame($area, $croppedArea);
+    }
+}