Issue #2421427 by samuel.mortenson, droplet, dawehner, nod_, Cottser, Wim Leers, xjm, Gábor Hojtsy, Bojhan, tstoeckler, webchick, naveenvalecha, alexpott, LewisNyman, chris_h, Manjit.Singh, phenaproxima, avitslv, yoroy, tim.plunkett, Mixologic, ipwa, slashrsm: Improve the UX of Quick Editing single-valued image fields

8.3.x
webchick 2016-11-15 12:57:16 -08:00
parent 4f8869ca9f
commit 24eb0704ad
20 changed files with 1431 additions and 0 deletions

View File

@ -0,0 +1,52 @@
/**
* @file
* Functional styles for the Image module's in-place editor.
*/
/**
* A minimum width/height is required so that users can drag and drop files
* onto small images.
*/
.quickedit-image-element {
min-width: 200px;
min-height: 200px;
}
.quickedit-image-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.quickedit-image-icon {
display: block;
width: 50px;
height: 50px;
background-repeat: no-repeat;
background-size: cover;
}
.quickedit-image-field-info {
display: flex;
align-items: center;
justify-content: flex-end;
}
.quickedit-image-text {
display: block;
}
/**
* If we do not prevent pointer-events for child elements, our drag+drop events
* will not fire properly. This can lead to unintentional redirects if a file
* is dropped on a child element when a user intended to upload it.
*/
.quickedit-image-dropzone * {
pointer-events: none;
}

View File

@ -0,0 +1,100 @@
/**
* @file
* Theme styles for the Image module's in-place editor.
*/
.quickedit-image-dropzone {
background: rgba(116, 183, 255, 0.8);
transition: background .2s;
}
.quickedit-image-icon {
margin: 0 0 10px 0;
transition: margin .5s;
}
.quickedit-image-dropzone.hover {
background: rgba(116, 183, 255, 0.9);
}
.quickedit-image-dropzone.error {
background: rgba(255, 52, 27, 0.81);
}
.quickedit-image-dropzone.upload .quickedit-image-icon {
background-image: url('../../images/upload.svg');
}
.quickedit-image-dropzone.error .quickedit-image-icon {
background-image: url('../../images/error.svg');
}
.quickedit-image-dropzone.loading .quickedit-image-icon {
margin: -10px 0 20px 0;
}
.quickedit-image-dropzone.loading .quickedit-image-icon::after {
display: block;
content: "";
margin-left: -10px;
margin-top: -5px;
animation-duration: 2s;
animation-name: quickedit-image-spin;
animation-iteration-count: infinite;
animation-timing-function: linear;
width: 60px;
height: 60px;
border-style: solid;
border-radius: 35px;
border-width: 5px;
border-color: white transparent transparent transparent;
}
@keyframes quickedit-image-spin {
0% {transform: rotate(0deg);}
50% {transform: rotate(180deg);}
100% {transform: rotate(360deg);}
}
.quickedit-image-text {
text-align: center;
color: white;
font-family: "Droid sans", "Lucida Grande", sans-serif;
font-size: 16px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.quickedit-image-field-info {
background: rgba(0, 0, 0, 0.05);
border-top: 1px solid #c5c5c5;
padding: 5px;
}
.quickedit-image-field-info div {
margin-right: 10px; /* LTR */
}
.quickedit-image-field-info div:last-child {
margin-right: 0; /* LTR */
}
[dir="rtl"] .quickedit-image-field-info div {
margin-left: 10px;
margin-right: 0;
}
[dir="rtl"] .quickedit-image-field-info div:last-child {
margin-left: 0;
}
.quickedit-image-errors .messages__wrapper {
margin: 0;
padding: 0;
}
.quickedit-image-errors .messages--error {
box-shadow: none;
}

View File

@ -61,3 +61,10 @@ function image_requirements($phase) {
return $requirements; return $requirements;
} }
/**
* Flush caches as we changed field formatter metadata.
*/
function image_update_8201() {
// Empty update to trigger a cache flush.
}

View File

@ -3,3 +3,19 @@ admin:
css: css:
theme: theme:
css/image.admin.css: {} css/image.admin.css: {}
quickedit.inPlaceEditor.image:
version: VERSION
js:
js/editors/image.js: {}
js/theme.js: {}
css:
component:
css/editors/image.css: {}
theme:
css/editors/image.theme.css: {}
dependencies:
- core/jquery
- core/drupal
- core/underscore
- quickedit/quickedit

View File

@ -71,3 +71,29 @@ image.effect_edit_form:
route_callbacks: route_callbacks:
- '\Drupal\image\Routing\ImageStyleRoutes::routes' - '\Drupal\image\Routing\ImageStyleRoutes::routes'
image.upload:
path: '/quickedit/image/upload/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
defaults:
_controller: '\Drupal\image\Controller\QuickEditImageController::upload'
options:
parameters:
entity:
type: entity:{entity_type}
requirements:
_permission: 'access in-place editing'
_access_quickedit_entity_field: 'TRUE'
_method: 'POST'
image.info:
path: '/quickedit/image/info/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
defaults:
_controller: '\Drupal\image\Controller\QuickEditImageController::getInfo'
options:
parameters:
entity:
type: entity:{entity_type}
requirements:
_permission: 'access in-place editing'
_access_quickedit_entity_field: 'TRUE'
_method: 'GET'

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

View File

@ -0,0 +1,342 @@
/**
* @file
* Drag+drop based in-place editor for images.
*/
(function ($, _, Drupal) {
'use strict';
Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.image# */{
/**
* @constructs
*
* @augments Drupal.quickedit.EditorView
*
* @param {object} options
* Options for the image editor.
*/
initialize: function (options) {
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
// Set our original value to our current HTML (for reverting).
this.model.set('originalValue', this.$el.html().trim());
// $.val() callback function for copying input from our custom form to
// the Quick Edit Field Form.
this.model.set('currentValue', function (index, value) {
var matches = $(this).attr('name').match(/(alt|title)]$/);
if (matches) {
var name = matches[1];
var $toolgroup = $('#' + options.fieldModel.toolbarView.getMainWysiwygToolgroupId());
var $input = $toolgroup.find('.quickedit-image-field-info input[name="' + name + '"]');
if ($input.length) {
return $input.val();
}
}
});
},
/**
* @inheritdoc
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The field model that holds the state.
* @param {string} state
* The state to change to.
* @param {object} options
* State options, if needed by the state change.
*/
stateChange: function (fieldModel, state, options) {
var from = fieldModel.previous('state');
switch (state) {
case 'inactive':
break;
case 'candidate':
if (from !== 'inactive') {
this.$el.find('.quickedit-image-dropzone').remove();
this.$el.removeClass('quickedit-image-element');
}
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
break;
case 'activating':
// Defer updating the field model until the current state change has
// propagated, to not trigger a nested state change event.
_.defer(function () {
fieldModel.set('state', 'active');
});
break;
case 'active':
var self = this;
// Indicate that this element is being edited by Quick Edit Image.
this.$el.addClass('quickedit-image-element');
// Render our initial dropzone element. Once the user reverts changes
// or saves a new image, this element is removed.
var $dropzone = this.renderDropzone('upload', Drupal.t('Drop file here or click to upload'));
$dropzone.on('dragenter', function (e) {
$(this).addClass('hover');
});
$dropzone.on('dragleave', function (e) {
$(this).removeClass('hover');
});
$dropzone.on('drop', function (e) {
// Only respond when a file is dropped (could be another element).
if (e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files.length) {
$(this).removeClass('hover');
self.uploadImage(e.originalEvent.dataTransfer.files[0]);
}
});
$dropzone.on('click', function (e) {
// Create an <input> element without appending it to the DOM, and
// trigger a click event. This is the easiest way to arbitrarily
// open the browser's upload dialog.
$('<input type="file">')
.trigger('click')
.on('change', function () {
if (this.files.length) {
self.uploadImage(this.files[0]);
}
});
});
// Prevent the browser's default behavior when dragging files onto
// the document (usually opens them in the same tab).
$dropzone.on('dragover dragenter dragleave drop click', function (e) {
e.preventDefault();
e.stopPropagation();
});
this.renderToolbar(fieldModel);
break;
case 'changed':
break;
case 'saving':
if (from === 'invalid') {
this.removeValidationErrors();
}
this.save(options);
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
break;
}
},
/**
* Validates/uploads a given file.
*
* @param {File} file
* The file to upload.
*/
uploadImage: function (file) {
// Indicate loading by adding a special class to our icon.
this.renderDropzone('upload loading', Drupal.t('Uploading <i>@file</i>…', {'@file': file.name}));
// Build a valid URL for our endpoint.
var fieldID = this.fieldModel.get('fieldID');
var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode'));
// Construct form data that our endpoint can consume.
var data = new FormData();
data.append('files[image]', file);
// Construct a POST request to our endpoint.
var self = this;
this.ajax({
type: 'POST',
url: url,
data: data,
success: function (response) {
var $el = $(self.fieldModel.get('el'));
// Indicate that the field has changed - this enables the
// "Save" button.
self.fieldModel.set('state', 'changed');
self.fieldModel.get('entity').set('inTempStore', true);
self.removeValidationErrors();
// Replace our html with the new image. If we replaced our entire
// element with data.html, we would have to implement complicated logic
// like what's in Drupal.quickedit.AppView.renderUpdatedField.
var $content = $(response.html).closest('[data-quickedit-field-id]').children();
$el.empty().append($content);
}
});
},
/**
* Utility function to make an AJAX request to the server.
*
* In addition to formatting the correct request, this also handles error
* codes and messages by displaying them visually inline with the image.
*
* Drupal.ajax is not called here as the Form API is unused by this
* in-place editor, and our JSON requests/responses try to be
* editor-agnostic. Ideally similar logic and routes could be used by
* modules like CKEditor for drag+drop file uploads as well.
*
* @param {object} options
* Ajax options.
* @param {string} options.type
* The type of request (i.e. GET, POST, PUT, DELETE, etc.)
* @param {string} options.url
* The URL for the request.
* @param {*} options.data
* The data to send to the server.
* @param {function} options.success
* A callback function used when a request is successful, without errors.
*/
ajax: function (options) {
var defaultOptions = {
context: this,
dataType: 'json',
cache: false,
contentType: false,
processData: false,
error: function () {
this.renderDropzone('error', Drupal.t('A server error has occurred.'));
}
};
var ajaxOptions = $.extend(defaultOptions, options);
var successCallback = ajaxOptions.success;
// Handle the success callback.
ajaxOptions.success = function (response) {
if (response.main_error) {
this.renderDropzone('error', response.main_error);
if (response.errors.length) {
this.model.set('validationErrors', response.errors);
}
this.showValidationErrors();
}
else {
successCallback(response);
}
};
$.ajax(ajaxOptions);
},
/**
* Renders our toolbar form for editing metadata.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The current Field Model.
*/
renderToolbar: function (fieldModel) {
var $toolgroup = $('#' + fieldModel.toolbarView.getMainWysiwygToolgroupId());
var $toolbar = $toolgroup.find('.quickedit-image-field-info');
if ($toolbar.length === 0) {
// Perform an AJAX request for extra image info (alt/title).
var fieldID = fieldModel.get('fieldID');
var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode'));
var self = this;
self.ajax({
type: 'GET',
url: url,
success: function (response) {
$toolbar = $(Drupal.theme.quickeditImageToolbar(response));
$toolgroup.append($toolbar);
$toolbar.on('keyup paste', function () {
fieldModel.set('state', 'changed');
});
// Re-position the toolbar, which could have changed size.
fieldModel.get('entity').toolbarView.position();
}
});
}
},
/**
* Renders our dropzone element.
*
* @param {string} state
* The current state of our editor. Only used for visual styling.
* @param {string} text
* The text to display in the dropzone area.
*
* @return {jQuery}
* The rendered dropzone.
*/
renderDropzone: function (state, text) {
var $dropzone = this.$el.find('.quickedit-image-dropzone');
// If the element already exists, modify its contents.
if ($dropzone.length) {
$dropzone
.removeClass('upload error hover loading')
.addClass('.quickedit-image-dropzone ' + state)
.children('.quickedit-image-text')
.html(text);
}
else {
$dropzone = $(Drupal.theme('quickeditImageDropzone', {
state: state,
text: text
}));
this.$el.append($dropzone);
}
return $dropzone;
},
/**
* @inheritdoc
*/
revert: function () {
this.$el.html(this.model.get('originalValue'));
},
/**
* @inheritdoc
*/
getQuickEditUISettings: function () {
return {padding: false, unifiedToolbar: true, fullWidthToolbar: true, popup: false};
},
/**
* @inheritdoc
*/
showValidationErrors: function () {
var errors = Drupal.theme('quickeditImageErrors', {
errors: this.model.get('validationErrors')
});
$('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
.append(errors);
this.getEditedElement()
.addClass('quickedit-validation-error');
// Re-position the toolbar, which could have changed size.
this.fieldModel.get('entity').toolbarView.position();
},
/**
* @inheritdoc
*/
removeValidationErrors: function () {
$('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
.find('.quickedit-image-errors').remove();
this.getEditedElement()
.removeClass('quickedit-validation-error');
}
});
})(jQuery, _, Drupal);

View File

@ -0,0 +1,86 @@
/**
* @file
* Provides theme functions for image Quick Edit's client-side HTML.
*/
(function (Drupal) {
'use strict';
/**
* Theme function for validation errors of the Image in-place editor.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.errors
* Already escaped HTML representing error messages.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditImageErrors = function (settings) {
return '<div class="quickedit-image-errors">' + settings.errors + '</div>';
};
/**
* Theme function for the dropzone element of the Image module's in-place
* editor.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.state
* State of the upload.
* @param {string} settings.text
* Text to display inline with the dropzone element.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditImageDropzone = function (settings) {
return '<div class="quickedit-image-dropzone ' + settings.state + '">' +
' <i class="quickedit-image-icon"></i>' +
' <span class="quickedit-image-text">' + settings.text + '</span>' +
'</div>';
};
/**
* Theme function for the toolbar of the Image module's in-place editor.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {bool} settings.alt_field
* Whether or not the "Alt" field is enabled for this field.
* @param {bool} settings.alt_field_required
* Whether or not the "Alt" field is required for this field.
* @param {string} settings.alt
* The current value for the "Alt" field.
* @param {bool} settings.title_field
* Whether or not the "Title" field is enabled for this field.
* @param {bool} settings.title_field_required
* Whether or not the "Title" field is required for this field.
* @param {string} settings.title
* The current value for the "Title" field.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditImageToolbar = function (settings) {
var html = '<form class="quickedit-image-field-info">';
if (settings.alt_field) {
html += ' <div>' +
' <label for="alt" class="' + (settings.alt_field_required ? 'required' : '') + '">' + Drupal.t('Alternative text') + '</label>' +
' <input type="text" placeholder="' + settings.alt + '" value="' + settings.alt + '" name="alt" ' + (settings.alt_field_required ? 'required' : '') + '/>' +
' </div>';
}
if (settings.title_field) {
html += ' <div>' +
' <label for="title" class="' + (settings.title_field_required ? 'form-required' : '') + '">' + Drupal.t('Title') + '</label>' +
' <input type="text" placeholder="' + settings.title + '" value="' + settings.title + '" name="title" ' + (settings.title_field_required ? 'required' : '') + '/>' +
' </div>';
}
html += '</form>';
return html;
};
})(Drupal);

View File

@ -0,0 +1,225 @@
<?php
namespace Drupal\image\Controller;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Render\Element\StatusMessages;
use Drupal\Core\Render\RendererInterface;
use Drupal\image\Plugin\Field\FieldType\ImageItem;
use Drupal\user\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Returns responses for our image routes.
*/
class QuickEditImageController extends ControllerBase {
/**
* Stores The Quick Edit tempstore.
*
* @var \Drupal\user\PrivateTempStore
*/
protected $tempStore;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The image factory.
*
* @var \Drupal\Core\Image\ImageFactory
*/
protected $imageFactory;
/**
* Constructs a new QuickEditImageController.
*
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param \Drupal\Core\Image\ImageFactory $image_factory
* The image factory.
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
* The tempstore factory.
*/
public function __construct(RendererInterface $renderer, ImageFactory $image_factory, PrivateTempStoreFactory $temp_store_factory) {
$this->renderer = $renderer;
$this->imageFactory = $image_factory;
$this->tempStore = $temp_store_factory->get('quickedit');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer'),
$container->get('image.factory'),
$container->get('user.private_tempstore')
);
}
/**
* Returns JSON representing the new file upload, or validation errors.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity of which an image field is being rendered.
* @param string $field_name
* The name of the (image) field that is being rendered
* @param string $langcode
* The language code of the field that is being rendered.
* @param string $view_mode_id
* The view mode of the field that is being rendered.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*/
public function upload(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
$field = $this->getField($entity, $field_name, $langcode);
$field_validators = $field->getUploadValidators();
$field_settings = $field->getFieldDefinition()->getSettings();
$destination = $field->getUploadLocation();
// Add upload resolution validation.
if ($field_settings['max_resolution'] || $field_settings['min_resolution']) {
$field_validators['file_validate_image_resolution'] = [$field_settings['max_resolution'], $field_settings['min_resolution']];
}
// Create the destination directory if it does not already exist.
if (isset($destination) && !file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
return new JsonResponse(['main_error' => $this->t('The destination directory could not be created.'), 'errors' => '']);
}
// Attempt to save the image given the field's constraints.
$result = file_save_upload('image', $field_validators, $destination);
if (is_array($result) && $result[0]) {
/** @var \Drupal\file\Entity\File $file */
$file = $result[0];
$image = $this->imageFactory->get($file->getFileUri());
// Set the value in the Entity to the new file.
/** @var \Drupal\file\Plugin\Field\FieldType\FileFieldItemList $field_list */
$value = $entity->$field_name->getValue();
$value[0]['target_id'] = $file->id();
$value[0]['width'] = $image->getWidth();
$value[0]['height'] = $image->getHeight();
$entity->$field_name->setValue($value);
// Render the new image using the correct formatter settings.
$entity_view_mode_ids = array_keys($this->entityManager()->getViewModes($entity->getEntityTypeId()));
if (in_array($view_mode_id, $entity_view_mode_ids, TRUE)) {
$output = $entity->$field_name->view($view_mode_id);
}
else {
// Each part of a custom (non-Entity Display) view mode ID is separated
// by a dash; the first part must be the module name.
$mode_id_parts = explode('-', $view_mode_id, 2);
$module = reset($mode_id_parts);
$args = [$entity, $field_name, $view_mode_id, $langcode];
$output = $this->moduleHandler()->invoke($module, 'quickedit_render_field', $args);
}
// Save the Entity to tempstore.
$this->tempStore->set($entity->uuid(), $entity);
$data = [
'fid' => $file->id(),
'html' => $this->renderer->renderRoot($output),
];
return new JsonResponse($data);
}
else {
// Return a JSON object containing the errors from Drupal and our
// "main_error", which is displayed inside the dropzone area.
$messages = StatusMessages::renderMessages('error');
return new JsonResponse(['errors' => $this->renderer->render($messages), 'main_error' => $this->t('The image failed validation.')]);
}
}
/**
* Returns JSON representing an image field's metadata.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity of which an image field is being rendered.
* @param string $field_name
* The name of the (image) field that is being rendered
* @param string $langcode
* The language code of the field that is being rendered.
* @param string $view_mode_id
* The view mode of the field that is being rendered.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* The JSON response.
*/
public function getInfo(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
$field = $this->getField($entity, $field_name, $langcode);
$settings = $field->getFieldDefinition()->getSettings();
$info = [
'alt' => $field->alt,
'title' => $field->title,
'alt_field' => $settings['alt_field'],
'title_field' => $settings['title_field'],
'alt_field_required' => $settings['alt_field_required'],
'title_field_required' => $settings['title_field_required'],
];
$response = new CacheableJsonResponse($info);
$response->addCacheableDependency($entity);
return $response;
}
/**
* Returns JSON representing the current state of the field.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity of which an image field is being rendered.
* @param string $field_name
* The name of the (image) field that is being rendered
* @param string $langcode
* The language code of the field that is being rendered.
*
* @return \Drupal\image\Plugin\Field\FieldType\ImageItem
* The field for this request.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Throws an exception if the request is invalid.
*/
protected function getField(EntityInterface $entity, $field_name, $langcode) {
// Ensure that this is a valid Entity.
if (!($entity instanceof ContentEntityInterface)) {
throw new BadRequestHttpException('Requested Entity is not a Content Entity.');
}
// Check that this field exists.
/** @var \Drupal\Core\Field\FieldItemListInterface $field_list */
$field_list = $entity->getTranslation($langcode)->get($field_name);
if (!$field_list) {
throw new BadRequestHttpException('Requested Field does not exist.');
}
// If the list is empty, append an empty item to use.
if ($field_list->isEmpty()) {
$field = $field_list->appendItem();
}
// Otherwise, use the first item.
else {
$field = $entity->getTranslation($langcode)->get($field_name)->first();
}
// Ensure that the field is the type we expect.
if (!($field instanceof ImageItem)) {
throw new BadRequestHttpException('Requested Field is not of type "image".');
}
return $field;
}
}

View File

@ -22,6 +22,9 @@ use Drupal\Core\Cache\Cache;
* label = @Translation("Image"), * label = @Translation("Image"),
* field_types = { * field_types = {
* "image" * "image"
* },
* quickedit = {
* "editor" = "image"
* } * }
* ) * )
*/ */

View File

@ -0,0 +1,39 @@
<?php
namespace Drupal\image\Plugin\InPlaceEditor;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\quickedit\Plugin\InPlaceEditorBase;
/**
* Defines the image text in-place editor.
*
* @InPlaceEditor(
* id = "image"
* )
*/
class Image extends InPlaceEditorBase {
/**
* {@inheritdoc}
*/
public function isCompatible(FieldItemListInterface $items) {
$field_definition = $items->getFieldDefinition();
// This editor is only compatible with single-value image fields.
return $field_definition->getFieldStorageDefinition()->getCardinality() === 1
&& $field_definition->getType() === 'image';
}
/**
* {@inheritdoc}
*/
public function getAttachments() {
return [
'library' => [
'image/quickedit.inPlaceEditor.image',
],
];
}
}

View File

@ -0,0 +1,186 @@
<?php
namespace Drupal\image\Tests;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\simpletest\WebTestBase;
/**
* Tests the endpoints used by the "image" in-place editor.
*
* @group image
*/
class QuickEditImageControllerTest extends WebTestBase {
use ImageFieldCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'image', 'quickedit'];
/**
* The machine name of our image field.
*
* @var string
*/
protected $fieldName;
/**
* A user with permissions to edit articles and use Quick Edit.
*
* @var \Drupal\user\UserInterface
*/
protected $contentAuthorUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create the Article node type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Log in as a content author who can use Quick Edit and edit Articles.
$this->contentAuthorUser = $this->drupalCreateUser([
'access contextual links',
'access in-place editing',
'access content',
'create article content',
'edit any article content',
'delete any article content',
]);
$this->drupalLogin($this->contentAuthorUser);
// Create a field with basic resolution validators.
$this->fieldName = strtolower($this->randomMachineName());
$field_settings = [
'max_resolution' => '100x',
'min_resolution' => '50x',
];
$this->createImageField($this->fieldName, 'article', [], $field_settings);
}
/**
* Tests that routes restrict access for un-privileged users.
*/
function testAccess() {
// Create an anonymous user.
$user = $this->createUser();
$this->drupalLogin($user);
// Create a test Node.
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('Test Node'),
]);
$this->drupalGet('quickedit/image/info/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default');
$this->assertResponse('403');
$this->drupalPost('quickedit/image/upload/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default', 'application/json', []);
$this->assertResponse('403');
}
/**
* Tests that the field info route returns expected data.
*/
function testFieldInfo() {
// Create a test Node.
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('Test Node'),
]);
$info = $this->drupalGetJSON('quickedit/image/info/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default');
// Assert that the default settings for our field are respected by our JSON
// endpoint.
$this->assertTrue($info['alt_field']);
$this->assertFalse($info['title_field']);
}
/**
* Tests that uploading a valid image works.
*/
function testValidImageUpload() {
// Create a test Node.
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('Test Node'),
]);
// We want a test image that is a valid size.
$valid_image = FALSE;
$image_factory = $this->container->get('image.factory');
foreach ($this->drupalGetTestFiles('image') as $image) {
$image_file = $image_factory->get($image->uri);
if ($image_file->getWidth() > 50 && $image_file->getWidth() < 100) {
$valid_image = $image;
break;
}
}
$this->assertTrue($valid_image);
$this->uploadImage($valid_image, $node->id(), $this->fieldName, $node->language()->getId());
$this->assertText('fid', t('Valid upload completed successfully.'));
}
/**
* Tests that uploading a invalid image does not work.
*/
function testInvalidUpload() {
// Create a test Node.
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('Test Node'),
]);
// We want a test image that will fail validation.
$invalid_image = FALSE;
/** @var \Drupal\Core\Image\ImageFactory $image_factory */
$image_factory = $this->container->get('image.factory');
foreach ($this->drupalGetTestFiles('image') as $image) {
/** @var \Drupal\Core\Image\ImageInterface $image_file */
$image_file = $image_factory->get($image->uri);
if ($image_file->getWidth() < 50 || $image_file->getWidth() > 100 ) {
$invalid_image = $image;
break;
}
}
$this->assertTrue($invalid_image);
$this->uploadImage($invalid_image, $node->id(), $this->fieldName, $node->language()->getId());
$this->assertText('main_error', t('Invalid upload returned errors.'));
}
/**
* Uploads an image using the image module's Quick Edit route.
*
* @param object $image
* The image to upload.
* @param int $nid
* The target node ID.
* @param string $field_name
* The target field machine name.
* @param string $langcode
* The langcode to use when setting the field's value.
*
* @return mixed
* The content returned from the call to $this->curlExec().
*/
function uploadImage($image, $nid, $field_name, $langcode) {
$filepath = $this->container->get('file_system')->realpath($image->uri);
$data = [
'files[image]' => curl_file_create($filepath),
];
$path = 'quickedit/image/upload/node/' . $nid . '/' . $field_name . '/' . $langcode . '/default';
// We assemble the curl request ourselves as drupalPost cannot process file
// uploads, and drupalPostForm only works with typical Drupal forms.
return $this->curlExec([
CURLOPT_URL => $this->buildUrl($path, []),
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $data,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: multipart/form-data',
],
]);
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace Drupal\Tests\image\FunctionalJavascript;
use Drupal\file\Entity\File;
use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests the JavaScript functionality of the "image" in-place editor.
*
* @group image
*/
class QuickEditImageTest extends JavascriptTestBase {
use ImageFieldCreationTrait;
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'image', 'field_ui', 'contextual', 'quickedit', 'toolbar'];
/**
* A user with permissions to edit Articles and use Quick Edit.
*
* @var \Drupal\user\UserInterface
*/
protected $contentAuthorUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create the Article node type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Log in as a content author who can use Quick Edit and edit Articles.
$this->contentAuthorUser = $this->drupalCreateUser([
'access contextual links',
'access toolbar',
'access in-place editing',
'access content',
'create article content',
'edit any article content',
'delete any article content',
]);
$this->drupalLogin($this->contentAuthorUser);
}
/**
* Tests if an image can be uploaded inline with Quick Edit.
*/
public function testUpload() {
// Create a field with a basic filetype restriction.
$field_name = strtolower($this->randomMachineName());
$field_settings = [
'file_extensions' => 'png',
];
$formatter_settings = [
'image_style' => 'large',
'image_link' => '',
];
$this->createImageField($field_name, 'article', [], $field_settings, [], $formatter_settings);
// Find images that match our field settings.
$valid_images = [];
foreach ($this->getTestFiles('image') as $image) {
// This regex is taken from file_validate_extensions().
$regex = '/\.(' . preg_replace('/ +/', '|', preg_quote($field_settings['file_extensions'])) . ')$/i';
if (preg_match($regex, $image->filename)) {
$valid_images[] = $image;
}
}
// Ensure we have at least two valid images.
$this->assertGreaterThanOrEqual(2, count($valid_images));
// Create a File entity for the initial image.
$file = File::create([
'uri' => $valid_images[0]->uri,
'uid' => $this->contentAuthorUser->id(),
'status' => FILE_STATUS_PERMANENT,
]);
$file->save();
// Use the first valid image to create a new Node.
$image_factory = $this->container->get('image.factory');
$image = $image_factory->get($valid_images[0]->uri);
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('Test Node'),
$field_name => [
'target_id' => $file->id(),
'alt' => 'Hello world',
'title' => '',
'width' => $image->getWidth(),
'height' => $image->getHeight(),
],
]);
// Visit the new Node.
$this->drupalGet('node/' . $node->id());
// Assemble common CSS selectors.
$entity_selector = '[data-quickedit-entity-id="node/' . $node->id() . '"]';
$field_selector = '[data-quickedit-field-id="node/' . $node->id() . '/' . $field_name . '/' . $node->language()->getId() . '/full"]';
$original_image_selector = 'img[src*="' . $valid_images[0]->filename . '"][alt="Hello world"]';
$new_image_selector = 'img[src*="' . $valid_images[1]->filename . '"][alt="New text"]';
// Assert that the initial image is present.
$this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $original_image_selector);
// Wait until Quick Edit loads.
$condition = "jQuery('" . $entity_selector . " .quickedit').length > 0";
$this->assertJsCondition($condition, 10000);
// Initiate Quick Editing.
$this->click('.contextual-toolbar-tab button');
$this->click($entity_selector . ' [data-contextual-id] > button');
$this->click($entity_selector . ' [data-contextual-id] .quickedit > a');
$this->click($field_selector);
// Wait for the field info to load and set new alt text.
$condition = "jQuery('.quickedit-image-field-info').length > 0";
$this->assertJsCondition($condition, 10000);
$input = $this->assertSession()->elementExists('css', '.quickedit-image-field-info input[name="alt"]');
$input->setValue('New text');
// Check that our Dropzone element exists.
$this->assertSession()->elementExists('css', $field_selector . ' .quickedit-image-dropzone');
// Our headless browser can't drag+drop files, but we can mock the event.
// Append a hidden upload element to the DOM.
$script = 'jQuery("<input id=\"quickedit-image-test-input\" type=\"file\" />").appendTo("body")';
$this->getSession()->executeScript($script);
// Find the element, and set its value to our new image.
$input = $this->assertSession()->elementExists('css', '#quickedit-image-test-input');
$filepath = $this->container->get('file_system')->realpath($valid_images[1]->uri);
$input->attachFile($filepath);
// Trigger the upload logic with a mock "drop" event.
$script = 'var e = jQuery.Event("drop");'
. 'e.originalEvent = {dataTransfer: {files: jQuery("#quickedit-image-test-input").get(0).files}};'
. 'e.preventDefault = e.stopPropagation = function () {};'
. 'jQuery(".quickedit-image-dropzone").trigger(e);';
$this->getSession()->executeScript($script);
// Wait for the dropzone element to be removed (i.e. loading is done).
$condition = "jQuery('" . $field_selector . " .quickedit-image-dropzone').length == 0";
$this->assertJsCondition($condition, 20000);
// To prevent 403s on save, we re-set our request (cookie) state.
$this->prepareRequest();
// Save the change.
$this->click('.quickedit-button.action-save');
$this->assertSession()->assertWaitOnAjaxRequest();
// Re-visit the page to make sure the edit worked.
$this->drupalGet('node/' . $node->id());
// Check that the new image appears as expected.
$this->assertSession()->elementNotExists('css', $entity_selector . ' ' . $field_selector . ' ' . $original_image_selector);
$this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $new_image_selector);
}
}

