core/modules/edit/edit.module | 1 - core/modules/edit/edit.routing.yml | 8 - core/modules/edit/js/util.js | 35 ---- .../edit/lib/Drupal/edit/EditController.php | 28 --- core/modules/editor/editor.module | 37 ++++ core/modules/editor/editor.routing.yml | 7 + core/modules/editor/js/editor.createjs.js | 187 ++++++++++++++++++ ...RenderedWithoutTransformationFiltersCommand.php | 7 +- .../editor/lib/Drupal/editor/EditorController.php | 47 +++++ .../Drupal/editor/Plugin/edit/editor/Editor.php | 100 ++++++++++ .../Drupal/editor/Tests/EditIntegrationTest.php | 200 ++++++++++++++++++++ .../Plugin/editor/editor/UnicornEditor.php | 3 +- 12 files changed, 584 insertions(+), 76 deletions(-) diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index dc1eec7..902d423 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -119,7 +119,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', diff --git a/core/modules/edit/edit.routing.yml b/core/modules/edit/edit.routing.yml index f63dc82..d66881d 100644 --- a/core/modules/edit/edit.routing.yml +++ b/core/modules/edit/edit.routing.yml @@ -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' diff --git a/core/modules/edit/js/util.js b/core/modules/edit/js/util.js index 6633859..e0aa490 100644 --- a/core/modules/edit/js/util.js +++ b/core/modules/edit/js/util.js @@ -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. diff --git a/core/modules/edit/lib/Drupal/edit/EditController.php b/core/modules/edit/lib/Drupal/edit/EditController.php index 16644ab..aaf2c95 100644 --- a/core/modules/edit/lib/Drupal/edit/EditController.php +++ b/core/modules/edit/lib/Drupal/edit/EditController.php @@ -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; - } - } diff --git a/core/modules/editor/editor.module b/core/modules/editor/editor.module index 6db6210..6cdc777 100644 --- a/core/modules/editor/editor.module +++ b/core/modules/editor/editor.module @@ -78,11 +78,48 @@ function editor_library_info() { array('system', 'jquery.once'), ), ); + // Create.js PropertyEditor widget library names begin with "edit.editor". + $libraries['edit.editor.wysiwyg'] = 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( + 'rerenderProcessedTextURL' => 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(). */ function editor_form_filter_admin_overview_alter(&$form, $form_state) { diff --git a/core/modules/editor/editor.routing.yml b/core/modules/editor/editor.routing.yml new file mode 100644 index 0000000..0bb56cf --- /dev/null +++ b/core/modules/editor/editor.routing.yml @@ -0,0 +1,7 @@ +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' diff --git a/core/modules/editor/js/editor.createjs.js b/core/modules/editor/js/editor.createjs.js new file mode 100644 index 0000000..c7b1fdc --- /dev/null +++ b/core/modules/editor/js/editor.createjs.js @@ -0,0 +1,187 @@ +/** + * @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 + * attachInlineEditor(), detach() and onChange() methods. + */ +(function (jQuery, Drupal, drupalSettings) { + +"use strict"; + + jQuery.widget('Drupal.drupalWysiwygWidget', jQuery.Create.editWidget, { + + textFormat: null, + textFormatHasTransformations: null, + textEditor: null, + + /** + * Implements getEditUISettings() method. + */ + getEditUISettings: 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) { + this._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.attachInlineEditor( + this.element.get(0), + this.textFormat, + this.toolbarView.getMainWysiwygToolgroupId(), + this.toolbarView.getFloatedWysiwygToolgroupId() + ); + + // 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. + * + * @todo: this should not be necessary, will be obviated by edit.module. + */ + _removeValidationErrors: function () { + this.element + .removeClass('edit-validation-error') + .next('.edit-validation-errors').remove(); + }, + + /** + * Cleans up after the widget has been saved. + * + * @todo: this should not be necessary, will be obviated by edit.module. + */ + _cleanUp: function () { + Drupal.edit.util.form.unajaxifySaving(jQuery('#edit_backstage form .edit-form-submit')); + jQuery('#edit_backstage form').remove(); + }, + + /** + * 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. + */ + _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.editor.rerenderProcessedTextURL), + event: 'editor-internal.editor', + submit: { nocssjs : true }, + progress: { type : null } // No progress indicator. + }); + // Implement a scoped editFieldRenderedWithoutTransformationFilters AJAX + // command: calls the callback. + Drupal.ajax[options.propertyID].commands.editorFieldRenderedWithoutTransformationFilters = 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('editor-internal.editor'); + }; + // This will ensure our scoped editFieldRenderedWithoutTransformationFilters + // AJAX command gets called. + options.$editorElement.trigger('editor-internal.editor'); + } + + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/edit/lib/Drupal/edit/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php b/core/modules/editor/lib/Drupal/editor/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php similarity index 65% rename from core/modules/edit/lib/Drupal/edit/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php rename to core/modules/editor/lib/Drupal/editor/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php index 53a8826..77100a7 100644 --- a/core/modules/edit/lib/Drupal/edit/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php +++ b/core/modules/editor/lib/Drupal/editor/Ajax/FieldRenderedWithoutTransformationFiltersCommand.php @@ -2,12 +2,13 @@ /** * @file - * Definition of Drupal\edit\Ajax\FieldRenderedWithoutTransformationFiltersCommand. + * Definition of Drupal\editor\Ajax\FieldRenderedWithoutTransformationFiltersCommand. */ -namespace Drupal\edit\Ajax; +namespace Drupal\editor\Ajax; use Drupal\Core\Ajax\CommandInterface; +use Drupal\edit\Ajax\BaseCommand; /** * AJAX command to rerender a processed text field without any transformation @@ -22,7 +23,7 @@ class FieldRenderedWithoutTransformationFiltersCommand extends BaseCommand { * The data to pass on to the client side. */ public function __construct($data) { - parent::__construct('editFieldRenderedWithoutTransformationFilters', $data); + parent::__construct('editorFieldRenderedWithoutTransformationFilters', $data); } } diff --git a/core/modules/editor/lib/Drupal/editor/EditorController.php b/core/modules/editor/lib/Drupal/editor/EditorController.php new file mode 100644 index 0000000..2f774fd --- /dev/null +++ b/core/modules/editor/lib/Drupal/editor/EditorController.php @@ -0,0 +1,47 @@ +addCommand(new FieldRenderedWithoutTransformationFiltersCommand($editable_text)); + + return $response; + } + +} 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..5aee9ef --- /dev/null +++ b/core/modules/editor/lib/Drupal/editor/Plugin/edit/editor/Editor.php @@ -0,0 +1,100 @@ +get('plugin.manager.editor')->getDefinition($editor->editor); + if ($definition['supports_inline_editing'] === 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\EditorInterface::getAttachments(). + */ + public function getAttachments() { + global $user; + + $user_format_ids = array_keys(filter_formats($user)); + $manager = drupal_container()->get('plugin.manager.editor'); + $definitions = $manager->getDefinitions(); + + // Filter the current user's text to those that support inline editing. + $formats = array(); + foreach ($user_format_ids as $format_id) { + $editor = editor_load($format_id); + if ($editor && isset($definitions[$editor->editor]) && isset($definitions[$editor->editor]['supports_inline_editing']) && $definitions[$editor->editor]['supports_inline_editing'] === TRUE) { + $formats[] = $format_id; + } + } + + // Get the attachments for all text editors that the user might use. + $attachments = $manager->getAttachments($formats); + + // Also include editor.module's Create.js PropertyEditor widget. + $attachments['library'][] = array('editor', 'edit.editor.wysiwyg'); + + return $attachments; + } + +} 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..3dfd688 --- /dev/null +++ b/core/modules/editor/lib/Drupal/editor/Tests/EditIntegrationTest.php @@ -0,0 +1,200 @@ + 'In-place text editors (Edit module integration)', + 'description' => 'Tests Edit module integration (Editor module\'s inline editing support).', + 'group' => 'Text Editor', + ); + } + + function setUp() { + parent::setUp(); + + // Install the Filter module. + $this->installSchema('system', 'url_alias'); + $this->enableModules(array('user', 'filter')); + + // Enable the Text Editor and Text Editor Test module. + $this->enableModules(array('editor', '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 = entity_create('filter_format', array( + 'format' => 'full_html', + 'name' => 'Full HTML', + 'weight' => 1, + 'filters' => array(), + )); + $full_html_format->save(); + + // Associate text editor with text format. + $editor = entity_create('editor', array( + '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.'); + } + + /** + * Tests FieldRenderedWithoutTransformationFiltersCommand AJAX command. + */ + function testFieldRenderedWithoutTransformationFiltersCommand() { + // 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 AJAX response. + $controller = new EditorController(); + $request = new Request(); + $response = $controller->getUntransformedText($entity, $this->field_name, LANGUAGE_NOT_SPECIFIED, 'default'); + $expected = array( + array( + 'command' => 'editorFieldRenderedWithoutTransformationFilters', + 'data' => 'Test', + ) + ); + $this->assertEqual(drupal_json_encode($expected), $response->prepare($request)->getContent(), 'The FieldRenderedWithoutTransformationFiltersCommand AJAX command works correctly.'); + } +} diff --git a/core/modules/editor/tests/modules/lib/Drupal/editor_test/Plugin/editor/editor/UnicornEditor.php b/core/modules/editor/tests/modules/lib/Drupal/editor_test/Plugin/editor/editor/UnicornEditor.php index 6a0fbaa..4ba8fac 100644 --- a/core/modules/editor/tests/modules/lib/Drupal/editor_test/Plugin/editor/editor/UnicornEditor.php +++ b/core/modules/editor/tests/modules/lib/Drupal/editor_test/Plugin/editor/editor/UnicornEditor.php @@ -18,7 +18,8 @@ * @Plugin( * id = "unicorn", * label = @Translation("Unicorn Editor"), - * module = "editor_test" + * module = "editor_test", + * supports_inline_editing = TRUE * ) */ class UnicornEditor extends EditorBase {