diff --git a/core/modules/image/css/editors/image.css b/core/modules/image/css/editors/image.css
new file mode 100644
index 0000000..08bb679
--- /dev/null
+++ b/core/modules/image/css/editors/image.css
@@ -0,0 +1,52 @@
+/**
+ * @file
+ * Functional styles for the Image module's in-place editor.
+ */
+
+/**
+ * A minimum width/height is required so that users can drag and drop files
+ * onto small images.
+ */
+.quickedit-image-element {
+ min-width: 200px;
+ min-height: 200px;
+}
+
+.quickedit-image-dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.quickedit-image-icon {
+ display: block;
+ width: 50px;
+ height: 50px;
+ background-repeat: no-repeat;
+ background-size: cover;
+}
+
+.quickedit-image-field-info {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.quickedit-image-text {
+ display: block;
+}
+
+/**
+ * If we do not prevent pointer-events for child elements, our drag+drop events
+ * will not fire properly. This can lead to unintentional redirects if a file
+ * is dropped on a child element when a user intended to upload it.
+ */
+.quickedit-image-dropzone * {
+ pointer-events: none;
+}
diff --git a/core/modules/image/css/editors/image.theme.css b/core/modules/image/css/editors/image.theme.css
new file mode 100644
index 0000000..cf1c7db
--- /dev/null
+++ b/core/modules/image/css/editors/image.theme.css
@@ -0,0 +1,90 @@
+/**
+ * @file
+ * Theme styles for the Image module's in-place editor.
+ */
+
+.quickedit-image-dropzone {
+ background: rgba(116, 183, 255, 0.8);
+ transition: background .2s;
+}
+
+.quickedit-image-icon {
+ margin: 0 0 10px 0;
+ transition: margin .5s;
+}
+
+.quickedit-image-dropzone.hover {
+ background: rgba(116, 183, 255, 0.9);
+}
+
+.quickedit-image-dropzone.error {
+ background: rgba(255, 52, 27, 0.81);
+}
+
+.quickedit-image-dropzone.upload .quickedit-image-icon {
+ background-image: url('../../images/upload.svg');
+}
+
+.quickedit-image-dropzone.error .quickedit-image-icon {
+ background-image: url('../../images/error.svg');
+}
+
+.quickedit-image-dropzone.loading .quickedit-image-icon {
+ margin: -10px 0 20px 0;
+}
+
+.quickedit-image-dropzone.loading .quickedit-image-icon::after {
+ display: block;
+ content: "";
+ margin-left: -10px;
+ margin-top: -5px;
+ animation-duration: 2s;
+ animation-name: quickedit-image-spin;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+ width: 60px;
+ height: 60px;
+ border-style: solid;
+ border-radius: 35px;
+ border-width: 5px;
+ border-color: white transparent transparent transparent;
+}
+
+@keyframes quickedit-image-spin {
+ 0% {transform: rotate(0deg);}
+ 50% {transform: rotate(180deg);}
+ 100% {transform: rotate(360deg);}
+}
+
+.quickedit-image-text {
+ text-align: center;
+ color: white;
+ font-family: "Droid sans", "Lucida Grande", sans-serif;
+ font-size: 16px;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+
+.quickedit-image-field-info {
+ background: rgba(0, 0, 0, 0.05);
+ border-top: 1px solid #c5c5c5;
+ padding: 5px;
+}
+
+.quickedit-image-field-info div {
+ margin-right: 10px;
+}
+
+.quickedit-image-field-info div:last-child {
+ margin-right: 0;
+}
+
+.quickedit-image-errors .messages__wrapper {
+ margin: 0;
+ padding: 0;
+}
+
+.quickedit-image-errors .messages--error {
+ box-shadow: none;
+}
diff --git a/core/modules/image/image.install b/core/modules/image/image.install
index fd14d03..c044912 100644
--- a/core/modules/image/image.install
+++ b/core/modules/image/image.install
@@ -61,3 +61,10 @@ function image_requirements($phase) {
return $requirements;
}
+
+/**
+ * Flush caches as we changed field formatter metadata.
+ */
+function image_update_8201() {
+ // Empty update to trigger a cache flush.
+}
diff --git a/core/modules/image/image.libraries.yml b/core/modules/image/image.libraries.yml
index e9061a4..a47a2b5 100644
--- a/core/modules/image/image.libraries.yml
+++ b/core/modules/image/image.libraries.yml
@@ -3,3 +3,19 @@ admin:
css:
theme:
css/image.admin.css: {}
+
+quickedit.inPlaceEditor.image:
+ version: VERSION
+ js:
+ js/editors/image.js: {}
+ js/theme.js: {}
+ css:
+ component:
+ css/editors/image.css: {}
+ theme:
+ css/editors/image.theme.css: {}
+ dependencies:
+ - core/jquery
+ - core/drupal
+ - core/underscore
+ - quickedit/quickedit
diff --git a/core/modules/image/image.routing.yml b/core/modules/image/image.routing.yml
index ffeed86..4a0cb88 100644
--- a/core/modules/image/image.routing.yml
+++ b/core/modules/image/image.routing.yml
@@ -71,3 +71,29 @@ image.effect_edit_form:
route_callbacks:
- '\Drupal\image\Routing\ImageStyleRoutes::routes'
+
+image.upload:
+ path: '/quickedit/image/upload/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
+ defaults:
+ _controller: '\Drupal\image\Controller\QuickEditImageController::upload'
+ options:
+ parameters:
+ entity:
+ type: entity:{entity_type}
+ requirements:
+ _permission: 'access in-place editing'
+ _access_quickedit_entity_field: 'TRUE'
+ _method: 'POST'
+
+image.info:
+ path: '/quickedit/image/info/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
+ defaults:
+ _controller: '\Drupal\image\Controller\QuickEditImageController::getInfo'
+ options:
+ parameters:
+ entity:
+ type: entity:{entity_type}
+ requirements:
+ _permission: 'access in-place editing'
+ _access_quickedit_entity_field: 'TRUE'
+ _method: 'GET'
diff --git a/core/modules/image/images/error.svg b/core/modules/image/images/error.svg
new file mode 100644
index 0000000..1932ea4
--- /dev/null
+++ b/core/modules/image/images/error.svg
@@ -0,0 +1,4 @@
+
diff --git a/core/modules/image/images/upload.svg b/core/modules/image/images/upload.svg
new file mode 100644
index 0000000..168bc43
--- /dev/null
+++ b/core/modules/image/images/upload.svg
@@ -0,0 +1,4 @@
+
diff --git a/core/modules/image/js/editors/image.js b/core/modules/image/js/editors/image.js
new file mode 100644
index 0000000..dea08df
--- /dev/null
+++ b/core/modules/image/js/editors/image.js
@@ -0,0 +1,342 @@
+/**
+ * @file
+ * Drag+drop based in-place editor for images.
+ */
+
+(function ($, _, Drupal) {
+
+ 'use strict';
+
+ Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.image# */{
+
+ /**
+ * @constructs
+ *
+ * @augments Drupal.quickedit.EditorView
+ *
+ * @param {object} options
+ * Options for the image editor.
+ */
+ initialize: function (options) {
+ Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
+ // Set our original value to our current HTML (for reverting).
+ this.model.set('originalValue', this.$el.html().trim());
+ // $.val() callback function for copying input from our custom form to
+ // the Quick Edit Field Form.
+ this.model.set('currentValue', function (index, value) {
+ var matches = $(this).attr('name').match(/(alt|title)]$/);
+ if (matches) {
+ var name = matches[1];
+ var $toolgroup = $('#' + options.fieldModel.toolbarView.getMainWysiwygToolgroupId());
+ var $input = $toolgroup.find('.quickedit-image-field-info input[name="' + name + '"]');
+ if ($input.length) {
+ return $input.val();
+ }
+ }
+ });
+ },
+
+ /**
+ * @inheritdoc
+ *
+ * @param {Drupal.quickedit.FieldModel} fieldModel
+ * The field model that holds the state.
+ * @param {string} state
+ * The state to change to.
+ * @param {object} options
+ * State options, if needed by the state change.
+ */
+ stateChange: function (fieldModel, state, options) {
+ var from = fieldModel.previous('state');
+ switch (state) {
+ case 'inactive':
+ break;
+
+ case 'candidate':
+ if (from !== 'inactive') {
+ this.$el.find('.quickedit-image-dropzone').remove();
+ this.$el.removeClass('quickedit-image-element');
+ }
+ if (from === 'invalid') {
+ this.removeValidationErrors();
+ }
+ break;
+
+ case 'highlighted':
+ break;
+
+ case 'activating':
+ // Defer updating the field model until the current state change has
+ // propagated, to not trigger a nested state change event.
+ _.defer(function () {
+ fieldModel.set('state', 'active');
+ });
+ break;
+
+ case 'active':
+ var self = this;
+
+ // Indicate that this element is being edited by Quick Edit Image.
+ this.$el.addClass('quickedit-image-element');
+
+ // Render our initial dropzone element. Once the user reverts changes
+ // or saves a new image, this element is removed.
+ var $dropzone = this.renderDropzone('upload', Drupal.t('Drop file here or click to upload'));
+
+ $dropzone.on('dragenter', function (e) {
+ $(this).addClass('hover');
+ });
+ $dropzone.on('dragleave', function (e) {
+ $(this).removeClass('hover');
+ });
+
+ $dropzone.on('drop', function (e) {
+ // Only respond when a file is dropped (could be another element).
+ if (e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files.length) {
+ $(this).removeClass('hover');
+ self.uploadImage(e.originalEvent.dataTransfer.files[0]);
+ }
+ });
+
+ $dropzone.on('click', function (e) {
+ // Create an element without appending it to the DOM, and
+ // trigger a click event. This is the easiest way to arbitrarily
+ // open the browser's upload dialog.
+ $('')
+ .trigger('click')
+ .on('change', function () {
+ if (this.files.length) {
+ self.uploadImage(this.files[0]);
+ }
+ });
+ });
+
+ // Prevent the browser's default behavior when dragging files onto
+ // the document (usually opens them in the same tab).
+ $dropzone.on('dragover dragenter dragleave drop click', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+
+ this.renderToolbar(fieldModel);
+ break;
+
+ case 'changed':
+ break;
+
+ case 'saving':
+ if (from === 'invalid') {
+ this.removeValidationErrors();
+ }
+
+ this.save(options);
+ break;
+
+ case 'saved':
+ break;
+
+ case 'invalid':
+ this.showValidationErrors();
+ break;
+ }
+ },
+
+ /**
+ * Validates/uploads a given file.
+ *
+ * @param {File} file
+ * The file to upload.
+ */
+ uploadImage: function (file) {
+ // Indicate loading by adding a special class to our icon.
+ this.renderDropzone('upload loading', Drupal.t('Uploading @file…', {'@file': file.name}));
+
+ // Build a valid URL for our endpoint.
+ var fieldID = this.fieldModel.get('fieldID');
+ var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode'));
+
+ // Construct form data that our endpoint can consume.
+ var data = new FormData();
+ data.append('files[image]', file);
+
+ // Construct a POST request to our endpoint.
+ var self = this;
+ this.ajax({
+ type: 'POST',
+ url: url,
+ data: data,
+ success: function (response) {
+ var $el = $(self.fieldModel.get('el'));
+ // Indicate that the field has changed - this enables the
+ // "Save" button.
+ self.fieldModel.set('state', 'changed');
+ self.fieldModel.get('entity').set('inTempStore', true);
+ self.removeValidationErrors();
+
+ // Replace our html with the new image. If we replaced our entire
+ // element with data.html, we would have to implement complicated logic
+ // like what's in Drupal.quickedit.AppView.renderUpdatedField.
+ var $content = $(response.html).closest('[data-quickedit-field-id]').children();
+ $el.empty().append($content);
+ }
+ });
+ },
+
+ /**
+ * Utility function to make an AJAX request to the server.
+ *
+ * In addition to formatting the correct request, this also handles error
+ * codes and messages by displaying them visually inline with the image.
+ *
+ * Drupal.ajax is not called here as the Form API is unused by this
+ * in-place editor, and our JSON requests/responses try to be
+ * editor-agnostic. Ideally similar logic and routes could be used by
+ * modules like CKEditor for drag+drop file uploads as well.
+ *
+ * @param {object} options
+ * Ajax options.
+ * @param {string} options.type
+ * The type of request (i.e. GET, POST, PUT, DELETE, etc.)
+ * @param {string} options.url
+ * The URL for the request.
+ * @param {*} options.data
+ * The data to send to the server.
+ * @param {function} options.success
+ * A callback function used when a request is successful, without errors.
+ */
+ ajax: function (options) {
+ var defaultOptions = {
+ context: this,
+ dataType: 'json',
+ cache: false,
+ contentType: false,
+ processData: false,
+ error: function () {
+ this.renderDropzone('error', Drupal.t('A server error has occurred.'));
+ }
+ };
+
+ var ajaxOptions = $.extend(defaultOptions, options);
+ var successCallback = ajaxOptions.success;
+
+ // Handle the success callback.
+ ajaxOptions.success = function (response) {
+ if (response.main_error) {
+ this.renderDropzone('error', response.main_error);
+ if (response.errors.length) {
+ this.model.set('validationErrors', response.errors);
+ }
+ this.showValidationErrors();
+ }
+ else {
+ successCallback(response);
+ }
+ };
+
+ $.ajax(ajaxOptions);
+ },
+
+ /**
+ * Renders our toolbar form for editing metadata.
+ *
+ * @param {Drupal.quickedit.FieldModel} fieldModel
+ * The current Field Model.
+ */
+ renderToolbar: function (fieldModel) {
+ var $toolgroup = $('#' + fieldModel.toolbarView.getMainWysiwygToolgroupId());
+ var $toolbar = $toolgroup.find('.quickedit-image-field-info');
+ if ($toolbar.length === 0) {
+ // Perform an AJAX request for extra image info (alt/title).
+ var fieldID = fieldModel.get('fieldID');
+ var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode'));
+ var self = this;
+ self.ajax({
+ type: 'GET',
+ url: url,
+ success: function (response) {
+ $toolbar = $(Drupal.theme.quickeditImageToolbar(response));
+ $toolgroup.append($toolbar);
+ $toolbar.on('keyup paste', function () {
+ fieldModel.set('state', 'changed');
+ });
+ // Re-position the toolbar, which could have changed size.
+ fieldModel.get('entity').toolbarView.position();
+ }
+ });
+ }
+ },
+
+ /**
+ * Renders our dropzone element.
+ *
+ * @param {string} state
+ * The current state of our editor. Only used for visual styling.
+ * @param {string} text
+ * The text to display in the dropzone area.
+ *
+ * @return {jQuery}
+ * The rendered dropzone.
+ */
+ renderDropzone: function (state, text) {
+ var $dropzone = this.$el.find('.quickedit-image-dropzone');
+ // If the element already exists, modify its contents.
+ if ($dropzone.length) {
+ $dropzone
+ .removeClass('upload error hover loading')
+ .addClass('.quickedit-image-dropzone ' + state)
+ .children('.quickedit-image-text')
+ .html(text);
+ }
+ else {
+ $dropzone = $(Drupal.theme('quickeditImageDropzone', {
+ state: state,
+ text: text
+ }));
+ this.$el.append($dropzone);
+ }
+
+ return $dropzone;
+ },
+
+ /**
+ * @inheritdoc
+ */
+ revert: function () {
+ this.$el.html(this.model.get('originalValue'));
+ },
+
+ /**
+ * @inheritdoc
+ */
+ getQuickEditUISettings: function () {
+ return {padding: false, unifiedToolbar: true, fullWidthToolbar: true, popup: false};
+ },
+
+ /**
+ * @inheritdoc
+ */
+ showValidationErrors: function () {
+ var errors = Drupal.theme('quickeditImageErrors', {
+ errors: this.model.get('validationErrors')
+ });
+ $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
+ .append(errors);
+ this.getEditedElement()
+ .addClass('quickedit-validation-error');
+ // Re-position the toolbar, which could have changed size.
+ this.fieldModel.get('entity').toolbarView.position();
+ },
+
+ /**
+ * @inheritdoc
+ */
+ removeValidationErrors: function () {
+ $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
+ .find('.quickedit-image-errors').remove();
+ this.getEditedElement()
+ .removeClass('quickedit-validation-error');
+ }
+
+ });
+
+})(jQuery, _, Drupal);
diff --git a/core/modules/image/js/theme.js b/core/modules/image/js/theme.js
new file mode 100644
index 0000000..9e61e8a
--- /dev/null
+++ b/core/modules/image/js/theme.js
@@ -0,0 +1,86 @@
+/**
+ * @file
+ * Provides theme functions for image Quick Edit's client-side HTML.
+ */
+
+(function (Drupal) {
+
+ 'use strict';
+
+ /**
+ * Theme function for validation errors of the Image in-place editor.
+ *
+ * @param {object} settings
+ * Settings object used to construct the markup.
+ * @param {string} settings.errors
+ * Already escaped HTML representing error messages.
+ *
+ * @return {string}
+ * The corresponding HTML.
+ */
+ Drupal.theme.quickeditImageErrors = function(settings) {
+ return '
' + settings.errors + '
';
+ };
+
+ /**
+ * Theme function for the dropzone element of the Image module's in-place
+ * editor.
+ *
+ * @param {object} settings
+ * Settings object used to construct the markup.
+ * @param {string} settings.state
+ * State of the upload.
+ * @param {string} settings.text
+ * Text to display inline with the dropzone element.
+ *
+ * @return {string}
+ * The corresponding HTML.
+ */
+ Drupal.theme.quickeditImageDropzone = function (settings) {
+ return '
' +
+ ' ' +
+ ' ' + settings.text +'' +
+ '
';
+ };
+
+ /**
+ * Theme function for the toolbar of the Image module's in-place editor.
+ *
+ * @param {object} settings
+ * Settings object used to construct the markup.
+ * @param {bool} settings.alt_field
+ * Whether or not the "Alt" field is enabled for this field.
+ * @param {bool} settings.alt_field_required
+ * Whether or not the "Alt" field is required for this field.
+ * @param {string} settings.alt
+ * The current value for the "Alt" field.
+ * @param {bool} settings.title_field
+ * Whether or not the "Title" field is enabled for this field.
+ * @param {bool} settings.title_field_required
+ * Whether or not the "Title" field is required for this field.
+ * @param {string} settings.title
+ * The current value for the "Title" field.
+ *
+ * @return {string}
+ * The corresponding HTML.
+ */
+ Drupal.theme.quickeditImageToolbar = function (settings) {
+ var html = '';
+
+ return html;
+ };
+
+})(Drupal);
diff --git a/core/modules/image/src/Controller/QuickEditImageController.php b/core/modules/image/src/Controller/QuickEditImageController.php
new file mode 100644
index 0000000..a913086
--- /dev/null
+++ b/core/modules/image/src/Controller/QuickEditImageController.php
@@ -0,0 +1,225 @@
+renderer = $renderer;
+ $this->imageFactory = $image_factory;
+ $this->tempStore = $temp_store_factory->get('quickedit');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('renderer'),
+ $container->get('image.factory'),
+ $container->get('user.private_tempstore')
+ );
+ }
+
+ /**
+ * Returns JSON representing the new file upload, or validation errors.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ * @param string $view_mode_id
+ * The view mode of the field that is being rendered.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * The JSON response.
+ */
+ public function upload(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
+ $field = $this->getField($entity, $field_name, $langcode);
+ $field_validators = $field->getUploadValidators();
+ $field_settings = $field->getFieldDefinition()->getSettings();
+ $destination = $field->getUploadLocation();
+
+ // Add upload resolution validation.
+ if ($field_settings['max_resolution'] || $field_settings['min_resolution']) {
+ $field_validators['file_validate_image_resolution'] = [$field_settings['max_resolution'], $field_settings['min_resolution']];
+ }
+
+ // Create the destination directory if it does not already exist.
+ if (isset($destination) && !file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
+ return new JsonResponse(['main_error' => $this->t('The destination directory could not be created.'), 'errors' => '']);
+ }
+
+ // Attempt to save the image given the field's constraints.
+ $result = file_save_upload('image', $field_validators, $destination);
+ if (is_array($result) && $result[0]) {
+ /** @var \Drupal\file\Entity\File $file */
+ $file = $result[0];
+ $image = $this->imageFactory->get($file->getFileUri());
+
+ // Set the value in the Entity to the new file.
+ /** @var \Drupal\file\Plugin\Field\FieldType\FileFieldItemList $field_list */
+ $value = $entity->$field_name->getValue();
+ $value[0]['target_id'] = $file->id();
+ $value[0]['width'] = $image->getWidth();
+ $value[0]['height'] = $image->getHeight();
+ $entity->$field_name->setValue($value);
+
+ // Render the new image using the correct formatter settings.
+ $entity_view_mode_ids = array_keys($this->entityManager()->getViewModes($entity->getEntityTypeId()));
+ if (in_array($view_mode_id, $entity_view_mode_ids)) {
+ $output = $entity->$field_name->view($view_mode_id);
+ }
+ else {
+ // Each part of a custom (non-Entity Display) view mode ID is separated
+ // by a dash; the first part must be the module name.
+ $mode_id_parts = explode('-', $view_mode_id, 2);
+ $module = reset($mode_id_parts);
+ $args = [$entity, $field_name, $view_mode_id, $langcode];
+ $output = $this->moduleHandler()->invoke($module, 'quickedit_render_field', $args);
+ }
+
+ // Save the Entity to tempstore.
+ $this->tempStore->set($entity->uuid(), $entity);
+
+ $data = [
+ 'fid' => $file->id(),
+ 'html' => $this->renderer->renderRoot($output),
+ ];
+ return new JsonResponse($data);
+ }
+ else {
+ // Return a JSON object containing the errors from Drupal and our
+ // "main_error", which is displayed inside the dropzone area.
+ $messages = StatusMessages::renderMessages('error');
+ return new JsonResponse(['errors' => $this->renderer->render($messages), 'main_error' => $this->t('The image failed validation.')]);
+ }
+ }
+
+ /**
+ * Returns JSON representing an image field's metadata.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ * @param string $view_mode_id
+ * The view mode of the field that is being rendered.
+ *
+ * @return \Drupal\Core\Cache\CacheableJsonResponse
+ * The JSON response.
+ */
+ public function getInfo(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
+ $field = $this->getField($entity, $field_name, $langcode);
+ $settings = $field->getFieldDefinition()->getSettings();
+ $info = [
+ 'alt' => $field->alt,
+ 'title' => $field->title,
+ 'alt_field' => $settings['alt_field'],
+ 'title_field' => $settings['title_field'],
+ 'alt_field_required' => $settings['alt_field_required'],
+ 'title_field_required' => $settings['title_field_required'],
+ ];
+ $response = new CacheableJsonResponse($info);
+ $response->addCacheableDependency($entity);
+ return $response;
+ }
+
+ /**
+ * Returns JSON representing the current state of the field.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ *
+ * @return \Drupal\image\Plugin\Field\FieldType\ImageItem
+ * The field for this request.
+ *
+ * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * Throws an exception if the request is invalid.
+ */
+ protected function getField(EntityInterface $entity, $field_name, $langcode) {
+ // Ensure that this is a valid Entity.
+ if (!($entity instanceof ContentEntityInterface)) {
+ throw new BadRequestHttpException('Requested Entity is not a Content Entity.');
+ }
+
+ // Check that this field exists.
+ /** @var \Drupal\Core\Field\FieldItemListInterface $field_list */
+ $field_list = $entity->getTranslation($langcode)->$field_name;
+ if (!$field_list) {
+ throw new BadRequestHttpException('Requested Field does not exist.');
+ }
+
+ // If the list is empty, append an empty item to use.
+ if ($field_list->isEmpty()) {
+ $field = $field_list->appendItem();
+ }
+ // Otherwise, use the first item.
+ else {
+ $field = $entity->getTranslation($langcode)->$field_name->get(0);
+ }
+
+ // Ensure that the field is the type we expect.
+ if (!($field instanceof ImageItem)) {
+ throw new BadRequestHttpException('Requested Field is not of type "image".');
+ }
+
+ return $field;
+ }
+
+}
diff --git a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php
index 4c3a27d..c1ee4df 100644
--- a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php
+++ b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php
@@ -22,6 +22,9 @@
* label = @Translation("Image"),
* field_types = {
* "image"
+ * },
+ * quickedit = {
+ * "editor" = "image"
* }
* )
*/
diff --git a/core/modules/image/src/Plugin/InPlaceEditor/Image.php b/core/modules/image/src/Plugin/InPlaceEditor/Image.php
new file mode 100644
index 0000000..9cf9cd2
--- /dev/null
+++ b/core/modules/image/src/Plugin/InPlaceEditor/Image.php
@@ -0,0 +1,39 @@
+getFieldDefinition();
+
+ // This editor is only compatible with single-value image fields.
+ return $field_definition->getFieldStorageDefinition()->getCardinality() === 1
+ && $field_definition->getType() === 'image';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAttachments() {
+ return [
+ 'library' => [
+ 'image/quickedit.inPlaceEditor.image',
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/image/src/Tests/QuickEditImageControllerTest.php b/core/modules/image/src/Tests/QuickEditImageControllerTest.php
new file mode 100644
index 0000000..0485f8a
--- /dev/null
+++ b/core/modules/image/src/Tests/QuickEditImageControllerTest.php
@@ -0,0 +1,186 @@
+drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
+
+ // Log in as a content author who can use Quick Edit and edit Articles.
+ $this->contentAuthorUser = $this->drupalCreateUser([
+ 'access contextual links',
+ 'access in-place editing',
+ 'access content',
+ 'create article content',
+ 'edit any article content',
+ 'delete any article content',
+ ]);
+ $this->drupalLogin($this->contentAuthorUser);
+
+ // Create a field with basic resolution validators.
+ $this->fieldName = strtolower($this->randomMachineName());
+ $field_settings = [
+ 'max_resolution' => '100x',
+ 'min_resolution' => '50x',
+ ];
+ $this->createImageField($this->fieldName, 'article', [], $field_settings);
+ }
+
+ /**
+ * Tests that routes restrict access for un-privileged users.
+ */
+ function testAccess() {
+ // Create an anonymous user.
+ $user = $this->createUser();
+ $this->drupalLogin($user);
+
+ // Create a test Node.
+ $node = $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => t('Test Node'),
+ ]);
+ $this->drupalGet('quickedit/image/info/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default');
+ $this->assertResponse('403');
+ $this->drupalPost('quickedit/image/upload/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default', 'application/json', []);
+ $this->assertResponse('403');
+ }
+
+ /**
+ * Tests that the field info route returns expected data.
+ */
+ function testFieldInfo() {
+ // Create a test Node.
+ $node = $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => t('Test Node'),
+ ]);
+ $info = $this->drupalGetJSON('quickedit/image/info/node/' . $node->id() . '/' . $this->fieldName . '/' . $node->language()->getId() . '/default');
+ // Assert that the default settings for our field are respected by our JSON
+ // endpoint.
+ $this->assertTrue($info['alt_field']);
+ $this->assertFalse($info['title_field']);
+ }
+
+ /**
+ * Tests that uploading a valid image works.
+ */
+ function testValidImageUpload() {
+ // Create a test Node.
+ $node = $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => t('Test Node'),
+ ]);
+
+ // We want a test image that is a valid size.
+ $valid_image = FALSE;
+ $image_factory = $this->container->get('image.factory');
+ foreach ($this->drupalGetTestFiles('image') as $image) {
+ $image_file = $image_factory->get($image->uri);
+ if ($image_file->getWidth() > 50 && $image_file->getWidth() < 100) {
+ $valid_image = $image;
+ break;
+ }
+ }
+ $this->assertTrue($valid_image);
+ $this->uploadImage($valid_image, $node->id(), $this->fieldName, $node->language()->getId());
+ $this->assertText('fid', t('Valid upload completed successfully.'));
+ }
+
+ /**
+ * Tests that uploading a invalid image does not work.
+ */
+ function testInvalidUpload() {
+ // Create a test Node.
+ $node = $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => t('Test Node'),
+ ]);
+
+ // We want a test image that will fail validation.
+ $invalid_image = FALSE;
+ /** @var \Drupal\Core\Image\ImageFactory $image_factory */
+ $image_factory = $this->container->get('image.factory');
+ foreach ($this->drupalGetTestFiles('image') as $image) {
+ /** @var \Drupal\Core\Image\ImageInterface $image_file */
+ $image_file = $image_factory->get($image->uri);
+ if ($image_file->getWidth() < 50 || $image_file->getWidth() > 100 ) {
+ $invalid_image = $image;
+ break;
+ }
+ }
+ $this->assertTrue($invalid_image);
+ $this->uploadImage($invalid_image, $node->id(), $this->fieldName, $node->language()->getId());
+ $this->assertText('main_error', t('Invalid upload returned errors.'));
+ }
+
+ /**
+ * Uploads an image using the image module's Quick Edit route.
+ *
+ * @param object $image
+ * The image to upload.
+ * @param integer $nid
+ * The target node ID.
+ * @param string $field_name
+ * The target field machine name.
+ * @param string $langcode
+ * The langcode to use when setting the field's value.
+ *
+ * @return mixed
+ * The content returned from the call to $this->curlExec().
+ */
+ function uploadImage($image, $nid, $field_name, $langcode) {
+ $filepath = $this->container->get('file_system')->realpath($image->uri);
+ $data = [
+ 'files[image]' => curl_file_create($filepath),
+ ];
+ $path = 'quickedit/image/upload/node/' . $nid . '/' . $field_name . '/' . $langcode . '/default';
+ // We assemble the curl request ourselves as drupalPost cannot process file
+ // uploads, and drupalPostForm only works with typical Drupal forms.
+ return $this->curlExec([
+ CURLOPT_URL => $this->buildUrl($path, []),
+ CURLOPT_POST => TRUE,
+ CURLOPT_POSTFIELDS => $data,
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/json',
+ 'Content-Type: multipart/form-data',
+ ],
+ ]);
+ }
+
+}
diff --git a/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php
new file mode 100644
index 0000000..6e924f0
--- /dev/null
+++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php
@@ -0,0 +1,171 @@
+drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
+
+ // Log in as a content author who can use Quick Edit and edit Articles.
+ $this->contentAuthorUser = $this->drupalCreateUser([
+ 'access contextual links',
+ 'access toolbar',
+ 'access in-place editing',
+ 'access content',
+ 'create article content',
+ 'edit any article content',
+ 'delete any article content',
+ ]);
+ $this->drupalLogin($this->contentAuthorUser);
+ }
+
+ /**
+ * Tests if an image can be uploaded inline with Quick Edit.
+ */
+ public function testUpload() {
+ // Create a field with a basic filetype restriction.
+ $field_name = strtolower($this->randomMachineName());
+ $field_settings = [
+ 'file_extensions' => 'png',
+ ];
+ $formatter_settings = [
+ 'image_style' => 'large',
+ 'image_link' => '',
+ ];
+ $this->createImageField($field_name, 'article', [], $field_settings, [], $formatter_settings);
+
+ // Find images that match our field settings.
+ $valid_images = [];
+ foreach ($this->getTestFiles('image') as $image) {
+ // This regex is taken from file_validate_extensions().
+ $regex = '/\.(' . preg_replace('/ +/', '|', preg_quote($field_settings['file_extensions'])) . ')$/i';
+ if (preg_match($regex, $image->filename)) {
+ $valid_images[] = $image;
+ }
+ }
+
+ // Ensure we have at least two valid images.
+ $this->assertGreaterThanOrEqual(2, count($valid_images));
+
+ // Create a File entity for the initial image.
+ $file = File::create([
+ 'uri' => $valid_images[0]->uri,
+ 'uid' => $this->contentAuthorUser->id(),
+ 'status' => FILE_STATUS_PERMANENT,
+ ]);
+ $file->save();
+
+ // Use the first valid image to create a new Node.
+ $image_factory = $this->container->get('image.factory');
+ $image = $image_factory->get($valid_images[0]->uri);
+ $node = $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => t('Test Node'),
+ $field_name => [
+ 'target_id' => $file->id(),
+ 'alt' => 'Hello world',
+ 'title' => '',
+ 'width' => $image->getWidth(),
+ 'height' => $image->getHeight(),
+ ],
+ ]);
+
+ // Visit the new Node.
+ $this->drupalGet('node/' . $node->id());
+
+ // Assemble common CSS selectors.
+ $entity_selector = '[data-quickedit-entity-id="node/' . $node->id() . '"]';
+ $field_selector = '[data-quickedit-field-id="node/' . $node->id() . '/' . $field_name . '/' . $node->language()->getId() . '/full"]';
+ $original_image_selector = 'img[src*="' . $valid_images[0]->filename . '"][alt="Hello world"]';
+ $new_image_selector = 'img[src*="' . $valid_images[1]->filename . '"][alt="New text"]';
+
+ // Assert that the initial image is present.
+ $this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $original_image_selector);
+
+ // Wait until Quick Edit loads.
+ $condition = "jQuery('" . $entity_selector . " .quickedit').length > 0";
+ $this->assertJsCondition($condition, 10000);
+
+ // Initiate Quick Editing.
+ $this->click('.contextual-toolbar-tab button');
+ $this->click($entity_selector . ' [data-contextual-id] > button');
+ $this->click($entity_selector . ' [data-contextual-id] .quickedit > a');
+ $this->click($field_selector);
+
+ // Wait for the field info to load and set new alt text.
+ $condition = "jQuery('.quickedit-image-field-info').length > 0";
+ $this->assertJsCondition($condition, 10000);
+ $input = $this->assertSession()->elementExists('css', '.quickedit-image-field-info input[name="alt"]');
+ $input->setValue('New text');
+
+ // Check that our Dropzone element exists.
+ $this->assertSession()->elementExists('css', $field_selector . ' .quickedit-image-dropzone');
+
+ // Our headless browser can't drag+drop files, but we can mock the event.
+ // Append a hidden upload element to the DOM.
+ $script = 'jQuery("").appendTo("body")';
+ $this->getSession()->executeScript($script);
+
+ // Find the element, and set its value to our new image.
+ $input = $this->assertSession()->elementExists('css', '#quickedit-image-test-input');
+ $filepath = $this->container->get('file_system')->realpath($valid_images[1]->uri);
+ $input->attachFile($filepath);
+
+ // Trigger the upload logic with a mock "drop" event.
+ $script = 'var e = jQuery.Event("drop");'
+ . 'e.originalEvent = {dataTransfer: {files: jQuery("#quickedit-image-test-input").get(0).files}};'
+ . 'e.preventDefault = e.stopPropagation = function () {};'
+ . 'jQuery(".quickedit-image-dropzone").trigger(e);';
+ $this->getSession()->executeScript($script);
+
+ // Wait for the dropzone element to be removed (i.e. loading is done).
+ $condition = "jQuery('" . $field_selector . " .quickedit-image-dropzone').length == 0";
+ $this->assertJsCondition($condition, 20000);
+
+ // To prevent 403s on save, we re-set our request (cookie) state.
+ $this->prepareRequest();
+
+ // Save the change.
+ $this->click('.quickedit-button.action-save');
+ $this->assertSession()->assertWaitOnAjaxRequest();
+
+ // Re-visit the page to make sure the edit worked.
+ $this->drupalGet('node/' . $node->id());
+
+ // Check that the new image appears as expected.
+ $this->assertSession()->elementNotExists('css', $entity_selector . ' ' . $field_selector . ' ' . $original_image_selector);
+ $this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $new_image_selector);
+ }
+}
diff --git a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
index ff91e68..3ec1a19 100644
--- a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
+++ b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
@@ -23,6 +23,9 @@
* label = @Translation("Responsive image"),
* field_types = {
* "image",
+ * },
+ * quickedit = {
+ * "editor" = "image"
* }
* )
*/
diff --git a/core/themes/stable/css/image/editors/image.css b/core/themes/stable/css/image/editors/image.css
new file mode 100644
index 0000000..08bb679
--- /dev/null
+++ b/core/themes/stable/css/image/editors/image.css
@@ -0,0 +1,52 @@
+/**
+ * @file
+ * Functional styles for the Image module's in-place editor.
+ */
+
+/**
+ * A minimum width/height is required so that users can drag and drop files
+ * onto small images.
+ */
+.quickedit-image-element {
+ min-width: 200px;
+ min-height: 200px;
+}
+
+.quickedit-image-dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.quickedit-image-icon {
+ display: block;
+ width: 50px;
+ height: 50px;
+ background-repeat: no-repeat;
+ background-size: cover;
+}
+
+.quickedit-image-field-info {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.quickedit-image-text {
+ display: block;
+}
+
+/**
+ * If we do not prevent pointer-events for child elements, our drag+drop events
+ * will not fire properly. This can lead to unintentional redirects if a file
+ * is dropped on a child element when a user intended to upload it.
+ */
+.quickedit-image-dropzone * {
+ pointer-events: none;
+}
diff --git a/core/themes/stable/css/image/editors/image.theme.css b/core/themes/stable/css/image/editors/image.theme.css
new file mode 100644
index 0000000..3a05c20
--- /dev/null
+++ b/core/themes/stable/css/image/editors/image.theme.css
@@ -0,0 +1,90 @@
+/**
+ * @file
+ * Theme styles for the Image module's in-place editor.
+ */
+
+.quickedit-image-dropzone {
+ background: rgba(116, 183, 255, 0.8);
+ transition: background .2s;
+}
+
+.quickedit-image-icon {
+ margin: 0 0 10px 0;
+ transition: margin .5s;
+}
+
+.quickedit-image-dropzone.hover {
+ background: rgba(116, 183, 255, 0.9);
+}
+
+.quickedit-image-dropzone.error {
+ background: rgba(255, 52, 27, 0.81);
+}
+
+.quickedit-image-dropzone.upload .quickedit-image-icon {
+ background-image: url('../../../images/image/upload.svg');
+}
+
+.quickedit-image-dropzone.error .quickedit-image-icon {
+ background-image: url('../../../images/image/error.svg');
+}
+
+.quickedit-image-dropzone.loading .quickedit-image-icon {
+ margin: -10px 0 20px 0;
+}
+
+.quickedit-image-dropzone.loading .quickedit-image-icon::after {
+ display: block;
+ content: "";
+ margin-left: -10px;
+ margin-top: -5px;
+ animation-duration: 2s;
+ animation-name: quickedit-image-spin;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+ width: 60px;
+ height: 60px;
+ border-style: solid;
+ border-radius: 35px;
+ border-width: 5px;
+ border-color: white transparent transparent transparent;
+}
+
+@keyframes quickedit-image-spin {
+ 0% {transform: rotate(0deg);}
+ 50% {transform: rotate(180deg);}
+ 100% {transform: rotate(360deg);}
+}
+
+.quickedit-image-text {
+ text-align: center;
+ color: white;
+ font-family: "Droid sans", "Lucida Grande", sans-serif;
+ font-size: 16px;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+
+.quickedit-image-field-info {
+ background: rgba(0, 0, 0, 0.05);
+ border-top: 1px solid #c5c5c5;
+ padding: 5px;
+}
+
+.quickedit-image-field-info div {
+ margin-right: 10px;
+}
+
+.quickedit-image-field-info div:last-child {
+ margin-right: 0;
+}
+
+.quickedit-image-errors .messages__wrapper {
+ margin: 0;
+ padding: 0;
+}
+
+.quickedit-image-errors .messages--error {
+ box-shadow: none;
+}
diff --git a/core/themes/stable/images/image/error.svg b/core/themes/stable/images/image/error.svg
new file mode 100644
index 0000000..1932ea4
--- /dev/null
+++ b/core/themes/stable/images/image/error.svg
@@ -0,0 +1,4 @@
+
diff --git a/core/themes/stable/images/image/upload.svg b/core/themes/stable/images/image/upload.svg
new file mode 100644
index 0000000..168bc43
--- /dev/null
+++ b/core/themes/stable/images/image/upload.svg
@@ -0,0 +1,4 @@
+
diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml
index 3476833..7e585b1 100644
--- a/core/themes/stable/stable.info.yml
+++ b/core/themes/stable/stable.info.yml
@@ -98,6 +98,12 @@ libraries-override:
css:
theme:
css/image.admin.css: css/image/image.admin.css
+ image/quickedit.inPlaceEditor.image:
+ css:
+ component:
+ css/editors/image.css: css/image/editors/image.css
+ theme:
+ css/editors/image.theme.css: css/image/editors/image.theme.css
language/drupal.language.admin:
css: