Commit 24eb0704 authored by webchick's avatar webchick
Browse files

Issue #2421427 by samuel.mortenson, droplet, dawehner, nod_, Cottser, Wim...

Issue #2421427 by samuel.mortenson, droplet, dawehner, nod_, Cottser, Wim Leers, xjm, Gábor Hojtsy, Bojhan, tstoeckler, webchick, naveenvalecha, alexpott, LewisNyman, chris_h, Manjit.Singh, phenaproxima, avitslv, yoroy, tim.plunkett, Mixologic, ipwa, slashrsm: Improve the UX of Quick Editing single-valued image fields
parent 4f8869ca
/**
* @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;
}
/**
* @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;
}
......@@ -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.
}
......@@ -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
......@@ -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'
<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>
<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>
/**
* @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);
/**
* @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.
*/