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/QuickEditImageTest.php b/core/modules/image/src/Tests/QuickEditImageTest.php new file mode 100644 index 0000000..0fb74d9 --- /dev/null +++ b/core/modules/image/src/Tests/QuickEditImageTest.php @@ -0,0 +1,138 @@ +createRole(['access in-place editing']); + $this->adminUser->addRole($rid); + $this->adminUser->save(); + + // Create a field with basic resolution validators. + $field_settings = [ + 'max_resolution' => '100x', + 'min_resolution' => '50x', + ]; + $this->createImageField('image_test', '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() . '/image_test/' . $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->uploadImage($valid_image, $node->id(), 'image_test', $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->uploadImage($invalid_image, $node->id(), 'image_test', $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/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..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: