'
+ ),
+
+ /**
+ * Template to display the toolbar.
+ */
+ template_toolbar: _.template(
+ ''
+ ),
+
+ /**
+ * @inheritdoc
+ *
+ * @param {Drupal.quickedit.FieldModel} fieldModel
+ * The field model that holds the state.
+ * @param {string} state
+ * The state to change to.
+ * @param {object} options
+ * State options, if needed by the state change.
+ */
+ stateChange: function (fieldModel, state, options) {
+ var from = fieldModel.previous('state');
+ switch (state) {
+ case 'inactive':
+ break;
+
+ case 'candidate':
+ if (from !== 'inactive') {
+ this.$el.find('.quickedit-image-dropzone').remove();
+ this.$el.removeClass('quickedit-image-element');
+ }
+ if (from === 'invalid') {
+ this.removeValidationErrors();
+ }
+ break;
+
+ case 'highlighted':
+ break;
+
+ case 'activating':
+ // Defer updating the field model until the current state change has
+ // propagated, to not trigger a nested state change event.
+ _.defer(function () {
+ fieldModel.set('state', 'active');
+ });
+ break;
+
+ case 'active':
+ var self = this;
+
+ // Indicate that this element is being edited by Quick Edit Image.
+ this.$el.addClass('quickedit-image-element');
+
+ // Render our initial dropzone element. Once the user reverts changes
+ // or saves a new image, this element is removed.
+ var $dropzone = this.renderDropzone('upload', Drupal.t('Drag file here or click to upload'));
+
+ // Generic event callback to stop event behavior and bubbling.
+ var stopEvent = function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ // Prevent the browser's default behavior when dragging files onto
+ // the document (usually opens them in the same tab).
+ $dropzone.on('dragover', stopEvent);
+ $dropzone.on('dragenter', function (e) {
+ stopEvent(e);
+ $(this).addClass('hover');
+ });
+ $dropzone.on('dragleave', function (e) {
+ stopEvent(e);
+ $(this).removeClass('hover');
+ });
+
+ $dropzone.on('drop', function (e){
+ stopEvent(e);
+
+ // Only respond when a file is dropped (could be another element).
+ if(e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files.length){
+ $(this).removeClass('hover');
+ self.uploadImage(e.originalEvent.dataTransfer.files[0]);
+ }
+ });
+
+ $dropzone.on('click', function (e) {
+ stopEvent(e);
+ var $input = $('').trigger('click');
+ $input.change(function () {
+ if (this.files.length) {
+ self.uploadImage(this.files[0]);
+ }
+ });
+ });
+
+ this.renderToolbar(fieldModel);
+ break;
+
+ case 'changed':
+ break;
+
+ case 'saving':
+ if (from === 'invalid') {
+ this.removeValidationErrors();
+ }
+
+ // Before we submit, validate the alt/title text fields.
+ this.save(options);
+ break;
+
+ case 'saved':
+ break;
+
+ case 'invalid':
+ this.showValidationErrors();
+ break;
+ }
+ },
+
+ /**
+ * Validates/uploads a given file.
+ *
+ * @param {File} file
+ * The file to upload.
+ */
+ uploadImage: function (file) {
+ // Indicate loading by adding a special class to our icon.
+ this.renderDropzone('upload loading', Drupal.t('Uploading @file...', {'@file': file.name}));
+
+ // Build a valid URL for our endpoint.
+ var fieldID = this.fieldModel.get('fieldID');
+ var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode'));
+
+ // Construct form data that our endpoint can consume.
+ var data = new FormData();
+ data.append('files[image]', file);
+
+ // Construct a POST request to our endpoint.
+ var self = this;
+ this.ajax('POST', url, data, function (response) {
+ var $el = $(self.fieldModel.get('el'));
+ // Indicate that the field has changed - this enables the
+ // "Save" button.
+ self.fieldModel.set('state', 'changed');
+ self.fieldModel.get('entity').set('inTempStore', true);
+ self.removeValidationErrors();
+
+ // Replace our innerHTML with the new image. If we replaced
+ // our entire element with data.html, we would have to
+ // implement complicated logic like what's in
+ // Drupal.quickedit.AppView.renderUpdatedField.
+ var content = $(response.html).closest('[data-quickedit-field-id]').html();
+ $el.html(content);
+ });
+ },
+
+ /**
+ * Utility function to make an AJAX request to the server.
+ *
+ * In addition to formatting the correct request, this also handles error
+ * codes and messages by displaying them visually inline with the image.
+ *
+ * Drupal.ajax is not called here as the Form API is unused by this editor,
+ * and our JSON requests/responses try to be editor-agnostic. Ideally
+ * similar logic and routes could be used by modules like CKEditor for
+ * drag+drop file uploads as well.
+ *
+ * @param {string} type
+ * The type of request (i.e. GET, POST, PUT, DELETE, etc.)
+ * @param {string} url
+ * The URL for the request.
+ * @param {*} data
+ * The data to send to the server.
+ * @param {function} callback
+ * A callback function used when a request is successful, without errors.
+ */
+ ajax: function (type, url, data, callback) {
+ $.ajax({
+ url: url,
+ context: this,
+ type: type,
+ data: data,
+ dataType: 'json',
+ cache: false,
+ contentType: false,
+ processData: false,
+ success: function(response) {
+ if (response.main_error) {
+ this.renderDropzone('error', response.main_error);
+ if (response.errors.length) {
+ this.model.set('validationErrors', response.errors);
+ }
+ this.showValidationErrors();
+ }
+ else {
+ callback(response);
+ }
+ },
+ error: function() {
+ this.renderDropzone('error', Drupal.t('A server error has occurred.'));
+ }
+ });
+ },
+
+ /**
+ * Renders our toolbar form for editing metadata.
+ *
+ * @param {Drupal.quickedit.FieldModel} fieldModel
+ * The current Field Model.
+ */
+ renderToolbar: function (fieldModel) {
+ var $toolgroup = $('#' + fieldModel.toolbarView.getMainWysiwygToolgroupId());
+ var $toolbar = $toolgroup.find('.quickedit-image-field-info');
+ if ($toolbar.length === 0) {
+ // Perform an AJAX request for extra image info (alt/title).
+ var fieldID = fieldModel.get('fieldID');
+ var url = Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode'));
+ var self = this;
+ self.ajax('GET', url, null, function (response) {
+ $toolbar = $(self.template_toolbar(response));
+ $toolgroup.append($toolbar);
+ $toolbar.on('keyup paste', function () {
+ fieldModel.set('state', 'changed');
+ });
+ // Re-position the toolbar, which could have changed size.
+ fieldModel.get('entity').toolbarView.position();
+ });
+ }
+ },
+
+ /**
+ * Renders our dropzone element.
+ *
+ * @param {string} state
+ * The current state of our editor. Only used for visual styling.
+ * @param {string} text
+ * The text to display in the dropzone area.
+ */
+ renderDropzone: function (state, text) {
+ var $dropzone = this.$el.find('.quickedit-image-dropzone');
+ // If the element already exists, modify its contents.
+ if ($dropzone.length) {
+ $dropzone.removeClass('upload error hover loading');
+ $dropzone.addClass('.quickedit-image-dropzone ' + state);
+ $dropzone.children('.quickedit-image-text').html(text);
+ }
+ else {
+ $dropzone = $(this.template_dropzone({
+ 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 = $(this.template_errors({
+ errors: this.model.get('validationErrors')
+ }));
+ $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
+ .append($errors);
+ this.getEditedElement()
+ .addClass('quickedit-validation-error');
+ // Re-position the toolbar, which could have changed size.
+ this.fieldModel.get('entity').toolbarView.position();
+ },
+
+ /**
+ * @inheritdoc
+ */
+ removeValidationErrors: function () {
+ $('#' + this.fieldModel.toolbarView.getMainWysiwygToolgroupId())
+ .find('.quickedit-image-errors').remove();
+ this.getEditedElement()
+ .removeClass('quickedit-validation-error');
+ }
+
+ });
+
+})(jQuery, _, Drupal);
diff --git a/core/modules/image/src/Controller/QuickEditImageController.php b/core/modules/image/src/Controller/QuickEditImageController.php
new file mode 100644
index 0000000..bf98948
--- /dev/null
+++ b/core/modules/image/src/Controller/QuickEditImageController.php
@@ -0,0 +1,208 @@
+renderer = $renderer;
+ $this->imageFactory = $image_factory;
+ $this->tempStore = $temp_store_factory->get('quickedit');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('renderer'),
+ $container->get('image.factory'),
+ $container->get('user.private_tempstore')
+ );
+ }
+
+ /**
+ * Returns a JSON object representing the new file upload, or validation
+ * errors.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ * @param string $view_mode_id
+ * The view mode of the field that is being rendered.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * The JSON response.
+ */
+ public function upload(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
+ $field = $this->getField($entity, $field_name, $langcode);
+ $field_validators = $field->getUploadValidators();
+ $field_settings = $field->getFieldDefinition()->getSettings();
+ $destination = $field->getUploadLocation();
+
+ // Add upload resolution validation.
+ if ($field_settings['max_resolution'] || $field_settings['min_resolution']) {
+ $field_validators['file_validate_image_resolution'] = [$field_settings['max_resolution'], $field_settings['min_resolution']];
+ }
+
+ // Create the destination directory if it does not already exist.
+ if (isset($destination) && !file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
+ return new JsonResponse(['main_error' => $this->t('The destination directory could not be created.'), 'errors' => '']);
+ }
+
+ // Attempt to save the image given the field's constraints.
+ $result = file_save_upload('image', $field_validators, $destination);
+ if (is_array($result) && $result[0]) {
+ /** @var \Drupal\file\Entity\File $file */
+ $file = $result[0];
+ $image = $this->imageFactory->get($file->getFileUri());
+
+ // Set the value in the Entity to the new file.
+ /** @var \Drupal\file\Plugin\Field\FieldType\FileFieldItemList $field_list */
+ $value = $entity->$field_name->getValue();
+ $value[0]['target_id'] = $file->id();
+ $value[0]['width'] = $image->getWidth();
+ $value[0]['height'] = $image->getHeight();
+ $entity->$field_name->setValue($value);
+
+ // Render the new image using the correct formatter settings.
+ $entity_view_mode_ids = array_keys($this->entityManager()->getViewModes($entity->getEntityTypeId()));
+ if (in_array($view_mode_id, $entity_view_mode_ids)) {
+ $output = $entity->$field_name->view($view_mode_id);
+ }
+ else {
+ // Each part of a custom (non-Entity Display) view mode ID is separated
+ // by a dash; the first part must be the module name.
+ $mode_id_parts = explode('-', $view_mode_id, 2);
+ $module = reset($mode_id_parts);
+ $args = [$entity, $field_name, $view_mode_id, $langcode];
+ $output = $this->moduleHandler()->invoke($module, 'quickedit_render_field', $args);
+ }
+
+ // Save the Entity to tempstore.
+ $this->tempStore->set($entity->uuid(), $entity);
+
+ $data = [
+ 'fid' => $file->id(),
+ 'html' => $this->renderer->renderRoot($output),
+ ];
+ return new JsonResponse($data);
+ }
+ else {
+ // Return a JSON object containing the errors from Drupal and our
+ // "main_error", which is displayed inside the dropzone area.
+ $messages = StatusMessages::renderMessages('error');
+ return new JsonResponse(['errors' => $this->renderer->render($messages), 'main_error' => $this->t('The requested image failed validation.')]);
+ }
+ }
+
+ /**
+ * Returns a JSON object representing an image field's metadata.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ * @param string $view_mode_id
+ * The view mode of the field that is being rendered.
+ *
+ * @return \Symfony\Component\HttpFoundation\JsonResponse
+ * The Ajax response.
+ */
+ public function getInfo(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
+ $field = $this->getField($entity, $field_name, $langcode);
+ $settings = $field->getFieldDefinition()->getSettings();
+ $info = [
+ 'alt' => $field->alt,
+ 'title' => $field->title,
+ 'alt_field' => $settings['alt_field'],
+ 'title_field' => $settings['title_field'],
+ 'alt_field_required' => $settings['alt_field_required'],
+ 'title_field_required' => $settings['title_field_required'],
+ ];
+ return new JsonResponse($info);
+ }
+
+ /**
+ * Returns a JSON object representing the current state of the field.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity of which an image field is being rendered.
+ * @param string $field_name
+ * The name of the (image) field that is being rendered
+ * @param string $langcode
+ * The language code of the field that is being rendered.
+ * @return \Drupal\image\Plugin\Field\FieldType\ImageItem
+ * The field for this request.
+ *
+ * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * Throws an exception if the request is invalid.
+ */
+ protected function getField(EntityInterface $entity, $field_name, $langcode) {
+ // Ensure that this is a valid Entity.
+ if (!($entity instanceof ContentEntityInterface)) {
+ throw new BadRequestHttpException('Requested Entity is not a Content Entity.');
+ }
+
+ // Get the first item (we only support single-cardinality image fields).
+ $field = $entity->getTranslation($langcode)->$field_name->get(0);
+
+ 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/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"
* }
* )
*/