core/modules/editor/editor.js | 80 --------- core/modules/editor/editor.module | 18 +- .../editor/js/createjs/drupalwysiwygwidget.js | 158 +++++++++++++++++ core/modules/editor/js/editor.js | 80 +++++++++ .../Drupal/editor/Plugin/edit/editor/Editor.php | 97 +++++++++++ .../Drupal/editor/Tests/EditIntegrationTest.php | 178 ++++++++++++++++++++ core/modules/editor/tests/modules/editor_test.info | 6 + .../editor/tests/modules/editor_test.module | 6 + .../editor_test/Plugin/editor/editor/Unicorn.php | 58 +++++++ 9 files changed, 600 insertions(+), 81 deletions(-) diff --git a/core/modules/editor/editor.js b/core/modules/editor/editor.js deleted file mode 100644 index 3759284..0000000 --- a/core/modules/editor/editor.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file - * Attaches behavior for the Editor module. - */ - -(function ($, Drupal, drupalSettings) { - -"use strict"; - -/** - * Initialize an empty object where editors where place their attachment code. - */ -Drupal.editors = {}; - -/** - * Enables editors on text_format elements. - */ -Drupal.behaviors.editors = { - attach: function (context, settings) { - // If there are no editor settings, there are no editors to enable. - if (!settings.editor) { - return; - } - - var $context = $(context); - $context.find('.filter-list:input').once('editors', function () { - var $this = $(this); - var activeEditor = $this.val(); - var field = $this.closest('.text-format-wrapper').find('textarea').get(-1); - - // Directly attach this editor, if the text format is enabled or there is - // only one text format at all. - if ($this.is(':input')) { - if (drupalSettings.editor.formats[activeEditor]) { - Drupal.editorAttach(field, drupalSettings.editor.formats[activeEditor]); - } - } - // Attach onChange handlers to text format selector elements. - if ($this.is('select')) { - $this.on('change.editorAttach', function () { - // Prevent double-binding if the change event is triggered manually. - if ($this.val() === activeEditor) { - return; - } - - // Detach the current editor (if any) and attach a new editor. - if (drupalSettings.editor.formats[activeEditor]) { - Drupal.editorDetach(field, drupalSettings.editor.formats[activeEditor]); - } - activeEditor = $this.val(); - if (drupalSettings.editor.formats[activeEditor]) { - Drupal.editorAttach(field, drupalSettings.editor.formats[activeEditor]); - } - }); - } - // Detach any editor when the containing form is submitted. - $this.parents('form').submit(function (event) { - // Do not detach if the event was canceled. - if (event.isDefaultPrevented()) { - return; - } - Drupal.editorDetach(field, drupalSettings.editor.formats[activeEditor]); - }); - }); - } -}; - -Drupal.editorAttach = function (field, format) { - if (format.editor) { - Drupal.editors[format.editor].attach(field, format); - } -}; - -Drupal.editorDetach = function (field, format) { - if (format.editor) { - Drupal.editors[format.editor].detach(field, format); - } -}; - -})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/editor/editor.module b/core/modules/editor/editor.module index 4d817b3..b2fee9d 100644 --- a/core/modules/editor/editor.module +++ b/core/modules/editor/editor.module @@ -39,11 +39,12 @@ function editor_element_info() { * Implements hook_library_info(). */ function editor_library_info() { + $path = drupal_get_path('module', 'editor'); $libraries['drupal.editor'] = array( 'title' => 'Editor', 'version' => VERSION, 'js' => array( - drupal_get_path('module', 'editor') . '/editor.js' => array('group' => JS_LIBRARY), + $path . '/js/editor.js' => array('group' => JS_LIBRARY), ), 'dependencies' => array( array('system', 'jquery'), @@ -52,6 +53,20 @@ function editor_library_info() { array('system', 'jquery.once'), ), ); + $libraries['edit.editor.editor'] = array( + 'title' => '"Editor" Create.js PropertyEditor widget', + 'version' => VERSION, + 'js' => array( + $path . '/js/createjs/drupalwysiwygwidget.js' => array( + 'scope' => 'footer', + 'attributes' => array('defer' => TRUE), + ) + ), + 'dependencies' => array( + array('edit', 'edit'), + array('editor', 'drupal.editor'), + ), +); return $libraries; } @@ -217,6 +232,7 @@ function editor_get_attachments($formats) { $manager = drupal_container()->get('plugin.manager.editor'); $definitions = $manager->getDefinitions(); + $settings = array(); foreach ($formats as $format_id => $format_info) { $editor = editor_load($format_id); if (!$editor) { diff --git a/core/modules/editor/js/createjs/drupalwysiwygwidget.js b/core/modules/editor/js/createjs/drupalwysiwygwidget.js new file mode 100644 index 0000000..36409ec --- /dev/null +++ b/core/modules/editor/js/createjs/drupalwysiwygwidget.js @@ -0,0 +1,158 @@ +/** + * @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 + * attachTrueWysiwyg(), detach() and onChange() methods. + */ +(function (jQuery, Drupal, drupalSettings) { + +"use strict"; + + jQuery.widget('Drupal.drupalWysiwygWidget', jQuery.Create.editWidget, { + + textFormat: null, + textFormatHasTransformations: null, + textEditor: null, + + /** + * Implements getEditUiIntegration() method. + */ + getEditUiIntegration: function() { + return { padding: true, unifiedToolbar: true, fullWidthToolbar: true }; + }, + + /** + * Implements jQuery UI widget factory's _init() method. + * + * @todo: POSTPONED_ON(Create.js, https://github.com/bergie/create/issues/142) + * Get rid of this once that issue is solved. + */ + _init: function() {}, + + /** + * Implements Create's _initialize() method. + */ + _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]; + + this._bindEvents(); + }, + + /** + * Binds to events. + */ + _bindEvents: function() { + var that = this; + + // Sets the state to 'activated' upon clicking the element. + this.element.on('click.edit', function(event) { + event.stopPropagation(); + event.preventDefault(); + that.options.activating(); + }); + }, + + /** + * Makes this PropertyEditor widget react to state changes. + */ + stateChange: function(from, to) { + var that = this; + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + if (from !== 'highlighted') { + this.element.attr('contentEditable', 'false'); + this.textEditor.detach(this.element.get(0), this.textFormat); + } + + this._removeValidationErrors(); + this._cleanUp(); + this._bindEvents(); + } + break; + case 'highlighted': + break; + case 'activating': + // When transformation filters have been been applied to the processed + // text of this field, then we'll need to load a re-rendered version of + // it without the transformation filters. + if (this.textFormatHasTransformations) { + Drupal.edit.util.loadRerenderedProcessedText({ + $editorElement: this.element, + propertyID: Drupal.edit.util.calcPropertyID(this.options.entity, this.options.property), + callback: function (rerendered) { + that.element.html(rerendered); + that.options.activated(); + } + }); + } + // When no transformation filters have been applied: start WYSIWYG + // editing immediately! + else { + this.options.activated(); + } + break; + case 'active': + this.element.attr('contentEditable', 'true'); + this.textEditor.attachTrueWysiwyg( + this.element.get(0), + this.textFormat, + this.toolbarView.getMainWysiwygToolgroupId(), + this.toolbarView.getFloatingWysiwygToolgroupId() + ); + + // Sets the state to 'changed' whenever the content has changed. + this.textEditor.onChange(this.element.get(0), function (html) { + that.options.changed(html); + }); + break; + 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, drupalSettings); diff --git a/core/modules/editor/js/editor.js b/core/modules/editor/js/editor.js new file mode 100644 index 0000000..3759284 --- /dev/null +++ b/core/modules/editor/js/editor.js @@ -0,0 +1,80 @@ +/** + * @file + * Attaches behavior for the Editor module. + */ + +(function ($, Drupal, drupalSettings) { + +"use strict"; + +/** + * Initialize an empty object where editors where place their attachment code. + */ +Drupal.editors = {}; + +/** + * Enables editors on text_format elements. + */ +Drupal.behaviors.editors = { + attach: function (context, settings) { + // If there are no editor settings, there are no editors to enable. + if (!settings.editor) { + return; + } + + var $context = $(context); + $context.find('.filter-list:input').once('editors', function () { + var $this = $(this); + var activeEditor = $this.val(); + var field = $this.closest('.text-format-wrapper').find('textarea').get(-1); + + // Directly attach this editor, if the text format is enabled or there is + // only one text format at all. + if ($this.is(':input')) { + if (drupalSettings.editor.formats[activeEditor]) { + Drupal.editorAttach(field, drupalSettings.editor.formats[activeEditor]); + } + } + // Attach onChange handlers to text format selector elements. + if ($this.is('select')) { + $this.on('change.editorAttach', function () { + // Prevent double-binding if the change event is triggered manually. + if ($this.val() === activeEditor) { + return; + } + + // Detach the current editor (if any) and attach a new editor. + if (drupalSettings.editor.formats[activeEditor]) { + Drupal.editorDetach(field, drupalSettings.editor.formats[activeEditor]); + } + activeEditor = $this.val(); + if (drupalSettings.editor.formats[activeEditor]) { + Drupal.editorAttach(field, drupalSettings.editor.formats[activeEditor]); + } + }); + } + // Detach any editor when the containing form is submitted. + $this.parents('form').submit(function (event) { + // Do not detach if the event was canceled. + if (event.isDefaultPrevented()) { + return; + } + Drupal.editorDetach(field, drupalSettings.editor.formats[activeEditor]); + }); + }); + } +}; + +Drupal.editorAttach = function (field, format) { + if (format.editor) { + Drupal.editors[format.editor].attach(field, format); + } +}; + +Drupal.editorDetach = function (field, format) { + if (format.editor) { + Drupal.editors[format.editor].detach(field, format); + } +}; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/editor/lib/Drupal/editor/Plugin/edit/editor/Editor.php b/core/modules/editor/lib/Drupal/editor/Plugin/edit/editor/Editor.php new file mode 100644 index 0000000..b640eac --- /dev/null +++ b/core/modules/editor/lib/Drupal/editor/Plugin/edit/editor/Editor.php @@ -0,0 +1,97 @@ +get('plugin.manager.editor')->getDefinition($editor->editor); + if ($definition['supports_true_wysiwyg'] === TRUE) { + return TRUE; + } + } + + return FALSE; + } + } + + /** + * Implements \Drupal\edit\Plugin\EditorInterface::getMetadata(). + */ + function getMetadata(FieldInstance $instance, array $items) { + $format_id = $items[0]['format']; + $metadata['format'] = $format_id; + $metadata['formatHasTransformations'] = $this->textFormatHasTransformationFilters($format_id); + return $metadata; + } + + /** + * Returns whether the text format has transformation filters. + */ + protected function textFormatHasTransformationFilters($format_id) { + return (bool) count(array_intersect(array(FILTER_TYPE_TRANSFORM_REVERSIBLE, FILTER_TYPE_TRANSFORM_IRREVERSIBLE), filter_get_filter_types_by_format($format_id))); + } + + /** + * Implements \Drupal\edit\Plugin\EditorInterface::getDynamicAttachments(). + */ + function getDynamicAttachments() { + global $user; + + $user_formats = filter_formats($user); + $definitions = drupal_container()->get('plugin.manager.editor')->getDefinitions(); + + // Filter the current user's text to those that support "true WYSIWYG". + $formats = array(); + foreach ($user_formats as $format_id => $format) { + $editor = editor_load($format_id); + if ($editor && isset($definitions[$editor->editor]) && $definitions[$editor->editor]['supports_true_wysiwyg'] === TRUE) { + $formats[$format_id] = $format; + } + } + + return editor_get_attachments($formats); + } + +} diff --git a/core/modules/editor/lib/Drupal/editor/Tests/EditIntegrationTest.php b/core/modules/editor/lib/Drupal/editor/Tests/EditIntegrationTest.php new file mode 100644 index 0000000..ddc8fda --- /dev/null +++ b/core/modules/editor/lib/Drupal/editor/Tests/EditIntegrationTest.php @@ -0,0 +1,178 @@ + 'In-place text editors (Edit module integration)', + 'description' => 'Ensure Edit module uses Editor module\'s "true WYSIWYG" support.', + 'group' => 'Text Editor', + ); + } + + function setUp() { + parent::setUp(); + + // Install the Filter and Text Editor modules. + $this->enableModules(array('filter', 'editor')); + + // Enable the Text Editor Test module. + $this->enableModules(array('editor_test'), FALSE); + + // Create a field. + $this->field_name = 'field_textarea'; + $this->createFieldWithInstance( + $this->field_name, 'text', 1, 'Long text field', + // Instance settings. + array('text_processing' => 1), + // Widget type & settings. + 'text_textarea', + array('size' => 42), + // 'default' formatter type & settings. + 'text_default', + array() + ); + + // Create text format. + $full_html_format = array( + 'format' => 'full_html', + 'name' => 'Full HTML', + 'weight' => 1, + 'filters' => array( + 'filter_htmlcorrector' => array('status' => 1), + ), + ); + $full_html_format = (object) $full_html_format; + filter_format_save($full_html_format); + + // Associate text editor with text format. + $editor = entity_create('editor', array( + 'name' => $full_html_format->name, + 'format' => $full_html_format->format, + 'editor' => 'unicorn', + )); + $editor->save(); + } + + /** + * Retrieves the FieldInstance object for the given field and returns the + * editor that Edit selects. + */ + protected function getSelectedEditor($items, $field_name, $view_mode = 'default') { + $options = entity_get_display('test_entity', 'test_bundle', $view_mode)->getComponent($field_name); + $field_instance = field_info_instance('test_entity', $field_name, 'test_bundle'); + return $this->editorSelector->getEditor($options['type'], $field_instance, $items); + } + + /** + * Tests editor selection when the Editor module is present. + * + * Tests a textual field, with text processing, with cardinality 1 and >1, + * always with a ProcessedTextEditor plug-in present, but with varying text + * format compatibility. + */ + function testEditorSelection() { + $this->editorManager = new EditorManager(); + $this->editorSelector = new EditorSelector($this->editorManager); + + // Pretend there is an entity with these items for the field. + $items = array(array('value' => 'Hello, world!', 'format' => 'filtered_html')); + + // Editor selection w/ cardinality 1, text format w/o associated text editor. + $this->assertEqual('form', $this->getSelectedEditor($items, $this->field_name), "With cardinality 1, and the filtered_html text format, the 'form' editor is selected."); + + // Editor selection w/ cardinality 1, text format w/ associated text editor. + $items[0]['format'] = 'full_html'; + $this->assertEqual('editor', $this->getSelectedEditor($items, $this->field_name), "With cardinality 1, and the full_html text format, the 'editor' editor is selected."); + + // Editor selection with text processing, cardinality >1 + $this->field_textarea_field['cardinality'] = 2; + field_update_field($this->field_textarea_field); + $items[] = array('value' => 'Hallo, wereld!', 'format' => 'full_html'); + $this->assertEqual('form', $this->getSelectedEditor($items, $this->field_name), "With cardinality >1, and both items using the full_html text format, the 'form' editor is selected."); + } + + /** + * Tests (custom) metadata when the "Editor" Create.js editor is used. + */ + function testMetadata() { + $this->editorManager = new EditorManager(); + $this->accessChecker = new MockEditEntityFieldAccessCheck(); + $this->editorSelector = new EditorSelector($this->editorManager); + $this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager); + + // Create an entity with values for the field. + $this->entity = field_test_create_entity(); + $this->is_new = TRUE; + $this->entity->{$this->field_name}[LANGUAGE_NOT_SPECIFIED] = array(array('value' => 'Test', 'format' => 'full_html')); + field_test_entity_save($this->entity); + $entity = entity_load('test_entity', $this->entity->ftid); + + // Verify metadata. + $instance = field_info_instance($entity->entityType(), $this->field_name, $entity->bundle()); + $metadata = $this->metadataGenerator->generate($entity, $instance, LANGUAGE_NOT_SPECIFIED, 'default'); + $expected = array( + 'access' => TRUE, + 'label' => 'Long text field', + 'editor' => 'editor', + 'aria' => 'Entity test_entity 1, field Long text field', + 'custom' => array( + 'format' => 'full_html', + 'formatHasTransformations' => FALSE, + ), + ); + $this->assertEqual($expected, $metadata, 'The correct metadata (including custom metadata) is generated.'); + } + +} diff --git a/core/modules/editor/tests/modules/editor_test.info b/core/modules/editor/tests/modules/editor_test.info new file mode 100644 index 0000000..9745677 --- /dev/null +++ b/core/modules/editor/tests/modules/editor_test.info @@ -0,0 +1,6 @@ +name = Text Editor test +description = Support module for the Text Editor module tests. +core = 8.x +package = Testing +version = VERSION +hidden = TRUE diff --git a/core/modules/editor/tests/modules/editor_test.module b/core/modules/editor/tests/modules/editor_test.module new file mode 100644 index 0000000..7c56259 --- /dev/null +++ b/core/modules/editor/tests/modules/editor_test.module @@ -0,0 +1,6 @@ + TRUE); + } + + /** + * Implements \Drupal\editor\Plugin\EditorInterface::settingsForm(). + */ + function settingsForm(array &$form, array &$form_state, Editor $editor) { + return array(); + } + + /** + * Implements \Drupal\editor\Plugin\EditorInterface::settingsFormValidate(). + */ + function settingsFormValidate(array $form, array &$form_state) { } + + /** + * Implements \Drupal\editor\Plugin\EditorInterface::generateJsSettings(). + */ + function generateJsSettings(Editor $editor) { + return array(); + } + +}