Commit 03b6a410 authored by webchick's avatar webchick

Issue #1886566 by Wim Leers, sun, effulgentsia, quicksketch: Make WYSIWYG...

Issue #1886566 by Wim Leers, sun, effulgentsia, quicksketch: Make WYSIWYG editors available for in-place editing.
parent d97b8dac
(function (Drupal, CKEDITOR) {
(function (Drupal, CKEDITOR, $) {
"use strict";
Drupal.editors.ckeditor = {
attach: function (element, format) {
var externalPlugins = format.editorSettings.externalPlugins;
// Register and load additional CKEditor plugins as necessary.
if (externalPlugins) {
for (var pluginName in externalPlugins) {
if (externalPlugins.hasOwnProperty(pluginName)) {
CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], '');
}
}
delete format.editorSettings.drupalExternalPlugins;
}
this._loadExternalPlugins(format);
return !!CKEDITOR.replace(element, format.editorSettings);
},
......@@ -26,11 +17,69 @@ Drupal.editors.ckeditor = {
}
else {
editor.destroy();
element.removeAttribute('contentEditable');
}
}
return !!editor;
},
onChange: function (element, callback) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
var changed = function () {
callback(editor.getData());
};
// @todo Make this more elegant once http://dev.ckeditor.com/ticket/9794
// is fixed.
editor.on('key', changed);
editor.on('paste', changed);
editor.on('afterCommandExec', changed);
}
return !!editor;
},
attachInlineEditor: function (element, format, mainToolbarId, floatedToolbarId) {
this._loadExternalPlugins(format);
var settings = $.extend(true, {}, format.editorSettings);
// If a toolbar is already provided for "true WYSIWYG" (in-place editing),
// then use that toolbar instead: override the default settings to render
// CKEditor UI's top toolbar into mainToolbar, and don't render the bottom
// toolbar at all. (CKEditor doesn't need a floated toolbar.)
if (mainToolbarId) {
var settingsOverride = {
extraPlugins: 'sharedspace',
removePlugins: 'floatingspace,elementspath',
sharedSpaces: {
top: mainToolbarId
}
};
settings.extraPlugins += ',' + settingsOverride.extraPlugins;
settings.removePlugins += ',' + settingsOverride.removePlugins;
settings.sharedSpaces = settingsOverride.sharedSpaces;
}
// CKEditor requires an element to already have the contentEditable
// attribute set to "true", otherwise it won't attach an inline editor.
element.setAttribute('contentEditable', 'true');
return !!CKEDITOR.inline(element, settings);
},
_loadExternalPlugins: function(format) {
var externalPlugins = format.editorSettings.drupalExternalPlugins;
// Register and load additional CKEditor plugins as necessary.
if (externalPlugins) {
for (var pluginName in externalPlugins) {
if (externalPlugins.hasOwnProperty(pluginName)) {
CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], '');
}
}
delete format.editorSettings.drupalExternalPlugins;
}
}
};
})(Drupal, CKEDITOR);
})(Drupal, CKEDITOR, jQuery);
......@@ -18,7 +18,8 @@
* @Plugin(
* id = "ckeditor",
* label = @Translation("CKEditor"),
* module = "ckeditor"
* module = "ckeditor",
* supports_inline_editing = TRUE
* )
*/
class CKEditor extends EditorBase {
......
......@@ -97,7 +97,6 @@ function edit_library_info() {
'data' => array('edit' => array(
'metadataURL' => url('edit/metadata'),
'fieldFormURL' => url('edit/form/!entity_type/!id/!field_name/!langcode/!view_mode'),
'rerenderProcessedTextURL' => url('edit/text/!entity_type/!id/!field_name/!langcode/!view_mode'),
'context' => 'body',
)),
'type' => 'setting',
......@@ -118,7 +117,7 @@ function edit_library_info() {
array('system', 'drupalSettings'),
),
);
$libraries['edit.editor.form'] = array(
$libraries['edit.editorWidget.form'] = array(
'title' => '"Form" Create.js PropertyEditor widget',
'version' => VERSION,
'js' => array(
......@@ -128,7 +127,7 @@ function edit_library_info() {
array('edit', 'edit'),
),
);
$libraries['edit.editor.direct'] = array(
$libraries['edit.editorWidget.direct'] = array(
'title' => '"Direct" Create.js PropertyEditor widget',
'version' => VERSION,
'js' => array(
......
......@@ -12,11 +12,3 @@ edit_field_form:
requirements:
_permission: 'access in-place editing'
_access_edit_entity_field: 'TRUE'
edit_text:
pattern: '/edit/text/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}'
defaults:
_controller: '\Drupal\edit\EditController::getUntransformedText'
requirements:
_permission: 'access in-place editing'
_access_edit_entity_field: 'TRUE'
......@@ -125,8 +125,7 @@ Backbone.syncDirect = function(method, model, options) {
// Successfully saved.
Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) {
Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element));
jQuery('#edit_backstage form').remove();
Backbone.syncDirectCleanUp();
// Call Backbone.sync's success callback with the rerendered field.
var changedAttributes = {};
......@@ -163,4 +162,23 @@ Backbone.syncDirect = function(method, model, options) {
}
};
/**
* Cleans up the hidden form that Backbone.syncDirect uses for syncing.
*
* This is called automatically by Backbone.syncDirect when saving is successful
* (i.e. when there are no validation errors). Only when editing is canceled
* while a PropertyEditor widget is in the invalid state, this must be called
* "manually" (in practice, ToolbarView does this). This is necessary because
* Backbone.syncDirect is not aware of the application state, it only does the
* syncing.
* An alternative could be to also remove the hidden form when validation errors
* occur, but then the form must be retrieved again, thus resulting in another
* roundtrip, which is bad for front-end performance.
*/
Backbone.syncDirectCleanUp = function() {
var $submit = jQuery('#edit_backstage form .edit-form-submit');
Drupal.edit.util.form.unajaxifySaving($submit);
jQuery('#edit_backstage form').remove();
};
})(jQuery, Backbone, Drupal);
......@@ -6,7 +6,9 @@
"use strict";
jQuery.widget('Drupal.drupalContentEditableWidget', jQuery.Create.editWidget, {
// @todo D8: use jQuery UI Widget bridging.
// @see http://drupal.org/node/1874934#comment-7124904
jQuery.widget('DrupalEditEditor.direct', jQuery.Create.editWidget, {
/**
* Implements getEditUISettings() method.
......@@ -54,8 +56,6 @@
if (from !== 'inactive') {
// Removes the "contenteditable" attribute.
this.disable();
this._removeValidationErrors();
this._cleanUp();
}
break;
case 'highlighted':
......@@ -70,42 +70,14 @@
case 'changed':
break;
case 'saving':
this._removeValidationErrors();
break;
case 'saved':
break;
case 'invalid':
break;
}
},
/**
* Removes validation errors' markup changes, if any.
*
* Note: this only needs to happen for type=direct, because for type=direct,
* the property DOM element itself is modified; this is not the case for
* type=form.
*/
_removeValidationErrors: function() {
this.element
.removeClass('edit-validation-error')
.next('.edit-validation-errors').remove();
},
/**
* Cleans up after the widget has been saved.
*
* Note: this is where the Create.Storage and accompanying Backbone.sync
* abstractions "leak" implementation details. That is only the case because
* we have to use Drupal's Form API as a transport mechanism. It is
* unfortunately a stateful transport mechanism, and that's why we have to
* clean it up here. This clean-up is only necessary when canceling the
* editing of a property after having attempted to save at least once.
*/
_cleanUp: function() {
Drupal.edit.util.form.unajaxifySaving(jQuery('#edit_backstage form .edit-form-submit'));
jQuery('#edit_backstage form').remove();
}
});
})(jQuery, Drupal);
......@@ -6,7 +6,9 @@
"use strict";
$.widget('Drupal.drupalFormWidget', $.Create.editWidget, {
// @todo D8: change the name to "form" + use jQuery UI Widget bridging.
// @see http://drupal.org/node/1874934#comment-7124904
$.widget('DrupalEditEditor.formEditEditor', $.Create.editWidget, {
id: null,
$formContainer: null,
......
......@@ -54,41 +54,6 @@ Drupal.edit.util.buildUrl = function(id, urlFormat) {
});
};
/**
* Loads rerendered processed text for a given property.
*
* Leverages Drupal.ajax' ability to have scoped (per-instance) command
* implementations to be able to call a callback.
*
* @param options
* An object with the following keys:
* - $editorElement (required): the PredicateEditor DOM element.
* - propertyID (required): the property ID that uniquely identifies the
* property for which this form will be loaded.
* - callback (required: A callback function that will receive the rerendered
* processed text.
*/
Drupal.edit.util.loadRerenderedProcessedText = function(options) {
// Create a Drupal.ajax instance to load the form.
Drupal.ajax[options.propertyID] = new Drupal.ajax(options.propertyID, options.$editorElement, {
url: Drupal.edit.util.buildUrl(options.propertyID, drupalSettings.edit.rerenderProcessedTextURL),
event: 'edit-internal.edit',
submit: { nocssjs : true },
progress: { type : null } // No progress indicator.
});
// Implement a scoped editFieldRenderedWithoutTransformationFilters AJAX
// command: calls the callback.
Drupal.ajax[options.propertyID].commands.editFieldRenderedWithoutTransformationFilters = function(ajax, response, status) {
options.callback(response.data);
// Delete the Drupal.ajax instance that called this very function.
delete Drupal.ajax[options.propertyID];
options.$editorElement.off('edit-internal.edit');
};
// This will ensure our scoped editFieldRenderedWithoutTransformationFilters
// AJAX command gets called.
options.$editorElement.trigger('edit-internal.edit');
};
Drupal.edit.util.form = {
/**
* Loads a form, calls a callback to inserts.
......
......@@ -32,7 +32,8 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
* - editor: the editor object with an 'options' object that has these keys:
* * entity: the VIE entity for the property.
* * property: the predicate of the property.
* * widget: the parent EditableeEntity widget.
* * widget: the parent EditableEntity widget.
* * editorName: the name of the PropertyEditor widget
* - toolbarId: the ID attribute of the toolbar as rendered in the DOM.
*/
initialize: function(options) {
......@@ -40,6 +41,7 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
this.toolbarId = options.toolbarId;
this.predicate = this.editor.options.property;
this.editorName = this.editor.options.editorName;
// Only start listening to events as soon as we're no longer in the 'inactive' state.
this.undelegateEvents();
......@@ -53,6 +55,9 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
case 'inactive':
if (from !== null) {
this.undecorate();
if (from === 'invalid') {
this._removeValidationErrors();
}
}
break;
case 'candidate':
......@@ -61,6 +66,9 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
this.stopHighlight();
if (from !== 'highlighted') {
this.stopEdit();
if (from === 'invalid') {
this._removeValidationErrors();
}
}
}
break;
......@@ -81,6 +89,9 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
case 'changed':
break;
case 'saving':
if (from === 'invalid') {
this._removeValidationErrors();
}
break;
case 'saved':
break;
......@@ -305,7 +316,24 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({
else {
callback();
}
},
/**
* Removes validation errors' markup changes, if any.
*
* Note: this only needs to happen for type=direct, because for type=direct,
* the property DOM element itself is modified; this is not the case for
* type=form.
*/
_removeValidationErrors: function() {
if (this.editorName !== 'form') {
this.$el
.removeClass('edit-validation-error')
.next('.edit-validation-errors')
.remove();
}
}
});
})(jQuery, Backbone, Drupal);
......@@ -68,6 +68,9 @@ Drupal.edit.views.ToolbarView = Backbone.View.extend({
case 'inactive':
if (from) {
this.remove();
if (this.editorName !== 'form') {
Backbone.syncDirectCleanUp();
}
}
break;
case 'candidate':
......@@ -75,6 +78,9 @@ Drupal.edit.views.ToolbarView = Backbone.View.extend({
this.render();
}
else {
if (this.editorName !== 'form') {
Backbone.syncDirectCleanUp();
}
// Remove all toolgroups; they're no longer necessary.
this.$el
.removeClass('edit-highlighted edit-editing')
......
......@@ -16,7 +16,6 @@
use Drupal\edit\Ajax\FieldFormCommand;
use Drupal\edit\Ajax\FieldFormSavedCommand;
use Drupal\edit\Ajax\FieldFormValidationErrorsCommand;
use Drupal\edit\Ajax\FieldRenderedWithoutTransformationFiltersCommand;
/**
* Returns responses for Edit module routes.
......@@ -117,31 +116,4 @@ public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view
return $response;
}
/**
* Returns an Ajax response to render a text field without transformation filters.
*
* @param int $entity
* The entity of which a processed text field is being rerendered.
* @param string $field_name
* The name of the (processed text) field that that is being rerendered
* @param string $langcode
* The name of the language for which the processed text field is being
* rererendered.
* @param string $view_mode
* The view mode the processed text field should be rerendered in.
* @return \Drupal\Core\Ajax\AjaxResponse
* The Ajax response.
*/
public function getUntransformedText(EntityInterface $entity, $field_name, $langcode, $view_mode) {
$response = new AjaxResponse();
$output = field_view_field($entity, $field_name, $view_mode, $langcode);
$langcode = $output['#language'];
// Direct text editing is only supported for single-valued fields.
$editable_text = check_markup($output['#items'][0]['value'], $output['#items'][0]['format'], $langcode, FALSE, array(FILTER_TYPE_TRANSFORM_REVERSIBLE, FILTER_TYPE_TRANSFORM_IRREVERSIBLE));
$response->addCommand(new FieldRenderedWithoutTransformationFiltersCommand($editable_text));
return $response;
}
}
......@@ -16,7 +16,7 @@
*
* @Plugin(
* id = "direct",
* jsClassName = "drupalContentEditableWidget",
* jsClassName = "direct",
* module = "edit"
* )
*/
......@@ -50,7 +50,7 @@ function isCompatible(FieldInstance $instance, array $items) {
public function getAttachments() {
return array(
'library' => array(
array('edit', 'edit.editor.direct'),
array('edit', 'edit.editorWidget.direct'),
),
);
}
......
......@@ -16,7 +16,7 @@
*
* @Plugin(
* id = "form",
* jsClassName = "drupalFormWidget",
* jsClassName = "formEditEditor",
* module = "edit"
* )
*/
......@@ -35,7 +35,7 @@ function isCompatible(FieldInstance $instance, array $items) {
public function getAttachments() {
return array(
'library' => array(
array('edit', 'edit.editor.form'),
array('edit', 'edit.editorWidget.form'),
),
);
}
......
......@@ -29,7 +29,7 @@ function setUp() {
$this->installSchema('system', 'variable');
$this->installSchema('field', array('field_config', 'field_config_instance'));
$this->installSchema('entity_test', 'entity_test');
$this->installSchema('entity_test', array('entity_test', 'entity_test_rev'));
// Set default storage backend.
variable_set('field_storage_default', $this->default_storage);
......
......@@ -56,8 +56,6 @@ public static function getInfo() {
function setUp() {
parent::setUp();
$this->installSchema('field_test', 'test_entity_revision');
$this->editorManager = new EditorManager($this->container->getParameter('container.namespaces'));
$this->accessChecker = new MockEditEntityFieldAccessCheck();
$this->editorSelector = new EditorSelector($this->editorManager);
......
......@@ -78,10 +78,47 @@ function editor_library_info() {
array('system', 'jquery.once'),
),
);
// Create.js PropertyEditor widget library names begin with "edit.editor".
$libraries['edit.editorWidget.editor'] = array(
'title' => '"Editor" Create.js PropertyEditor widget',
'version' => VERSION,
'js' => array(
$path . '/js/editor.createjs.js' => array(
'scope' => 'footer',
'attributes' => array('defer' => TRUE),
),
array(
'type' => 'setting',
'data' => array(
'editor' => array(
'getUntransformedTextURL' => url('editor/!entity_type/!id/!field_name/!langcode/!view_mode'),
)
)
),
),
'dependencies' => array(
array('edit', 'edit'),
array('editor', 'drupal.editor'),
array('system', 'drupal.ajax'),
array('system', 'drupalSettings'),
),
);
return $libraries;
}
/**
* Implements hook_custom_theme().
*
* @todo Add an event subscriber to the Ajax system to automatically set the
* base page theme for all Ajax requests, and then remove this one off.
*/
function editor_custom_theme() {
if (substr(current_path(), 0, 7) === 'editor/') {
return ajax_base_page_theme();
}
}
/**
* Implements hook_form_FORM_ID_alter().
*/
......
editor_field_untransformed_text:
pattern: '/editor/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}'
defaults:
_controller: '\Drupal\editor\EditorController::getUntransformedText'
requirements:
_permission: 'access in-place editing'
_access_edit_entity_field: 'TRUE'
/**
* @file
* Text editor-based Create.js widget for processed text content in Drupal.
*
* Depends on editor.module. Works with any (WYSIWYG) editor that implements the
* editor.js API, including the optional attachInlineEditor() and onChange()
* methods.
* For example, assuming that a hypothetical editor's name was "Magical Editor"
* and its editor.js API implementation lived at Drupal.editors.magical, this
* JavaScript would use:
* - Drupal.editors.magical.attachInlineEditor()
* - Drupal.editors.magical.onChange()
* - Drupal.editors.magical.detach()
*/
(function (jQuery, Drupal, drupalSettings) {
"use strict";
// @todo D8: use jQuery UI Widget bridging.
// @see http://drupal.org/node/1874934#comment-7124904
jQuery.widget('DrupalEditEditor.editor', jQuery.DrupalEditEditor.direct, {
textFormat: null,
textFormatHasTransformations: null,
textEditor: null,
/**
* Implements Create.editWidget.getEditUISettings.
*/
getEditUISettings: function () {
return { padding: true, unifiedToolbar: true, fullWidthToolbar: true };
},
/**
* Implements jQuery.widget._init.
*
* @todo D8: Remove this.
* @see http://drupal.org/node/1874934
*/
_init: function () {},
/**
* Implements Create.editWidget._initialize.
*/
_initialize: function () {
var propertyID = Drupal.edit.util.calcPropertyID(this.options.entity, this.options.property);
var metadata = Drupal.edit.metadataCache[propertyID].custom;
this.textFormat = drupalSettings.editor.formats[metadata.format];
this.textFormatHasTransformations = metadata.formatHasTransformations;
this.textEditor = Drupal.editors[this.textFormat.editor];
},
/**
* Implements Create.editWidget.stateChange.
*/
stateChange: function (from, to) {
var that = this;
switch (to) {
case 'inactive':
break;