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..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..757afb8 --- /dev/null +++ b/core/modules/image/js/editors/image.js @@ -0,0 +1,402 @@ +/** + * @file + * Drag+drop based in-place editor for images. + */ + +(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 = _.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('Drop 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); + // 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. + 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..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/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..195b200 --- /dev/null +++ b/core/modules/image/src/Tests/QuickEditImageControllerTest.php @@ -0,0 +1,185 @@ +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..251b921 --- /dev/null +++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php @@ -0,0 +1,183 @@ +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); + } + + /** + * 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->triggerClick($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 AJAX to finish. + $this->assertSession()->assertWaitOnAjaxRequest(); + + // To prevent 403s on save, we re-set our request (cookie) state. + $this->prepareRequest(); + + // Save the change. + $this->triggerClick('.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); + } + + /** + * 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..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: