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..00bb093 --- /dev/null +++ b/core/modules/image/css/editors/image.theme.css @@ -0,0 +1,88 @@ +/** + * @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; +} + +.quickedit-image-field-info { + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid #c5c5c5; + padding: 5px; +} + +.quickedit-image-field-info label, .quickedit-image-field-info input { + margin-right: 5px; +} + +.quickedit-image-field-info input: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..0ce1ba8 100644 --- a/core/modules/image/image.libraries.yml +++ b/core/modules/image/image.libraries.yml @@ -3,3 +3,15 @@ admin: css: theme: css/image.admin.css: {} + +quickedit.inPlaceEditor.image: + version: VERSION + js: + js/editors/image.js: {} + css: + component: + css/editors/image.css: {} + theme: + css/editors/image.theme.css: {} + dependencies: + - 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..342ac05 --- /dev/null +++ b/core/modules/image/js/editors/image.js @@ -0,0 +1,397 @@ +/** + * @file + * Drag+drop based in-place editor for images. + */ + +(function ($, _, Drupal) { + + 'use strict'; + + /** + * Theme function for validation errors of the Image module's 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 = _.template( + '
' + + ' <%= 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.text + * Text to display inline with the dropzone element. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.quickeditImageDropzone = _.template( + '
' + + ' ' + + ' <%- 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 = _.template( + '
' + + '<% if (alt_field) { %>' + + ' ' + + ' required<%} %>/>' + + '<% } %>' + + '<% if (title_field) { %>' + + ' ' + + ' required<%} %>/>' + + '<% } %>' + + '
' + ); + + 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', $.trim(this.$el.html())); + // $.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('Drag file here or click to upload')); + + // Generic event callback to stop event behavior and bubbling. + var stopEvent = function (e) { + e.preventDefault(); + e.stopPropagation(); + }; + + // Prevent the browser's default behavior when dragging files onto + // the document (usually opens them in the same tab). + $dropzone.on('dragover', stopEvent); + $dropzone.on('dragenter', function (e) { + stopEvent(e); + $(this).addClass('hover'); + }); + $dropzone.on('dragleave', function (e) { + stopEvent(e); + $(this).removeClass('hover'); + }); + + $dropzone.on('drop', function (e) { + stopEvent(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) { + stopEvent(e); + var $input = $('').trigger('click'); + $input.change(function () { + if (this.files.length) { + self.uploadImage(this.files[0]); + } + }); + }); + + 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('POST', url, data, 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 innerHTML 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]').html(); + $el.html(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 {string} type + * The type of request (i.e. GET, POST, PUT, DELETE, etc.) + * @param {string} url + * The URL for the request. + * @param {*} data + * The data to send to the server. + * @param {function} callback + * A callback function used when a request is successful, without errors. + */ + ajax: function (type, url, data, callback) { + $.ajax({ + url: url, + context: this, + type: type, + data: data, + dataType: 'json', + cache: false, + contentType: false, + processData: false, + 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 { + callback(response); + } + }, + error: function () { + this.renderDropzone('error', Drupal.t('A server error has occurred.')); + } + }); + }, + + /** + * 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('GET', url, null, 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. + */ + 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'); + $dropzone.addClass('.quickedit-image-dropzone ' + state); + $dropzone.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/src/Controller/QuickEditImageController.php b/core/modules/image/src/Controller/QuickEditImageController.php new file mode 100644 index 0000000..a10600c --- /dev/null +++ b/core/modules/image/src/Controller/QuickEditImageController.php @@ -0,0 +1,222 @@ +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 a JSON object 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 requested image failed validation.')]); + } + } + + /** + * Returns a JSON object 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 \Symfony\Component\HttpFoundation\JsonResponse + * The Ajax 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'], + ]; + return new JsonResponse($info); + } + + /** + * Returns a JSON object 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/ImageFieldCreationTrait.php b/core/modules/image/src/Tests/ImageFieldCreationTrait.php new file mode 100644 index 0000000..3af9617 --- /dev/null +++ b/core/modules/image/src/Tests/ImageFieldCreationTrait.php @@ -0,0 +1,68 @@ + $name, + 'entity_type' => 'node', + 'type' => 'image', + 'settings' => $storage_settings, + 'cardinality' => !empty($storage_settings['cardinality']) ? $storage_settings['cardinality'] : 1, + ))->save(); + + $field_config = FieldConfig::create([ + 'field_name' => $name, + 'label' => $name, + 'entity_type' => 'node', + 'bundle' => $type_name, + 'required' => !empty($field_settings['required']), + 'settings' => $field_settings, + 'description' => $description, + ]); + $field_config->save(); + + entity_get_form_display('node', $type_name, 'default') + ->setComponent($name, array( + 'type' => 'image_image', + 'settings' => $widget_settings, + )) + ->save(); + + entity_get_display('node', $type_name, 'default') + ->setComponent($name, array( + 'type' => 'image', + 'settings' => $formatter_settings, + )) + ->save(); + + return $field_config; + } + +} diff --git a/core/modules/image/src/Tests/ImageFieldTestBase.php b/core/modules/image/src/Tests/ImageFieldTestBase.php index 6081d32..04da7ae 100644 --- a/core/modules/image/src/Tests/ImageFieldTestBase.php +++ b/core/modules/image/src/Tests/ImageFieldTestBase.php @@ -2,9 +2,7 @@ namespace Drupal\image\Tests; -use Drupal\field\Entity\FieldConfig; use Drupal\simpletest\WebTestBase; -use Drupal\field\Entity\FieldStorageConfig; /** * TODO: Test the following functions. @@ -24,6 +22,8 @@ */ abstract class ImageFieldTestBase extends WebTestBase { + use ImageFieldCreationTrait; + /** * Modules to enable. * @@ -52,62 +52,6 @@ protected function setUp() { } /** - * Create a new image field. - * - * @param string $name - * The name of the new field (all lowercase), exclude the "field_" prefix. - * @param string $type_name - * The node type that this field will be added to. - * @param array $storage_settings - * A list of field storage settings that will be added to the defaults. - * @param array $field_settings - * A list of instance settings that will be added to the instance defaults. - * @param array $widget_settings - * Widget settings to be added to the widget defaults. - * @param array $formatter_settings - * Formatter settings to be added to the formatter defaults. - * @param string $description - * A description for the field. - */ - function createImageField($name, $type_name, $storage_settings = array(), $field_settings = array(), $widget_settings = array(), $formatter_settings = array(), $description = '') { - FieldStorageConfig::create(array( - 'field_name' => $name, - 'entity_type' => 'node', - 'type' => 'image', - 'settings' => $storage_settings, - 'cardinality' => !empty($storage_settings['cardinality']) ? $storage_settings['cardinality'] : 1, - ))->save(); - - $field_config = FieldConfig::create([ - 'field_name' => $name, - 'label' => $name, - 'entity_type' => 'node', - 'bundle' => $type_name, - 'required' => !empty($field_settings['required']), - 'settings' => $field_settings, - 'description' => $description, - ]); - $field_config->save(); - - entity_get_form_display('node', $type_name, 'default') - ->setComponent($name, array( - 'type' => 'image_image', - 'settings' => $widget_settings, - )) - ->save(); - - entity_get_display('node', $type_name, 'default') - ->setComponent($name, array( - 'type' => 'image', - 'settings' => $formatter_settings, - )) - ->save(); - - return $field_config; - - } - - /** * Preview an image in a node. * * @param \Drupal\Core\Image\ImageInterface $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..e336497 --- /dev/null +++ b/core/modules/image/src/Tests/QuickEditImageControllerTest.php @@ -0,0 +1,146 @@ +createRole(['access in-place editing']); + $this->adminUser->addRole($rid); + $this->adminUser->save(); + + // 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 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..20eaf5e --- /dev/null +++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php @@ -0,0 +1,149 @@ +profile != 'standard') { + $this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']); + } + + $admin = $this->drupalCreateUser(['access contextual links', 'access in-place editing', 'access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer node fields', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer node display']); + $this->drupalLogin($admin); + + // 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' => $admin->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()); + + // Assert that the initial image is present. + $this->assertSession()->elementExists('css', 'img[src*="' . $valid_images[0]->filename . '"]'); + + $entity_selector = '[data-quickedit-entity-id="node/' . $node->id() . '"]'; + $field_selector = '[data-quickedit-field-id="node/' . $node->id() . '/' . $field_name . '/' . $node->language()->getId() . '/full"]'; + + // Wait until Quick Edit loads. + $condition = "jQuery('" . $entity_selector . " .quickedit').length > 0"; + $this->assertJsCondition($condition, 10000); + + // Initiate Quick Editing. + $this->triggerClick($entity_selector . ' [data-contextual-id] > button'); + $this->click($entity_selector . ' [data-contextual-id] .quickedit > a'); + $this->click($field_selector); + + // 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 AJAX to finish. + $this->assertSession()->assertWaitOnAjaxRequest(); + + // 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()); + $this->assertSession()->elementNotExists('css', 'img[src*="' . $valid_images[0]->filename . '"]'); + $this->assertSession()->elementExists('css', 'img[src*="' . $valid_images[1]->filename . '"]'); + } + + /** + * Clicks the element with the given CSS selector using event triggers. + * + * @todo Remove when https://github.com/jcalderonzumba/gastonjs/issues/19 + * is fixed. Currently clicking anchors/buttons with nested elements is not + * possible. + * + * @param string $css_selector + * The CSS selector identifying the element to click. + */ + protected function triggerClick($css_selector) { + $this->getSession()->executeScript("jQuery('" . $css_selector . "')[0].click()"); + } + +} 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/modules/simpletest/src/TestFilesTrait.php b/core/modules/simpletest/src/TestFilesTrait.php new file mode 100644 index 0000000..d919377 --- /dev/null +++ b/core/modules/simpletest/src/TestFilesTrait.php @@ -0,0 +1,117 @@ +generatedTestFiles)) { + // Generate binary test files. + $lines = array(64, 1024); + $count = 0; + foreach ($lines as $line) { + simpletest_generate_file('binary-' . $count++, 64, $line, 'binary'); + } + + // Generate ASCII text test files. + $lines = array(16, 256, 1024, 2048, 20480); + $count = 0; + foreach ($lines as $line) { + simpletest_generate_file('text-' . $count++, 64, $line, 'text'); + } + + // Copy other test files from simpletest. + $original = drupal_get_path('module', 'simpletest') . '/files'; + $files = file_scan_directory($original, '/(html|image|javascript|php|sql)-.*/'); + foreach ($files as $file) { + file_unmanaged_copy($file->uri, PublicStream::basePath()); + } + + $this->generatedTestFiles = TRUE; + } + + $files = array(); + // Make sure type is valid. + if (in_array($type, array('binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'))) { + $files = file_scan_directory('public://', '/' . $type . '\-.*/'); + + // If size is set then remove any files that are not of that size. + if ($size !== NULL) { + foreach ($files as $file) { + $stats = stat($file->uri); + if ($stats['size'] != $size) { + unset($files[$file->uri]); + } + } + } + } + usort($files, array($this, 'drupalCompareFiles')); + return $files; + } + + /** + * Compare two files based on size and file name. + * + * @param object $file1 + * An object with the properties 'uri', 'filename', and 'name'. + * @param object $file2 + * An object with the properties 'uri', 'filename', and 'name'. + * @return bool + * The comparison result based on size and file name. + */ + protected function drupalCompareFiles($file1, $file2) { + $compare_size = filesize($file1->uri) - filesize($file2->uri); + if ($compare_size) { + // Sort by file size. + return $compare_size; + } + else { + // The files were the same size, so sort alphabetically. + return strnatcmp($file1->name, $file2->name); + } + } + +} diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php index 04d1927..c046cd0 100644 --- a/core/modules/simpletest/src/WebTestBase.php +++ b/core/modules/simpletest/src/WebTestBase.php @@ -22,7 +22,6 @@ use Drupal\Core\Session\AnonymousUserSession; use Drupal\Core\Session\UserSession; use Drupal\Core\Site\Settings; -use Drupal\Core\StreamWrapper\PublicStream; use Drupal\Core\Test\AssertMailTrait; use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -56,6 +55,9 @@ createRole as drupalCreateRole; createAdminRole as drupalCreateAdminRole; } + use TestFilesTrait { + getTestFiles as drupalGetTestFiles; + } /** * The profile to install as a basis for testing. @@ -167,11 +169,6 @@ protected $sessionId = NULL; /** - * Whether the files were copied to the test files directory. - */ - protected $generatedTestFiles = FALSE; - - /** * The maximum number of redirects to follow when handling responses. */ protected $maximumRedirects = 5; @@ -332,99 +329,6 @@ protected function findBlockInstance(Block $block) { } /** - * Gets a list of files that can be used in tests. - * - * The first time this method is called, it will call - * simpletest_generate_file() to generate binary and ASCII text files in the - * public:// directory. It will also copy all files in - * core/modules/simpletest/files to public://. These contain image, SQL, PHP, - * JavaScript, and HTML files. - * - * All filenames are prefixed with their type and have appropriate extensions: - * - text-*.txt - * - binary-*.txt - * - html-*.html and html-*.txt - * - image-*.png, image-*.jpg, and image-*.gif - * - javascript-*.txt and javascript-*.script - * - php-*.txt and php-*.php - * - sql-*.txt and sql-*.sql - * - * Any subsequent calls will not generate any new files, or copy the files - * over again. However, if a test class adds a new file to public:// that - * is prefixed with one of the above types, it will get returned as well, even - * on subsequent calls. - * - * @param $type - * File type, possible values: 'binary', 'html', 'image', 'javascript', - * 'php', 'sql', 'text'. - * @param $size - * (optional) File size in bytes to match. Defaults to NULL, which will not - * filter the returned list by size. - * - * @return - * List of files in public:// that match the filter(s). - */ - protected function drupalGetTestFiles($type, $size = NULL) { - if (empty($this->generatedTestFiles)) { - // Generate binary test files. - $lines = array(64, 1024); - $count = 0; - foreach ($lines as $line) { - simpletest_generate_file('binary-' . $count++, 64, $line, 'binary'); - } - - // Generate ASCII text test files. - $lines = array(16, 256, 1024, 2048, 20480); - $count = 0; - foreach ($lines as $line) { - simpletest_generate_file('text-' . $count++, 64, $line, 'text'); - } - - // Copy other test files from simpletest. - $original = drupal_get_path('module', 'simpletest') . '/files'; - $files = file_scan_directory($original, '/(html|image|javascript|php|sql)-.*/'); - foreach ($files as $file) { - file_unmanaged_copy($file->uri, PublicStream::basePath()); - } - - $this->generatedTestFiles = TRUE; - } - - $files = array(); - // Make sure type is valid. - if (in_array($type, array('binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'))) { - $files = file_scan_directory('public://', '/' . $type . '\-.*/'); - - // If size is set then remove any files that are not of that size. - if ($size !== NULL) { - foreach ($files as $file) { - $stats = stat($file->uri); - if ($stats['size'] != $size) { - unset($files[$file->uri]); - } - } - } - } - usort($files, array($this, 'drupalCompareFiles')); - return $files; - } - - /** - * Compare two files based on size and file name. - */ - protected function drupalCompareFiles($file1, $file2) { - $compare_size = filesize($file1->uri) - filesize($file2->uri); - if ($compare_size) { - // Sort by file size. - return $compare_size; - } - else { - // The files were the same size, so sort alphabetically. - return strnatcmp($file1->name, $file2->name); - } - } - - /** * Log in a user with the internal browser. * * If a user is already logged in, then the current user is logged out before 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..7ff435b --- /dev/null +++ b/core/themes/stable/css/image/editors/image.theme.css @@ -0,0 +1,88 @@ +/** + * @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; +} + +.quickedit-image-field-info { + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid #c5c5c5; + padding: 5px; +} + +.quickedit-image-field-info label, .quickedit-image-field-info input { + margin-right: 5px; +} + +.quickedit-image-field-info input: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: