diff --git a/Build/Resources/Public/Less/TYPO3/_element_cropper.less b/Build/Resources/Public/Less/TYPO3/_element_cropper.less index 0404c0148477db01c776de6d8f3f2b533ecf42fa..853a93bc30edfc894ec45eaf1fdd5c30849bade2 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 903affac09ad09691ecbf621e249d896d61fc3c8..3d2b9ff8cb3fd5c77d41dedf4f274c52101845b1 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 a176c5e62d0dd8b1860c94c54a16b1ca8b75f2e7..112d956bd0d98b581c248a76ddd21c0205f665a4 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 0000000000000000000000000000000000000000..05351ecbc8ba66a0f88e352109805d440575fc33 --- /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 0000000000000000000000000000000000000000..f251983a57402251a06270419727bd4ce2d0e428 --- /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 0000000000000000000000000000000000000000..61d526f6a8f5cde4cb72f8f9d0b9d3759ac8daef --- /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 7a2a03dc7ea1a33d5f72a9032eff8a7afacb4e4e..fd9e97ac192456d88324a850f04c5216e08bc13b 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 722a0b39caa37871e426a9fe2a3b2da1daf90d1d..956aaa65402d39eb29df5a79d54946467fff831c 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 fd6907c5046ce9a49f080ec6f0d933d0c06d610b..25ea3324f8a45cab4d269e3053f90218d1488173 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 e74af9bebecc4c890cbdff9e3977241fdfd8565f..54388dd5e5c22021cc19b3953b287ca76ea4ed9f 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 f84ec4b13a634669001ff9e644c3ade6269ffed1..1645148ad7b5656b83bb6be226c72dc26b9fa787 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 c60b8681e4e74f96379a82d1e38f1d08f773ac95..7327a68727a8720a3caf336a578487c8cee939e6 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 e88f56a99baa279dcee80ea689283b62c0fff9f0..d13b41b8eaf8bad5ef1b734c97321516afd02f17 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 0000000000000000000000000000000000000000..b3c0be4110b015090c85adb4ecb8ab5d96b27123 --- /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); + } +}