From 24eb0704ad804839be00e1a38e3e06059b85e747 Mon Sep 17 00:00:00 2001
From: webchick <drupal@webchick.net>
Date: Tue, 15 Nov 2016 12:57:16 -0800
Subject: [PATCH] =?UTF-8?q?Issue=20#2421427=20by=20samuel.mortenson,=20dro?=
 =?UTF-8?q?plet,=20dawehner,=20nod=5F,=20Cottser,=20Wim=20Leers,=20xjm,=20?=
 =?UTF-8?q?G=C3=A1bor=20Hojtsy,=20Bojhan,=20tstoeckler,=20webchick,=20nave?=
 =?UTF-8?q?envalecha,=20alexpott,=20LewisNyman,=20chris=5Fh,=20Manjit.Sing?=
 =?UTF-8?q?h,=20phenaproxima,=20avitslv,=20yoroy,=20tim.plunkett,=20Mixolo?=
 =?UTF-8?q?gic,=20ipwa,=20slashrsm:=20Improve=20the=20UX=20of=20Quick=20Ed?=
 =?UTF-8?q?iting=20single-valued=20image=20fields?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 core/modules/image/css/editors/image.css      |  52 +++
 .../modules/image/css/editors/image.theme.css | 100 +++++
 core/modules/image/image.install              |   7 +
 core/modules/image/image.libraries.yml        |  16 +
 core/modules/image/image.routing.yml          |  26 ++
 core/modules/image/images/error.svg           |   4 +
 core/modules/image/images/upload.svg          |   4 +
 core/modules/image/js/editors/image.js        | 342 ++++++++++++++++++
 core/modules/image/js/theme.js                |  86 +++++
 .../Controller/QuickEditImageController.php   | 225 ++++++++++++
 .../Field/FieldFormatter/ImageFormatter.php   |   3 +
 .../image/src/Plugin/InPlaceEditor/Image.php  |  39 ++
 .../Tests/QuickEditImageControllerTest.php    | 186 ++++++++++
 .../QuickEditImageTest.php                    | 172 +++++++++
 .../ResponsiveImageFormatter.php              |   3 +
 .../themes/stable/css/image/editors/image.css |  52 +++
 .../stable/css/image/editors/image.theme.css  | 100 +++++
 core/themes/stable/images/image/error.svg     |   4 +
 core/themes/stable/images/image/upload.svg    |   4 +
 core/themes/stable/stable.info.yml            |   6 +
 20 files changed, 1431 insertions(+)
 create mode 100644 core/modules/image/css/editors/image.css
 create mode 100644 core/modules/image/css/editors/image.theme.css
 create mode 100644 core/modules/image/images/error.svg
 create mode 100644 core/modules/image/images/upload.svg
 create mode 100644 core/modules/image/js/editors/image.js
 create mode 100644 core/modules/image/js/theme.js
 create mode 100644 core/modules/image/src/Controller/QuickEditImageController.php
 create mode 100644 core/modules/image/src/Plugin/InPlaceEditor/Image.php
 create mode 100644 core/modules/image/src/Tests/QuickEditImageControllerTest.php
 create mode 100644 core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php
 create mode 100644 core/themes/stable/css/image/editors/image.css
 create mode 100644 core/themes/stable/css/image/editors/image.theme.css
 create mode 100644 core/themes/stable/images/image/error.svg
 create mode 100644 core/themes/stable/images/image/upload.svg