View File

@ -23,6 +23,9 @@ use Drupal\Core\Utility\LinkGeneratorInterface;
* label = @Translation("Responsive image"), * label = @Translation("Responsive image"),
* field_types = { * field_types = {
* "image", * "image",
* },
* quickedit = {
* "editor" = "image"
* } * }
* ) * )
*/ */

View File

@ -0,0 +1,52 @@
/**
* @file
* Functional styles for the Image module's in-place editor.
*/
/**
* A minimum width/height is required so that users can drag and drop files
* onto small images.
*/
.quickedit-image-element {
min-width: 200px;
min-height: 200px;
}
.quickedit-image-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.quickedit-image-icon {
display: block;
width: 50px;
height: 50px;
background-repeat: no-repeat;
background-size: cover;
}
.quickedit-image-field-info {
display: flex;
align-items: center;
justify-content: flex-end;
}
.quickedit-image-text {
display: block;
}
/**
* If we do not prevent pointer-events for child elements, our drag+drop events
* will not fire properly. This can lead to unintentional redirects if a file
* is dropped on a child element when a user intended to upload it.
*/
.quickedit-image-dropzone * {
pointer-events: none;
}

View File

@ -0,0 +1,100 @@
/**
* @file
* Theme styles for the Image module's in-place editor.
*/
.quickedit-image-dropzone {
background: rgba(116, 183, 255, 0.8);
transition: background .2s;
}
.quickedit-image-icon {
margin: 0 0 10px 0;
transition: margin .5s;
}
.quickedit-image-dropzone.hover {
background: rgba(116, 183, 255, 0.9);
}
.quickedit-image-dropzone.error {
background: rgba(255, 52, 27, 0.81);
}
.quickedit-image-dropzone.upload .quickedit-image-icon {
background-image: url('../../../images/image/upload.svg');
}
.quickedit-image-dropzone.error .quickedit-image-icon {
background-image: url('../../../images/image/error.svg');
}
.quickedit-image-dropzone.loading .quickedit-image-icon {
margin: -10px 0 20px 0;
}
.quickedit-image-dropzone.loading .quickedit-image-icon::after {
display: block;
content: "";
margin-left: -10px;
margin-top: -5px;
animation-duration: 2s;
animation-name: quickedit-image-spin;
animation-iteration-count: infinite;
animation-timing-function: linear;
width: 60px;
height: 60px;
border-style: solid;
border-radius: 35px;
border-width: 5px;
border-color: white transparent transparent transparent;
}
@keyframes quickedit-image-spin {
0% {transform: rotate(0deg);}
50% {transform: rotate(180deg);}
100% {transform: rotate(360deg);}
}
.quickedit-image-text {
text-align: center;
color: white;
font-family: "Droid sans", "Lucida Grande", sans-serif;
font-size: 16px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.quickedit-image-field-info {
background: rgba(0, 0, 0, 0.05);
border-top: 1px solid #c5c5c5;
padding: 5px;
}
.quickedit-image-field-info div {
margin-right: 10px; /* LTR */
}
.quickedit-image-field-info div:last-child {
margin-right: 0; /* LTR */
}
[dir="rtl"] .quickedit-image-field-info div {
margin-left: 10px;
margin-right: 0;
}
[dir="rtl"] .quickedit-image-field-info div:last-child {
margin-left: 0;
}
.quickedit-image-errors .messages__wrapper {
margin: 0;
padding: 0;
}
.quickedit-image-errors .messages--error {
box-shadow: none;
}

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

View File

@ -98,6 +98,12 @@ libraries-override:
css: css:
theme: theme:
css/image.admin.css: css/image/image.admin.css css/image.admin.css: css/image/image.admin.css
image/quickedit.inPlaceEditor.image:
css:
component:
css/editors/image.css: css/image/editors/image.css
theme:
css/editors/image.theme.css: css/image/editors/image.theme.css
language/drupal.language.admin: language/drupal.language.admin:
css: css: