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