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
4f8869ca9f
commit
24eb0704ad
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -61,3 +61,10 @@ function image_requirements($phase) {
|
||||||
|
|
||||||
return $requirements;
|
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:
|
css:
|
||||||
theme:
|
theme:
|
||||||
css/image.admin.css: {}
|
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:
|
route_callbacks:
|
||||||
- '\Drupal\image\Routing\ImageStyleRoutes::routes'
|
- '\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'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 261 B |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 202 B |
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,9 @@ use Drupal\Core\Cache\Cache;
|
||||||
* label = @Translation("Image"),
|
* label = @Translation("Image"),
|
||||||
* field_types = {
|
* field_types = {
|
||||||
* "image"
|
* "image"
|
||||||
|
* },
|
||||||
|
* quickedit = {
|
||||||
|
* "editor" = "image"
|
||||||
* }
|
* }
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ use Drupal\Core\Utility\LinkGeneratorInterface;
|
||||||
* label = @Translation("Responsive image"),
|
* label = @Translation("Responsive image"),
|
||||||
* field_types = {
|
* field_types = {
|
||||||
* "image",
|
* "image",
|
||||||
|
* },
|
||||||
|
* quickedit = {
|
||||||
|
* "editor" = "image"
|
||||||
* }
|
* }
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 261 B |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 202 B |
|
|
@ -98,6 +98,12 @@ libraries-override:
|
||||||
css:
|
css:
|
||||||
theme:
|
theme:
|
||||||
css/image.admin.css: css/image/image.admin.css
|
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:
|
language/drupal.language.admin:
|
||||||
css:
|
css:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue