Skip to content

Commit

Permalink
[FEATURE] Add new imageManipulation supporting multiple crop variants
Browse files Browse the repository at this point in the history
This feature extends the image cropping tool in the backend
so that editors can now not only select one crop area,
but multiple ones per image.

Within the crop are now also a focus are can be selected
and to preview areas that will be covered once the image
is rendered in the frontend one or more cover areas can be configured
to be shown inside the crop area.

This change also adds a format.json view helper and a view helper
to generate backend URIs that are used in the now fully Fluid rendered
imageManipulation element.

This is the TYPO3 integration part. TypeScript and CSS
will be done in a second commit.

Resolves: #75880
Releases: master
Change-Id: I646f0f0a149d05d1f3d8283bcc92ab09aede768e
Reviewed-on: https://review.typo3.org/51515
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Frans Saris <franssaris@gmail.com>
Tested-by: Frans Saris <franssaris@gmail.com>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Andreas Fernandez <typo3@scripting-base.de>
Tested-by: Andreas Fernandez <typo3@scripting-base.de>
  • Loading branch information
helhum authored and Andreas Fernandez committed Feb 7, 2017
1 parent 6b5e34d commit 0639383
Show file tree
Hide file tree
Showing 22 changed files with 1,986 additions and 483 deletions.
366 changes: 174 additions & 192 deletions typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php

Large diffs are not rendered by default.

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace TYPO3\CMS\Backend\Form\Wizard;

/*
Expand Down Expand Up @@ -28,9 +29,21 @@
class ImageManipulationWizard
{
/**
* @var string
* @var StandaloneView
*/
protected $templatePath = 'EXT:backend/Resources/Private/Templates/';
private $templateView;

/**
* @param StandaloneView $templateView
*/
public function __construct(StandaloneView $templateView = null)
{
if (!$templateView) {
$templateView = GeneralUtility::makeInstance(StandaloneView::class);
$templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html'));
}
$this->templateView = $templateView;
}

/**
* Returns the HTML for the wizard inside the modal
Expand All @@ -41,77 +54,38 @@ class ImageManipulationWizard
*/
public function getWizardAction(ServerRequestInterface $request, ResponseInterface $response)
{
if ($this->isValidToken($request)) {
$queryParams = $request->getQueryParams();
$fileUid = isset($request->getParsedBody()['file']) ? $request->getParsedBody()['file'] : $queryParams['file'];
if ($this->isSignatureValid($request)) {
$queryParams = json_decode($request->getQueryParams()['arguments'], true);
$fileUid = $queryParams['image'];
$image = null;
if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
try {
$image = ResourceFactory::getInstance()->getFileObject($fileUid);
} catch (FileDoesNotExistException $e) {
}
}

$view = $this->getFluidTemplateObject($this->templatePath . 'Wizards/ImageManipulationWizard.html');
$view->assign('image', $image);
$view->assign('zoom', (bool)$queryParams['zoom']);
$view->assign('ratios', $this->getAvailableRatios($request));
$content = $view->render();

$viewData = [
'image' => $image,
'cropVariants' => $queryParams['cropVariants']
];
$content = $this->templateView->renderSection('Cropper', $viewData);
$response->getBody()->write($content);

return $response;
} else {
return $response->withStatus(403);
}
}

/**
* Check if hmac token is correct
* Check if hmac signature is correct
*
* @param ServerRequestInterface $request the request with the GET parameters
* @return bool
*/
protected function isValidToken(ServerRequestInterface $request)
protected function isSignatureValid(ServerRequestInterface $request)
{
$parameters = [
'zoom' => $request->getQueryParams()['zoom'] ? '1' : '0',
'ratios' => $request->getQueryParams()['ratios'] ?: '',
'file' => $request->getQueryParams()['file'] ?: '',
];

$token = GeneralUtility::hmac(implode('|', $parameters), 'ImageManipulationWizard');
return $token === $request->getQueryParams()['token'];
}

/**
* Get available ratios
*
* @param ServerRequestInterface $request
* @return array
*/
protected function getAvailableRatios(ServerRequestInterface $request)
{
$ratios = json_decode($request->getQueryParams()['ratios']);
// Json transforms an array with string keys to an array,
// we need to transform this to an array for the fluid ForViewHelper
if (is_object($ratios)) {
$ratios = get_object_vars($ratios);
}
return $ratios;
}

/**
* Returns a new standalone view, shorthand function
*
* @param string $templatePathAndFileName optional the path to set the template path and filename
* @return StandaloneView
*/
protected function getFluidTemplateObject($templatePathAndFileName = null)
{
$view = GeneralUtility::makeInstance(StandaloneView::class);
if ($templatePathAndFileName) {
$view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName($templatePathAndFileName));
}
return $view;
$token = GeneralUtility::hmac($request->getQueryParams()['arguments'], 'ajax_wizard_image_manipulation');
return $token === $request->getQueryParams()['signature'];
}
}
@@ -0,0 +1,183 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers">

<f:section name="Element">
<div class="media">
<f:if condition="{isAllowedFileExtension}">
<f:then>
<div class="media-left">
<f:for each="{config.cropVariants}" as="cropVariant">
<div class="t3js-image-manipulation-preview media-object" data-preview-width="150" data-preview-height="200" data-crop-variant-id="{cropVariant.id}">
<f:image image="{image}" crop="{formEngine.field.value}" cropVariant="{cropVariant.id}" maxWidth="150" maxHeight="200" class="thumbnail thumbnail-status" additionalAttributes="{data-crop-variant: '{cropVariant -> f:format.json()}', data-crop-variant-id: cropVariant.id}" />
</div>
</f:for>
</div>
<div class="media-body">
<input type="hidden" id="{formEngine.field.id}" name="{formEngine.field.name}" value="{formEngine.field.value}" data-formengine-validation-rules="{formEngine.validation}" />
<button class="btn btn-default t3js-image-manipulation-trigger"
data-url="{wizardUri}"
data-preview-url="{previewUrl}"
data-severity="notice"
data-modal-title="{f:render(section: 'ModalTitle', arguments: _all)}"
data-image-uid="{image.uid}"
data-crop-variants="{config.cropVariants -> f:format.json()}"
data-file-field="{config.file_field}"
data-field="{formEngine.field.id}">
<span class="t3-icon fa fa-crop"></span><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.open-editor" />
</button>
<f:if condition="{crop}" >
<div class="table-fit-block table-spacer-wrap">
<table class="table table-no-borders t3js-image-manipulation-info">
<tr>
<td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width" /></td>
<td class="t3js-image-manipulation-info-crop-width">{crop.width}px</td>
</tr>
<tr>
<td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height" /></td>
<td class="t3js-image-manipulation-info-crop-height">{crop.height}px</td>
</tr>
</table>
</div>
</f:if>
</div>
</f:then>
<f:else>
<div class="media-body">
<p><em>
<f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.supported-types-message" />
<br/>
{config.allowedExtensions -> f:format.case(mode: 'upper')}
</em></p>
</div>
</f:else>
</f:if>
</div>
</f:section>
<f:section name="Cropper">
<f:if condition="{image.properties.width}">
<f:then>
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
</button>
<h4 class="modal-title">
{f:render(section: 'ModalTitle', arguments: _all)}
</h4>
</div>
<div class="cropper modal-panel">
<div class="modal-panel-body">
<div class="cropper-image-container">
<img id="t3js-crop-image" class="cropper-image-container-image"
src="{f:uri.image(image:image, maxWidth:'1000', maxHeight: '700')}"
data-original-width="{image.properties.width}" data-original-height="{image.properties.height}"/>
</div>
</div>
<div class="modal-panel-sidebar modal-panel-sidebar-right">
<div class="modal-body">
<div class="panel-group" id="accordion-cropper-variants" role="tablist" aria-multiselectable="true">
<f:for each="{cropVariants}" as="cropVariant" iteration="cropVariantIterator">
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="cropper-accordion-heading-{cropVariantIterator.cycle}">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#accordion-cropper-variants"
href="#cropper-collapse-{cropVariantIterator.cycle}"
aria-expanded="{f:if(condition:cropVariantIterator.isFirst, then:'true', else:'false')}"
aria-controls="cropper-collapse-{cropVariantIterator.cycle}"
class="t3js-crop-variant-trigger {f:if(condition:cropVariantIterator.isFirst, then:'is-active', else:'collapsed')}"
data-crop-variant-id="{cropVariant.id}"
data-crop-variant>
<span><i class="fa fa-chevron-{f:if(condition:cropVariantIterator.isFirst, then:'up', else:'down')}"
aria-hidden="true"></i> {cropVariant.title -> f:translate(id: cropVariant.title)}</span>
<div
class="cropper-preview-thumbnail {f:if(condition:'{image.properties.width}>{image.properties.height}', then:'wide', else: 'tall')}">
<img class="cropper-preview-thumbnail-image"
src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}">
<div class="cropper-preview-thumbnail-crop-area t3js-cropper-preview-thumbnail-crop-area">
<img src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}"
class="cropper-preview-thumbnail-crop-image t3js-cropper-preview-thumbnail-crop-image">
<div class="cropper-preview-thumbnail-focus-area t3js-cropper-preview-thumbnail-focus-area"></div>
</div>
</div>
</a>
</h4>
</div>
<div id="cropper-collapse-{cropVariantIterator.cycle}"
class="panel-collapse collapse {f:if(condition:cropVariantIterator.isFirst, then:'in')}"
role="tabpanel"
aria-labelledby="cropper-accordion-heading-{cropVariantIterator.cycle}">
<div class="panel-body">
<form class="form">
<div class="form-group">
<f:if condition="{cropVariant.allowedAspectRatios}">
<label for="ratio-{cropVariantIterator.cycle}">
<f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.aspect-ratio"/>
</label>
<div id="ratio-{cropVariantIterator.cycle}" class="ratio-buttons t3js-ratio-buttons"
data-toggle="buttons">
<f:for each="{cropVariant.allowedAspectRatios}" as="ratio" iteration="ratioIterator">
<label class="btn btn-secondary" data-method="setAspectRatio" data-option="{ratio.id}" title="{f:translate(id:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.set-aspect-ratio')}">
<input
class="sr-only" id="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}"
name="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}" value="{cropVariant.id}"
type="radio">
<span>{ratio.title -> f:translate(id: ratio.title)}</span> <i class="fa fa-check"></i></label>
</f:for>
</div>
</f:if>
<label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.selection" /></label>
<div class="table-fit-block">
<table class="table table-no-borders table-transparent">
<tr>
<td class="t3js-cropper-info-crop"></td>
</tr>
</table>
</div>
<button class="btn btn-secondary" data-method="reset" data-crop-variant="{cropVariant -> f:format.json()}"
title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}">
<i class="fa fa-refresh"></i>
{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}
</button>
</div>
</form>
</div>
</div>
</div>
</f:for>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default pull-left" data-method="preview" title="Preview"><i
class="fa fa-eye"></i>
Preview
</button>
<button class="btn btn-default" data-method="dismiss"
title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}">
<i class="fa fa-remove"></i>
{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}
</button>
<button class="btn btn-primary" data-method="save"
title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}">
<i class="fa fa-check"></i>
{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}
</button>
</div>
</f:then>
<f:else>
<div class="alert alert-danger">
<h4 class="alert-title">
<f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found"/>
</h4>
<p class="alert-message">
<f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found-message"/>
</p>
</div>
</f:else>
</f:if>
</f:section>
<f:section name="ModalTitle">
{f:translate(id: 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-manipulation')}
: {f:if(condition:image.properties.title, then:image.properties.title, else:image.name)}
({image.properties.width} × {image.properties.height})
</f:section>
</html>

0 comments on commit 0639383

Please sign in to comment.