Chris@0: /** Chris@0: * @file Chris@0: * Drag+drop based in-place editor for images. Chris@0: */ Chris@0: Chris@17: (function($, _, Drupal) { Chris@17: Drupal.quickedit.editors.image = Drupal.quickedit.EditorView.extend( Chris@17: /** @lends Drupal.quickedit.editors.image# */ { Chris@17: /** Chris@17: * @constructs Chris@17: * Chris@17: * @augments Drupal.quickedit.EditorView Chris@17: * Chris@17: * @param {object} options Chris@17: * Options for the image editor. Chris@17: */ Chris@17: initialize(options) { Chris@17: Drupal.quickedit.EditorView.prototype.initialize.call(this, options); Chris@17: // Set our original value to our current HTML (for reverting). Chris@17: this.model.set('originalValue', this.$el.html().trim()); Chris@17: // $.val() callback function for copying input from our custom form to Chris@17: // the Quick Edit Field Form. Chris@17: this.model.set('currentValue', function(index, value) { Chris@17: const matches = $(this) Chris@17: .attr('name') Chris@17: .match(/(alt|title)]$/); Chris@17: if (matches) { Chris@17: const name = matches[1]; Chris@17: const $toolgroup = $( Chris@17: `#${options.fieldModel.toolbarView.getMainWysiwygToolgroupId()}`, Chris@17: ); Chris@17: const $input = $toolgroup.find( Chris@17: `.quickedit-image-field-info input[name="${name}"]`, Chris@17: ); Chris@17: if ($input.length) { Chris@17: return $input.val(); Chris@17: } Chris@17: } Chris@17: }); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * @inheritdoc Chris@17: * Chris@17: * @param {Drupal.quickedit.FieldModel} fieldModel Chris@17: * The field model that holds the state. Chris@17: * @param {string} state Chris@17: * The state to change to. Chris@17: * @param {object} options Chris@17: * State options, if needed by the state change. Chris@17: */ Chris@17: stateChange(fieldModel, state, options) { Chris@17: const from = fieldModel.previous('state'); Chris@17: switch (state) { Chris@17: case 'inactive': Chris@17: break; Chris@17: Chris@17: case 'candidate': Chris@17: if (from !== 'inactive') { Chris@17: this.$el.find('.quickedit-image-dropzone').remove(); Chris@17: this.$el.removeClass('quickedit-image-element'); Chris@17: } Chris@17: if (from === 'invalid') { Chris@17: this.removeValidationErrors(); Chris@17: } Chris@17: break; Chris@17: Chris@17: case 'highlighted': Chris@17: break; Chris@17: Chris@17: case 'activating': Chris@17: // Defer updating the field model until the current state change has Chris@17: // propagated, to not trigger a nested state change event. Chris@17: _.defer(() => { Chris@17: fieldModel.set('state', 'active'); Chris@17: }); Chris@17: break; Chris@17: Chris@17: case 'active': { Chris@17: const self = this; Chris@17: Chris@17: // Indicate that this element is being edited by Quick Edit Image. Chris@17: this.$el.addClass('quickedit-image-element'); Chris@17: Chris@17: // Render our initial dropzone element. Once the user reverts changes Chris@17: // or saves a new image, this element is removed. Chris@17: const $dropzone = this.renderDropzone( Chris@17: 'upload', Chris@17: Drupal.t('Drop file here or click to upload'), Chris@17: ); Chris@17: Chris@17: $dropzone.on('dragenter', function(e) { Chris@17: $(this).addClass('hover'); Chris@17: }); Chris@17: $dropzone.on('dragleave', function(e) { Chris@17: $(this).removeClass('hover'); Chris@17: }); Chris@17: Chris@17: $dropzone.on('drop', function(e) { Chris@17: // Only respond when a file is dropped (could be another element). Chris@17: if ( Chris@17: e.originalEvent.dataTransfer && Chris@17: e.originalEvent.dataTransfer.files.length Chris@17: ) { Chris@17: $(this).removeClass('hover'); Chris@17: self.uploadImage(e.originalEvent.dataTransfer.files[0]); Chris@17: } Chris@17: }); Chris@17: Chris@17: $dropzone.on('click', e => { Chris@17: // Create an element without appending it to the DOM, and Chris@17: // trigger a click event. This is the easiest way to arbitrarily Chris@17: // open the browser's upload dialog. Chris@17: $('') Chris@17: .trigger('click') Chris@17: .on('change', function() { Chris@17: if (this.files.length) { Chris@17: self.uploadImage(this.files[0]); Chris@17: } Chris@17: }); Chris@17: }); Chris@17: Chris@17: // Prevent the browser's default behavior when dragging files onto Chris@17: // the document (usually opens them in the same tab). Chris@17: $dropzone.on('dragover dragenter dragleave drop click', e => { Chris@17: e.preventDefault(); Chris@17: e.stopPropagation(); Chris@17: }); Chris@17: Chris@17: this.renderToolbar(fieldModel); Chris@17: break; Chris@0: } Chris@17: Chris@17: case 'changed': Chris@17: break; Chris@17: Chris@17: case 'saving': Chris@17: if (from === 'invalid') { Chris@17: this.removeValidationErrors(); Chris@17: } Chris@17: Chris@17: this.save(options); Chris@17: break; Chris@17: Chris@17: case 'saved': Chris@17: break; Chris@17: Chris@17: case 'invalid': Chris@17: this.showValidationErrors(); Chris@17: break; Chris@0: } Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Validates/uploads a given file. Chris@17: * Chris@17: * @param {File} file Chris@17: * The file to upload. Chris@17: */ Chris@17: uploadImage(file) { Chris@17: // Indicate loading by adding a special class to our icon. Chris@17: this.renderDropzone( Chris@17: 'upload loading', Chris@17: Drupal.t('Uploading @file…', { '@file': file.name }), Chris@17: ); Chris@0: Chris@17: // Build a valid URL for our endpoint. Chris@17: const fieldID = this.fieldModel.get('fieldID'); Chris@17: const url = Drupal.quickedit.util.buildUrl( Chris@17: fieldID, Chris@17: Drupal.url( Chris@17: 'quickedit/image/upload/!entity_type/!id/!field_name/!langcode/!view_mode', Chris@17: ), Chris@17: ); Chris@17: Chris@17: // Construct form data that our endpoint can consume. Chris@17: const data = new FormData(); Chris@17: data.append('files[image]', file); Chris@17: Chris@17: // Construct a POST request to our endpoint. Chris@17: const self = this; Chris@17: this.ajax({ Chris@17: type: 'POST', Chris@17: url, Chris@17: data, Chris@17: success(response) { Chris@17: const $el = $(self.fieldModel.get('el')); Chris@17: // Indicate that the field has changed - this enables the Chris@17: // "Save" button. Chris@17: self.fieldModel.set('state', 'changed'); Chris@17: self.fieldModel.get('entity').set('inTempStore', true); Chris@17: self.removeValidationErrors(); Chris@17: Chris@17: // Replace our html with the new image. If we replaced our entire Chris@17: // element with data.html, we would have to implement complicated logic Chris@17: // like what's in Drupal.quickedit.AppView.renderUpdatedField. Chris@17: const $content = $(response.html) Chris@17: .closest('[data-quickedit-field-id]') Chris@17: .children(); Chris@17: $el.empty().append($content); Chris@17: }, Chris@17: }); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Utility function to make an AJAX request to the server. Chris@17: * Chris@17: * In addition to formatting the correct request, this also handles error Chris@17: * codes and messages by displaying them visually inline with the image. Chris@17: * Chris@17: * Drupal.ajax is not called here as the Form API is unused by this Chris@17: * in-place editor, and our JSON requests/responses try to be Chris@17: * editor-agnostic. Ideally similar logic and routes could be used by Chris@17: * modules like CKEditor for drag+drop file uploads as well. Chris@17: * Chris@17: * @param {object} options Chris@17: * Ajax options. Chris@17: * @param {string} options.type Chris@17: * The type of request (i.e. GET, POST, PUT, DELETE, etc.) Chris@17: * @param {string} options.url Chris@17: * The URL for the request. Chris@17: * @param {*} options.data Chris@17: * The data to send to the server. Chris@17: * @param {function} options.success Chris@17: * A callback function used when a request is successful, without errors. Chris@17: */ Chris@17: ajax(options) { Chris@17: const defaultOptions = { Chris@17: context: this, Chris@17: dataType: 'json', Chris@17: cache: false, Chris@17: contentType: false, Chris@17: processData: false, Chris@17: error() { Chris@17: this.renderDropzone( Chris@17: 'error', Chris@17: Drupal.t('A server error has occurred.'), Chris@17: ); Chris@17: }, Chris@17: }; Chris@17: Chris@17: const ajaxOptions = $.extend(defaultOptions, options); Chris@17: const successCallback = ajaxOptions.success; Chris@17: Chris@17: // Handle the success callback. Chris@17: ajaxOptions.success = function(response) { Chris@17: if (response.main_error) { Chris@17: this.renderDropzone('error', response.main_error); Chris@17: if (response.errors.length) { Chris@17: this.model.set('validationErrors', response.errors); Chris@17: } Chris@17: this.showValidationErrors(); Chris@17: } else { Chris@17: successCallback(response); Chris@0: } Chris@17: }; Chris@0: Chris@17: $.ajax(ajaxOptions); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Renders our toolbar form for editing metadata. Chris@17: * Chris@17: * @param {Drupal.quickedit.FieldModel} fieldModel Chris@17: * The current Field Model. Chris@17: */ Chris@17: renderToolbar(fieldModel) { Chris@17: const $toolgroup = $( Chris@17: `#${fieldModel.toolbarView.getMainWysiwygToolgroupId()}`, Chris@17: ); Chris@17: let $toolbar = $toolgroup.find('.quickedit-image-field-info'); Chris@17: if ($toolbar.length === 0) { Chris@17: // Perform an AJAX request for extra image info (alt/title). Chris@17: const fieldID = fieldModel.get('fieldID'); Chris@17: const url = Drupal.quickedit.util.buildUrl( Chris@17: fieldID, Chris@17: Drupal.url( Chris@17: 'quickedit/image/info/!entity_type/!id/!field_name/!langcode/!view_mode', Chris@17: ), Chris@17: ); Chris@17: const self = this; Chris@17: self.ajax({ Chris@17: type: 'GET', Chris@17: url, Chris@17: success(response) { Chris@17: $toolbar = $(Drupal.theme.quickeditImageToolbar(response)); Chris@17: $toolgroup.append($toolbar); Chris@17: $toolbar.on('keyup paste', () => { Chris@17: fieldModel.set('state', 'changed'); Chris@17: }); Chris@17: // Re-position the toolbar, which could have changed size. Chris@17: fieldModel.get('entity').toolbarView.position(); Chris@17: }, Chris@0: }); Chris@17: } Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Renders our dropzone element. Chris@17: * Chris@17: * @param {string} state Chris@17: * The current state of our editor. Only used for visual styling. Chris@17: * @param {string} text Chris@17: * The text to display in the dropzone area. Chris@17: * Chris@17: * @return {jQuery} Chris@17: * The rendered dropzone. Chris@17: */ Chris@17: renderDropzone(state, text) { Chris@17: let $dropzone = this.$el.find('.quickedit-image-dropzone'); Chris@17: // If the element already exists, modify its contents. Chris@17: if ($dropzone.length) { Chris@17: $dropzone Chris@17: .removeClass('upload error hover loading') Chris@17: .addClass(`.quickedit-image-dropzone ${state}`) Chris@17: .children('.quickedit-image-text') Chris@17: .html(text); Chris@17: } else { Chris@17: $dropzone = $( Chris@17: Drupal.theme('quickeditImageDropzone', { Chris@17: state, Chris@17: text, Chris@17: }), Chris@17: ); Chris@17: this.$el.append($dropzone); Chris@14: } Chris@0: Chris@17: return $dropzone; Chris@17: }, Chris@0: Chris@17: /** Chris@17: * @inheritdoc Chris@17: */ Chris@17: revert() { Chris@17: this.$el.html(this.model.get('originalValue')); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * @inheritdoc Chris@17: */ Chris@17: getQuickEditUISettings() { Chris@17: return { Chris@17: padding: false, Chris@17: unifiedToolbar: true, Chris@17: fullWidthToolbar: true, Chris@17: popup: false, Chris@17: }; Chris@17: }, Chris@0: Chris@17: /** Chris@17: * @inheritdoc Chris@17: */ Chris@17: showValidationErrors() { Chris@17: const errors = Drupal.theme('quickeditImageErrors', { Chris@17: errors: this.model.get('validationErrors'), Chris@17: }); Chris@17: $(`#${this.fieldModel.toolbarView.getMainWysiwygToolgroupId()}`).append( Chris@17: errors, Chris@17: ); Chris@17: this.getEditedElement().addClass('quickedit-validation-error'); Chris@17: // Re-position the toolbar, which could have changed size. Chris@17: this.fieldModel.get('entity').toolbarView.position(); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * @inheritdoc Chris@17: */ Chris@17: removeValidationErrors() { Chris@17: $(`#${this.fieldModel.toolbarView.getMainWysiwygToolgroupId()}`) Chris@17: .find('.quickedit-image-errors') Chris@17: .remove(); Chris@17: this.getEditedElement().removeClass('quickedit-validation-error'); Chris@17: }, Chris@0: }, Chris@17: ); Chris@17: })(jQuery, _, Drupal);