diff --git a/core/modules/image/css/editors/image.css b/core/modules/image/css/editors/image.css
new file mode 100644
index 000000000000..08bb6792e8df
--- /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 000000000000..cb0c7f9c8085
--- /dev/null
+++ b/core/modules/image/css/editors/image.theme.css
@@ -0,0 +1,100 @@
+/**
+ * @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;
+  user-select: none;
+  -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; /* LTR */
+}
+
+.quickedit-image-field-info div:last-child {
+  margin-right: 0; /* LTR */
+}
+
+[dir="rtl"] .quickedit-image-field-info div {
+  margin-left: 10px;
+  margin-right: 0;
+}
+
+[dir="rtl"] .quickedit-image-field-info div:last-child {
+  margin-left: 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 fd14d038de9d..c044912ef1de 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 e9061a42e424..a47a2b518f14 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 ffeed861a2a9..4a0cb88a833a 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 000000000000..1932ea402c5b
--- /dev/null
+++ b/core/modules/image/images/error.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
+</svg>
diff --git a/core/modules/image/images/upload.svg b/core/modules/image/images/upload.svg
new file mode 100644
index 000000000000..168bc43e6afb
--- /dev/null
+++ b/core/modules/image/images/upload.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
+</svg>
diff --git a/core/modules/image/js/editors/image.js b/core/modules/image/js/editors/image.js
new file mode 100644
index 000000000000..dea08df59ee8
--- /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 <input> 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.
+            $('<input type="file">')
+              .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 <i>@file</i>…', {'@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 000000000000..cba8f7bbca6b
--- /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 '<div class="quickedit-image-errors">' + settings.errors + '</div>';
+  };
+
+  /**
+   * 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 '<div class="quickedit-image-dropzone ' + settings.state + '">' +
+      '  <i class="quickedit-image-icon"></i>' +
+      '  <span class="quickedit-image-text">' + settings.text + '</span>' +
+      '</div>';
+  };
+
+  /**
+   * 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 = '<form class="quickedit-image-field-info">';
+    if (settings.alt_field) {
+      html += '  <div>' +
+        '    <label for="alt" class="' + (settings.alt_field_required ? 'required' : '') + '">' + Drupal.t('Alternative text') + '</label>' +
+        '    <input type="text" placeholder="' + settings.alt + '" value="' + settings.alt + '" name="alt" ' + (settings.alt_field_required ? 'required' : '') + '/>' +
+        '  </div>';
+    }
+    if (settings.title_field) {
+      html += '  <div>' +
+        '    <label for="title" class="' + (settings.title_field_required ? 'form-required' : '') + '">' + Drupal.t('Title') + '</label>' +
+        '    <input type="text" placeholder="' + settings.title + '" value="' + settings.title + '" name="title" ' + (settings.title_field_required ? 'required' : '') + '/>' +
+        '  </div>';
+    }
+    html += '</form>';
+
+    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 000000000000..aa49bc7d3c39
--- /dev/null
+++ b/core/modules/image/src/Controller/QuickEditImageController.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Drupal\image\Controller;
+
+use Drupal\Core\Cache\CacheableJsonResponse;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Image\ImageFactory;
+use Drupal\Core\Render\Element\StatusMessages;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\image\Plugin\Field\FieldType\ImageItem;
+use Drupal\user\PrivateTempStoreFactory;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Returns responses for our image routes.
+ */
+class QuickEditImageController extends ControllerBase {
+
+  /**
+   * Stores The Quick Edit tempstore.
+   *
+   * @var \Drupal\user\PrivateTempStore
+   */
+  protected $tempStore;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The image factory.
+   *
+   * @var \Drupal\Core\Image\ImageFactory
+   */
+  protected $imageFactory;
+
+  /**
+   * Constructs a new QuickEditImageController.
+   *
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\Core\Image\ImageFactory $image_factory
+   *   The image factory.
+   * @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
+   *   The tempstore factory.
+   */
+  public function __construct(RendererInterface $renderer, ImageFactory $image_factory, PrivateTempStoreFactory $temp_store_factory) {
+    $this->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, TRUE)) {
+        $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)->get($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)->get($field_name)->first();
+    }
+
+    // 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 4c3a27db441b..c1ee4dfa4946 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 000000000000..9cf9cd24859f
--- /dev/null
+++ b/core/modules/image/src/Plugin/InPlaceEditor/Image.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\image\Plugin\InPlaceEditor;
+
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\quickedit\Plugin\InPlaceEditorBase;
+
+/**
+ * Defines the image text in-place editor.
+ *
+ * @InPlaceEditor(
+ *   id = "image"
+ * )
+ */
+class Image extends InPlaceEditorBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCompatible(FieldItemListInterface $items) {
+    $field_definition = $items->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 000000000000..348ee2dd097a
--- /dev/null
+++ b/core/modules/image/src/Tests/QuickEditImageControllerTest.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace Drupal\image\Tests;
+
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Tests the endpoints used by the "image" in-place editor.
+ *
+ * @group image
+ */
+class QuickEditImageControllerTest extends WebTestBase {
+
+  use ImageFieldCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'image', 'quickedit'];
+
+  /**
+   * The machine name of our image field.
+   *
+   * @var string
+   */
+  protected $fieldName;
+
+  /**
+   * A user with permissions to edit articles and use Quick Edit.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $contentAuthorUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Create the Article node type.
+    $this->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 int $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 000000000000..12e43e75850b
--- /dev/null
+++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Drupal\Tests\image\FunctionalJavascript;
+
+use Drupal\file\Entity\File;
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\Tests\TestFileCreationTrait;
+
+/**
+ * Tests the JavaScript functionality of the "image" in-place editor.
+ *
+ * @group image
+ */
+class QuickEditImageTest extends JavascriptTestBase {
+
+  use ImageFieldCreationTrait;
+  use TestFileCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'image', 'field_ui', 'contextual', 'quickedit', 'toolbar'];
+
+  /**
+   * A user with permissions to edit Articles and use Quick Edit.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $contentAuthorUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Create the Article node type.
+    $this->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("<input id=\"quickedit-image-test-input\" type=\"file\" />").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 ff91e686cccf..3ec1a1998724 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 000000000000..08bb6792e8df
--- /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 000000000000..5520758a74fd
--- /dev/null
+++ b/core/themes/stable/css/image/editors/image.theme.css
@@ -0,0 +1,100 @@
+/**
+ * @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;
+  user-select: none;
+  -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; /* LTR */
+}
+
+.quickedit-image-field-info div:last-child {
+  margin-right: 0; /* LTR */
+}
+
+[dir="rtl"] .quickedit-image-field-info div {
+  margin-left: 10px;
+  margin-right: 0;
+}
+
+[dir="rtl"] .quickedit-image-field-info div:last-child {
+  margin-left: 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 000000000000..1932ea402c5b
--- /dev/null
+++ b/core/themes/stable/images/image/error.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
+</svg>
diff --git a/core/themes/stable/images/image/upload.svg b/core/themes/stable/images/image/upload.svg
new file mode 100644
index 000000000000..168bc43e6afb
--- /dev/null
+++ b/core/themes/stable/images/image/upload.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
+</svg>
diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml
index 34768334bc95..7e585b188295 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:
-- 
GitLab