From f4a73f2f10263b1a23d7b7a693bbf3adb6351fbc Mon Sep 17 00:00:00 2001 From: Helmut Hummel <typo3@helhum.io> Date: Tue, 7 Feb 2017 21:12:16 +0100 Subject: [PATCH] [BUGFIX] Eliminate gremlins in image cropper 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> --- .../Public/Less/TYPO3/_element_cropper.less | 2 +- .../Form/Element/ImageManipulationElement.php | 45 +++-- .../Form/Wizard/ImageManipulationWizard.php | 6 +- .../Private/Layouts/ImageManipulation.html | 16 ++ .../ImageManipulation/ModalTitle.html | 9 + .../ImageManipulationElement.html | 61 ++++++ ...ping.html => ImageManipulationWizard.html} | 67 +------ .../Private/TypeScript/ImageManipulation.ts | 186 +++++++++++++----- .../Public/JavaScript/ImageManipulation.js | 182 ++++++++++++----- .../Imaging/ImageManipulation/Area.php | 21 ++ .../Imaging/ImageManipulation/CropVariant.php | 24 ++- .../CropVariantCollection.php | 42 +++- .../Imaging/ImageManipulation/Ratio.php | 16 ++ .../Imaging/ImageManipulation/AreaTest.php | 93 +++++++++ 14 files changed, 577 insertions(+), 193 deletions(-) create mode 100644 typo3/sysext/backend/Resources/Private/Layouts/ImageManipulation.html create mode 100644 typo3/sysext/backend/Resources/Private/Partials/ImageManipulation/ModalTitle.html create mode 100644 typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html rename typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/{ImageCropping.html => ImageManipulationWizard.html} (66%) create mode 100644 typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/AreaTest.php diff --git a/Build/Resources/Public/Less/TYPO3/_element_cropper.less b/Build/Resources/Public/Less/TYPO3/_element_cropper.less index 0404c0148477..853a93bc30ed 100644 --- a/Build/Resources/Public/Less/TYPO3/_element_cropper.less +++ b/Build/Resources/Public/Less/TYPO3/_element_cropper.less @@ -442,4 +442,4 @@ max-width: none !important; max-height: none !important; } -} \ No newline at end of file +} diff --git a/typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php b/typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php index 903affac09ad..3d2b9ff8cb3f 100644 --- a/typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php +++ b/typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php @@ -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; } diff --git a/typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php b/typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php index a176c5e62d0d..112d956bd0d9 100644 --- a/typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php +++ b/typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php @@ -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 index 000000000000..05351ecbc8ba --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Layouts/ImageManipulation.html @@ -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 index 000000000000..f251983a5740 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Partials/ImageManipulation/ModalTitle.html @@ -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} — {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/ImageManipulationElement.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html new file mode 100644 index 000000000000..61d526f6a8f5 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationElement.html @@ -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/ImageCropping.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html similarity index 66% rename from typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html rename to typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html index 7a2a03dc7ea1..fd9e97ac1924 100644 --- a/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html +++ b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageManipulationWizard.html @@ -1,74 +1,20 @@ <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: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(section: 'ModalTitle', arguments: _all)} + {f:render(partial: 'ModalTitle', section:'Main', 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 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"> @@ -175,9 +121,4 @@ </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/TypeScript/ImageManipulation.ts b/typo3/sysext/backend/Resources/Private/TypeScript/ImageManipulation.ts index 722a0b39caa3..956aaa65402d 100644 --- a/typo3/sysext/backend/Resources/Private/TypeScript/ImageManipulation.ts +++ b/typo3/sysext/backend/Resources/Private/TypeScript/ImageManipulation.ts @@ -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); }); } } diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/ImageManipulation.js b/typo3/sysext/backend/Resources/Public/JavaScript/ImageManipulation.js index fd6907c5046c..25ea3324f8a4 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/ImageManipulation.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/ImageManipulation.js @@ -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; diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php index e74af9bebecc..54388dd5e5c2 100644 --- a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php +++ b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php @@ -136,6 +136,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 */ diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php index f84ec4b13a63..1645148ad7b5 100644 --- a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php +++ b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php @@ -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); @@ -158,6 +164,22 @@ class CropVariant return $this->focusArea; } + /** + * @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 diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php index c60b8681e4e7..7327a68727a8 100644 --- a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php +++ b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php @@ -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) { @@ -91,6 +93,34 @@ class CropVariantCollection return $cropVariantsAsArray; } + /** + * @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 diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php index e88f56a99baa..d13b41b8eaf8 100644 --- a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php +++ b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php @@ -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 index 000000000000..b3c0be4110b0 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/AreaTest.php @@ -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); + } +} -- GitLab