diff --git a/core/modules/image/css/editors/image.css b/core/modules/image/css/editors/image.css new file mode 100644 index 0000000..08bb679 --- /dev/null +++ b/core/modules/image/css/editors/image.css @@ -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; +} diff --git a/core/modules/image/css/editors/image.theme.css b/core/modules/image/css/editors/image.theme.css new file mode 100644 index 0000000..cf1c7db --- /dev/null +++ b/core/modules/image/css/editors/image.theme.css @@ -0,0 +1,90 @@ +/** + * @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; + -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; +} + +.quickedit-image-field-info div:last-child { + margin-right: 0; +} + +.quickedit-image-errors .messages__wrapper { + margin: 0; + padding: 0; +} + +.quickedit-image-errors .messages--error { + box-shadow: none; +} diff --git a/core/modules/image/image.install b/core/modules/image/image.install index fd14d03..c044912 100644 --- a/core/modules/image/image.install +++ b/core/modules/image/image.install @@ -61,3 +61,10 @@ function image_requirements($phase) { return $requirements; } + +/** + * Flush caches as we changed field formatter metadata. + */ +function image_update_8201() { + // Empty update to trigger a cache flush. +} diff --git a/core/modules/image/image.libraries.yml b/core/modules/image/image.libraries.yml index e9061a4..a47a2b5 100644 --- a/core/modules/image/image.libraries.yml +++ b/core/modules/image/image.libraries.yml @@ -3,3 +3,19 @@ admin: css: theme: 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 diff --git a/core/modules/image/image.routing.yml b/core/modules/image/image.routing.yml index ffeed86..4a0cb88 100644 --- a/core/modules/image/image.routing.yml +++ b/core/modules/image/image.routing.yml @@ -71,3 +71,29 @@ image.effect_edit_form: route_callbacks: - '\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' diff --git a/core/modules/image/images/error.svg b/core/modules/image/images/error.svg new file mode 100644 index 0000000..1932ea4 --- /dev/null +++ b/core/modules/image/images/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/modules/image/images/upload.svg b/core/modules/image/images/upload.svg new file mode 100644 index 0000000..168bc43 --- /dev/null +++ b/core/modules/image/images/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/modules/image/js/editors/image.js b/core/modules/image/js/editors/image.js new file mode 100644 index 0000000..dea08df --- /dev/null +++ b/core/modules/image/js/editors/image.js @@ -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 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. + $('') + .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 @file…', {'@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); diff --git a/core/modules/image/js/theme.js b/core/modules/image/js/theme.js new file mode 100644 index 0000000..9e61e8a --- /dev/null +++ b/core/modules/image/js/theme.js @@ -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 '
' + settings.errors + '
'; + }; + + /** + * 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 '
' + + ' ' + + ' ' + settings.text +'' + + '
'; + }; + + /** + * 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 = '
'; + if (settings.alt_field) { + html += '
' + + ' ' + + ' ' + + '
'; + } + if (settings.title_field) { + html += '
' + + ' ' + + ' ' + + '
'; + } + html += '
'; + + return html; + }; + +})(Drupal); diff --git a/core/modules/image/src/Controller/QuickEditImageController.php b/core/modules/image/src/Controller/QuickEditImageController.php new file mode 100644 index 0000000..a913086 --- /dev/null +++ b/core/modules/image/src/Controller/QuickEditImageController.php @@ -0,0 +1,225 @@ +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)) { + $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)->$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)->$field_name->get(0); + } + + // 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; + } + +} diff --git a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php index 4c3a27d..c1ee4df 100644 --- a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php +++ b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php @@ -22,6 +22,9 @@ * label = @Translation("Image"), * field_types = { * "image" + * }, + * quickedit = { + * "editor" = "image" * } * ) */ diff --git a/core/modules/image/src/Plugin/InPlaceEditor/Image.php b/core/modules/image/src/Plugin/InPlaceEditor/Image.php new file mode 100644 index 0000000..9cf9cd2 --- /dev/null +++ b/core/modules/image/src/Plugin/InPlaceEditor/Image.php @@ -0,0 +1,39 @@ +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', + ], + ]; + } + +} diff --git a/core/modules/image/src/Tests/QuickEditImageControllerTest.php b/core/modules/image/src/Tests/QuickEditImageControllerTest.php new file mode 100644 index 0000000..0485f8a --- /dev/null +++ b/core/modules/image/src/Tests/QuickEditImageControllerTest.php @@ -0,0 +1,186 @@ +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 integer $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', + ], + ]); + } + +} diff --git a/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php new file mode 100644 index 0000000..6e924f0 --- /dev/null +++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php @@ -0,0 +1,171 @@ +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("").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); + } +} diff --git a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php index ff91e68..3ec1a19 100644 --- a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php +++ b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php @@ -23,6 +23,9 @@ * label = @Translation("Responsive image"), * field_types = { * "image", + * }, + * quickedit = { + * "editor" = "image" * } * ) */ diff --git a/core/themes/stable/css/image/editors/image.css b/core/themes/stable/css/image/editors/image.css new file mode 100644 index 0000000..08bb679 --- /dev/null +++ b/core/themes/stable/css/image/editors/image.css @@ -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; +} diff --git a/core/themes/stable/css/image/editors/image.theme.css b/core/themes/stable/css/image/editors/image.theme.css new file mode 100644 index 0000000..3a05c20 --- /dev/null +++ b/core/themes/stable/css/image/editors/image.theme.css @@ -0,0 +1,90 @@ +/** + * @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; + -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; +} + +.quickedit-image-field-info div:last-child { + margin-right: 0; +} + +.quickedit-image-errors .messages__wrapper { + margin: 0; + padding: 0; +} + +.quickedit-image-errors .messages--error { + box-shadow: none; +} diff --git a/core/themes/stable/images/image/error.svg b/core/themes/stable/images/image/error.svg new file mode 100644 index 0000000..1932ea4 --- /dev/null +++ b/core/themes/stable/images/image/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/themes/stable/images/image/upload.svg b/core/themes/stable/images/image/upload.svg new file mode 100644 index 0000000..168bc43 --- /dev/null +++ b/core/themes/stable/images/image/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml index 3476833..7e585b1 100644 --- a/core/themes/stable/stable.info.yml +++ b/core/themes/stable/stable.info.yml @@ -98,6 +98,12 @@ libraries-override: css: theme: 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: css: