core/modules/edit/css/edit.css | 410 +++++++++++++++ core/modules/edit/edit.info | 6 + core/modules/edit/edit.module | 159 ++++++ core/modules/edit/edit.routing.yml | 22 + core/modules/edit/images/attention.png | 4 + core/modules/edit/images/close.png | 4 + core/modules/edit/images/icon-edit-active.png | 3 + core/modules/edit/images/icon-edit.png | 5 + core/modules/edit/images/throbber.gif | 6 + core/modules/edit/js/app.js | 528 ++++++++++++++++++++ core/modules/edit/js/backbone.drupalform.js | 164 ++++++ core/modules/edit/js/createjs/editable.js | 43 ++ .../editingWidgets/drupalcontenteditablewidget.js | 110 ++++ .../edit/js/createjs/editingWidgets/formwidget.js | 150 ++++++ core/modules/edit/js/createjs/storage.js | 11 + core/modules/edit/js/edit.js | 144 ++++++ core/modules/edit/js/models/edit-app-model.js | 22 + core/modules/edit/js/routers/edit-router.js | 59 +++ core/modules/edit/js/theme.js | 175 +++++++ core/modules/edit/js/util.js | 142 ++++++ core/modules/edit/js/viejs/EditService.js | 297 +++++++++++ core/modules/edit/js/views/menu-view.js | 82 +++ core/modules/edit/js/views/modal-view.js | 107 ++++ core/modules/edit/js/views/overlay-view.js | 86 ++++ .../edit/js/views/propertyeditordecoration-view.js | 324 ++++++++++++ core/modules/edit/js/views/toolbar-view.js | 465 +++++++++++++++++ .../edit/Access/EditEntityFieldAccessCheck.php | 78 +++ .../Access/EditEntityFieldAccessCheckInterface.php | 22 + .../edit/lib/Drupal/edit/Ajax/BaseCommand.php | 52 ++ .../edit/lib/Drupal/edit/Ajax/FieldFormCommand.php | 27 + .../lib/Drupal/edit/Ajax/FieldFormSavedCommand.php | 28 ++ .../edit/Ajax/FieldFormValidationErrorsCommand.php | 28 ++ ...RenderedWithoutTransformationFiltersCommand.php | 28 ++ core/modules/edit/lib/Drupal/edit/EditBundle.php | 37 ++ .../edit/lib/Drupal/edit/EditController.php | 146 ++++++ .../edit/lib/Drupal/edit/EditorSelector.php | 167 +++++++ .../lib/Drupal/edit/EditorSelectorInterface.php | 55 ++ .../edit/lib/Drupal/edit/Form/EditFieldForm.php | 142 ++++++ .../edit/lib/Drupal/edit/MetadataGenerator.php | 80 +++ .../lib/Drupal/edit/MetadataGeneratorInterface.php | 41 ++ .../Drupal/edit/Plugin/ProcessedTextEditorBase.php | 29 ++ .../edit/Plugin/ProcessedTextEditorInterface.php | 35 ++ .../edit/Plugin/ProcessedTextEditorManager.php | 31 ++ .../lib/Drupal/edit/Tests/EditorSelectionTest.php | 240 +++++++++ core/modules/edit/tests/modules/edit_test.info | 6 + core/modules/edit/tests/modules/edit_test.module | 6 + .../processed_text_editor/TestProcessedEditor.php | 31 ++ .../field/formatter/TextDefaultFormatter.php | 3 + .../Plugin/field/formatter/TextPlainFormatter.php | 3 + .../formatter/TextSummaryOrTrimmedFormatter.php | 3 + .../field/formatter/TextTrimmedFormatter.php | 3 + 51 files changed, 4849 insertions(+) diff --git a/core/modules/edit/css/edit.css b/core/modules/edit/css/edit.css new file mode 100644 index 0000000..65c7f38 --- /dev/null +++ b/core/modules/edit/css/edit.css @@ -0,0 +1,410 @@ +/** + * Animations. + */ +.edit-animate-invisible { + opacity: 0; +} + +.edit-animate-fast { +-webkit-transition: all .2s ease; + -moz-transition: all .2s ease; + -ms-transition: all .2s ease; + -o-transition: all .2s ease; + transition: all .2s ease; +} + +.edit-animate-default { + -webkit-transition: all .4s ease; + -moz-transition: all .4s ease; + -ms-transition: all .4s ease; + -o-transition: all .4s ease; + transition: all .4s ease; +} + +.edit-animate-slow { +-webkit-transition: all .6s ease; + -moz-transition: all .6s ease; + -ms-transition: all .6s ease; + -o-transition: all .6s ease; + transition: all .6s ease; +} + +.edit-animate-delay-veryfast { + -webkit-transition-delay: .05s; + -moz-transition-delay: .05s; + -ms-transition-delay: .05s; + -o-transition-delay: .05s; + transition-delay: .05s; +} + +.edit-animate-delay-fast { + -webkit-transition-delay: .2s; + -moz-transition-delay: .2s; + -ms-transition-delay: .2s; + -o-transition-delay: .2s; + transition-delay: .2s; +} + +.edit-animate-disable-width { + -webkit-transition: width 0s; + -moz-transition: width 0s; + -ms-transition: width 0s; + -o-transition: width 0s; + transition: width 0s; +} + +.edit-animate-only-visibility { + -webkit-transition: opacity .2s ease; + -moz-transition: opacity .2s ease; + -ms-transition: opacity .2s ease; + -o-transition: opacity .2s ease; + transition: opacity .2s ease; +} + +.edit-animate-only-background-and-padding { + -webkit-transition: background, padding .2s ease; + -moz-transition: background, padding .2s ease; + -ms-transition: background, padding .2s ease; + -o-transition: background, padding .2s ease; + transition: background, padding .2s ease; +} + + + +/** + * Toolbar. + */ +.icon-edit:before { + background-image: url("../images/icon-edit.png"); +} +.icon-edit:active:before, +.active .icon-edit:before { + background-image: url("../images/icon-edit-active.png"); +} +.toolbar .tray.edit.active { + z-index: 340; +} +.toolbar .icon-edit.edit-nothing-editable-hidden { + display: none; +} +/* In-place editing doesn't work in the overlay, so always hide the tab. */ +.overlay-open .toolbar .icon-edit { + display: none; +} + + + +/** + * Edit mode: overlay + candidate editables + editables being edited. + * + * Note: every class is prefixed with "edit-" to prevent collisions with modules + * or themes. In IPE-specific DOM subtrees, this is not necessary. + */ + +#edit_overlay { + position: fixed; + z-index: 250; + width: 100%; + height: 100%; + background-color: #fff; + background-color: rgba(255,255,255,.5); + top: 0; + left: 0; +} + +/* Editable. */ +.edit-editable { + z-index: 300; + position: relative; +} +.edit-editable:focus { + outline: none; +} +.edit-field.edit-editable, +.edit-field.edit-type-direct .edit-editable { + box-shadow: 0 0 1px 1px #4d9de9; +} + +/* Highlighted (hovered) editable. */ +.edit-editable.edit-highlighted { + min-width: 200px; +} +.edit-field.edit-editable.edit-highlighted, +.edit-form.edit-editable.edit-highlighted, +.edit-field.edit-type-direct .edit-editable.edit-highlighted { + box-shadow: 0 0 1px 1px #0199ff, 0 0 3px 3px rgba(153, 153, 153, .5); +} +.edit-field.edit-editable.edit-highlighted.edit-validation-error, +.edit-form.edit-editable.edit-highlighted.edit-validation-error, +.edit-field.edit-type-direct .edit-editable.edit-highlighted.edit-validation-error { + box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); +} +.edit-form.edit-editable .form-item .error { + border: 1px solid #eea0a0; +} + + +/* Editing (focused) editable. */ +.edit-form.edit-editable.edit-editing, +.edit-field.edit-type-direct .edit-editable.edit-editing { + /* In the latest design, there's no special styling when editing as opposed to + * just hovering. + * This will be necessary again for http://drupal.org/node/1844220. + */ +} + + + + +/** + * Edit mode: modal. + */ +#edit_modal { + z-index: 350; + position: fixed; + top: 40%; + left: 40%; + box-shadow: 3px 3px 5px #333; + background-color: white; + border: 1px solid #0199ff; + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} + +#edit_modal .main { + font-size: 130%; + margin: 25px; + padding-left: 40px; + background: transparent url('../images/attention.png') no-repeat; +} + +#edit_modal .actions { + border-top: 1px solid #ddd; + padding: 3px inherit; + text-align: right; + background: #f5f5f5; +} + +/* Modal active: prevent user from interacting with toolbar & editables. */ +.edit-form-container.edit-belowoverlay, +.edit-toolbar-container.edit-belowoverlay, +.edit-validation-errors.edit-belowoverlay { + z-index: 210; +} +.edit-editable.edit-belowoverlay { + z-index: 200; +} + + + + +/** + * Edit mode: type=direct. + */ +.edit-validation-errors { + z-index: 300; + position: relative; +} + +.edit-validation-errors .messages.error { + position: absolute; + top: 6px; + left: -5px; + margin: 0; + border: none; + box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); + background-color: white; +} + + + + +/** + * Edit mode: type=form. + */ +#edit_backstage { + display: none; +} + +.edit-form { + position: absolute; + z-index: 300; + box-shadow: 0 0 30px 4px #4f4f4f; + max-width: 35em; +} + +.edit-form .placeholder { + min-height: 22px; +} + +/* Default form styling overrides. */ +.edit-form form { padding: 1em; } +.edit-form .form-item { margin: 0; } +.edit-form .form-wrapper { margin: .5em; } +.edit-form .form-wrapper .form-wrapper { margin: inherit; } +.edit-form .form-actions { display: none; } +.edit-form input { max-width: 100%; } + + + + +/** + * Edit mode: toolbars + */ + +/* Trick: wrap statically positioned elements in relatively positioned element + without changing its location. This allows us to absolutely position the + toolbar. +*/ +.edit-toolbar-container, +.edit-form-container { + position: relative; + padding: 0; + border: 0; + margin: 0; + vertical-align: baseline; + z-index: 310; +} +.edit-toolbar-container { + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; +} + +.edit-toolbar-heightfaker { + height: auto; + position: absolute; + bottom: 1px; + box-shadow: 0 0 1px 1px #0199ff, 0 0 3px 3px rgba(153, 153, 153, .5); + background: #fff; +} + +/* The toolbar; these are not necessarily visible. */ +.edit-toolbar { + position: relative; + height: 100%; + font-family: 'Droid sans', 'Lucida Grande', sans-serif; +} +.edit-toolbar-heightfaker { + clip: rect(-1000px, 1000px, auto, -1000px); /* Remove bottom box-shadow. */ +} +/* Exception: when used for a directly WYSIWYG editable field that is actively + being edited. */ +.edit-type-direct-with-wysiwyg .edit-editing .edit-toolbar-heightfaker { + width: 100%; + clip: auto; +} + + +/* The toolbar contains toolgroups; these are visible. */ +.edit-toolgroup { + float: left; /* LTR */ +} + +/* Info toolgroup. */ +.edit-toolgroup.info { + float: left; /* LTR */ + font-weight: bolder; + padding: 0 5px; + background: #fff url('../images/throbber.gif') no-repeat -60px 60px; +} +.edit-toolgroup.info.loading { + padding-right: 35px; + background-position: 90% 50%; +} + +/* Operations toolgroup. */ +.edit-toolgroup.ops { + float: right; /* LTR */ + margin-left: 5px; +} + +.edit-toolgroup.wysiwyg-tabs { + float: right; +} +.edit-toolgroup.wysiwyg { + clear: left; + width: 100%; + padding-left: 0; +} + + + +/** + * Edit mode: buttons (in both modal and toolbar). + */ +#edit_modal button, +.edit-toolbar button { + float: left; /* LTR */ + display: block; + height: 29px; + min-width: 29px; + padding: 3px 6px 6px 6px; + margin: 4px 5px 1px 0; + border: 1px solid #fff; + border-radius: 3px; + color: white; + text-decoration: none; + font-size: 13px; + cursor: pointer; +} +#edit_modal button { + float: none; + display: inline-block; +} + +/* Button with icons. */ +#edit_modal button span, +.edit-toolbar button span { + width: 22px; + height: 19px; + display: block; + float: left; +} +.edit-toolbar span.close { + background: url('../images/close.png') no-repeat 3px 2px; + text-indent: -999em; + direction: ltr; +} + +.edit-toolbar button.blank-button { + color: black; + background-color: #fff; + font-weight: bolder; +} + +#edit_modal button.blue-button, +.edit-toolbar button.blue-button { + color: white; + background-image: -webkit-linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + background-image: -moz-linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + background-image: linear-gradient(top, #6fc2f2 0%, #4e97c0 100%); + border-radius: 5px; +} + +#edit_modal button.gray-button, +.edit-toolbar button.gray-button { + color: #666; + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #ccc 100%); + background-image: -moz-linear-gradient(top, #f5f5f5 0%, #ccc 100%); + background-image: linear-gradient(top, #f5f5f5 0%, #ccc 100%); + border-radius: 5px; +} + +#edit_modal button.blue-button:hover, +.edit-toolbar button.blue-button:hover, +#edit_modal button.blue-button:active, +.edit-toolbar button.blue-button:active { + border: 1px solid #55a5d3; + box-shadow: 0 2px 1px rgba(0,0,0,0.2); +} + +#edit_modal button.gray-button:hover, +.edit-toolbar button.gray-button:hover, +#edit_modal button.gray-button:active, +.edit-toolbar button.gray-button:active { + border: 1px solid #cdcdcd; + box-shadow: 0 2px 1px rgba(0,0,0,0.1); +} diff --git a/core/modules/edit/edit.info b/core/modules/edit/edit.info new file mode 100644 index 0000000..5298534 --- /dev/null +++ b/core/modules/edit/edit.info @@ -0,0 +1,6 @@ +name = Edit +description = In-place content editing. +package = Core +core = 8.x + +dependencies[] = field diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module new file mode 100644 index 0000000..e3beec0 --- /dev/null +++ b/core/modules/edit/edit.module @@ -0,0 +1,159 @@ + array( + 'title' => t('Access in-place editing'), + ), + ); +} + +/** + * Implements hook_toolbar(). + */ +function edit_toolbar() { + if (!user_access('access in-place editing')) { + return; + } + + $tab['edit'] = array( + 'tab' => array( + 'title' => t('Edit'), + 'href' => '', + 'html' => FALSE, + 'attributes' => array( + 'class' => array('icon', 'icon-edit', 'edit-nothing-editable-hidden'), + ), + ), + 'tray' => array( + '#attached' => array( + 'library' => array( + array('edit', 'edit'), + ), + ), + ), + ); + + // Include the attachments and settings for all available editors. + $attachments = drupal_container()->get('edit.editor.selector')->getAllEditorAttachments(); + $tab['edit']['tray']['#attached'] = array_merge_recursive($tab['edit']['tray']['#attached'], $attachments); + + return $tab; +} + +/** + * Implements hook_library(). + */ +function edit_library_info() { + $path = drupal_get_path('module', 'edit'); + $options = array( + 'scope' => 'footer', + 'attributes' => array('defer' => TRUE), + ); + $libraries['edit'] = array( + 'title' => 'Edit: in-place editing', + 'website' => 'http://drupal.org/project/edit', + 'version' => VERSION, + 'js' => array( + // Core. + $path . '/js/edit.js' => $options, + $path . '/js/app.js' => $options, + // Routers. + $path . '/js/routers/edit-router.js' => $options, + // Models. + $path . '/js/models/edit-app-model.js' => $options, + // Views. + $path . '/js/views/propertyeditordecoration-view.js' => $options, + $path . '/js/views/menu-view.js' => $options, + $path . '/js/views/modal-view.js' => $options, + $path . '/js/views/overlay-view.js' => $options, + $path . '/js/views/toolbar-view.js' => $options, + // Backbone.sync implementation on top of Drupal forms. + $path . '/js/backbone.drupalform.js' => $options, + // VIE service. + $path . '/js/viejs/EditService.js' => $options, + // Create.js subclasses. + $path . '/js/createjs/editable.js' => $options, + $path . '/js/createjs/storage.js' => $options, + $path . '/js/createjs/editingWidgets/formwidget.js' => $options, + $path . '/js/createjs/editingWidgets/drupalcontenteditablewidget.js' => $options, + // Other. + $path . '/js/util.js' => $options, + $path . '/js/theme.js' => $options, + // Basic settings. + array( + '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', + ), + ), + 'css' => array( + $path . '/css/edit.css' => array(), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'underscore'), + array('system', 'backbone'), + array('system', 'vie.core'), + array('system', 'create.editonly'), + array('system', 'jquery.form'), + array('system', 'drupal.form'), + array('system', 'drupal.ajax'), + array('system', 'drupalSettings'), + ), + ); + + return $libraries; +} + +/** + * Implements hook_preprocess_HOOK() for field.tpl.php. + */ +function edit_preprocess_field(&$variables) { + $element = $variables['element']; + $entity = $element['#object']; + $variables['attributes']['data-edit-id'] = $entity->entityType() . ':' . $entity->id() . ':' . $element['#field_name'] . ':' . $element['#language'] . ':' . $element['#view_mode']; +} + +/** + * Form constructor for the field editing form. + * + * @ingroup forms + */ +function edit_field_form(array $form, array &$form_state, EntityInterface $entity, $field_name) { + $form_handler = new EditFieldForm(); + return $form_handler->build($form, $form_state, $entity, $field_name); +} diff --git a/core/modules/edit/edit.routing.yml b/core/modules/edit/edit.routing.yml new file mode 100644 index 0000000..f63dc82 --- /dev/null +++ b/core/modules/edit/edit.routing.yml @@ -0,0 +1,22 @@ +edit_metadata: + pattern: '/edit/metadata' + defaults: + _controller: '\Drupal\edit\EditController::metadata' + requirements: + _permission: 'access in-place editing' + +edit_field_form: + pattern: '/edit/form/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}' + defaults: + _controller: '\Drupal\edit\EditController::fieldForm' + 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/images/attention.png b/core/modules/edit/images/attention.png new file mode 100644 index 0000000..6a35d1d --- /dev/null +++ b/core/modules/edit/images/attention.png @@ -0,0 +1,4 @@ +PNG + + IHDR *}`PLTEl՟FZݱ|В8ʂx՜nϏ۫@EN;[cgUH7 tRNS =g- Su- 4_IDATx^}ʇ @Qzn/g!ul6 ; 0!f>>Ǐ kν_΁j㜻!0-@>8,i vҤrlWn?B(ijk*yT%Pvs=b_v>@?k& +a|NciKBFUD^']d`5+5P :IENDB` \ No newline at end of file diff --git a/core/modules/edit/images/close.png b/core/modules/edit/images/close.png new file mode 100644 index 0000000..e3f98b8 --- /dev/null +++ b/core/modules/edit/images/close.png @@ -0,0 +1,4 @@ +PNG + + IHDR(-S`PLTE>+tRNS```00miIDATx^=   H4ͼ!KsfQGx"LCyל׽(ux;z KA.Jo +E wy/2cdD@Ҕ L؅O%8F?QIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-edit-active.png b/core/modules/edit/images/icon-edit-active.png new file mode 100644 index 0000000..ad84761 --- /dev/null +++ b/core/modules/edit/images/icon-edit-active.png @@ -0,0 +1,3 @@ +PNG + + IHDRj `PLTE[tRNS@ P00p`ϟDƙIDATxe DQ8Ϩ/BDU9xV+D\?x@qWcF8wicS B}?v;Vf.V$JgX=Kضp0XS"iRw\:LL\~;Z5wu 5E)LIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/icon-edit.png b/core/modules/edit/images/icon-edit.png new file mode 100644 index 0000000..4f0dcc2 --- /dev/null +++ b/core/modules/edit/images/icon-edit.png @@ -0,0 +1,5 @@ +PNG + + IHDRj PLTE̻ʪ̡̜ˠ̣̽¼ǷʨªZ(e+tRNSϟ `π@`0p0p`0PϟϟcIDATxeW0{W +H"ʵ,y {Hpyo?mf,RBRxB vL;&LPJaRb\(Tbn(1wϔJ)ԈkS +58äT^4P6c}[i <ާ'-+HP>KIENDB` \ No newline at end of file diff --git a/core/modules/edit/images/throbber.gif b/core/modules/edit/images/throbber.gif new file mode 100644 index 0000000..f2603e8 --- /dev/null +++ b/core/modules/edit/images/throbber.gif @@ -0,0 +1,6 @@ +GIF89aŽ{{{! NETSCAPE2.0! ,@`)KkŏA|ad0L9~\8L Ǹ0, i{qBC~H'JRĨ`f4&a! ,` sɺ(t34M!-0,l#))9 !q(i<hB 3˥(`  ,9%cm +b0AY_e! ,dRj:ړtG8$c02P hi,ǑQ0X[LEck5`ڭP2F! a @q dfz{lQzK! ,`BjR:$BPFq(ˢ J@0i-2͇ Qck6[G`m:pQ4fqow! ,d1j}MS@S\ H9#IrPØi8f..r$ł|nl +*T\![͂l,Q0@(KFMO{ ql{! ,^I 3a!P(Ol~`8 LÇ!@$Lgx|f ,`"`Jʼn"cUP +GAw< tz! ,h9C4kk\ &I&l )F-P@ch H!ш4< +  x@R`0C"8h0BfgX(rc}Y + 1!,aйҚX]mKl[)"aĢ\*"$t& #9@1pP`,`I,8 S 8U,Q`(d `3-qH$ 1T{; \ No newline at end of file diff --git a/core/modules/edit/js/app.js b/core/modules/edit/js/app.js new file mode 100644 index 0000000..00bba20 --- /dev/null +++ b/core/modules/edit/js/app.js @@ -0,0 +1,528 @@ +/** + * @file + * A Backbone View that is the central app controller. + */ +(function ($, _, Backbone, Drupal, VIE) { + +"use strict"; + + Drupal.edit = Drupal.edit || {}; + Drupal.edit.EditAppView = Backbone.View.extend({ + vie: null, + domService: null, + + // Configuration for state handling. + states: [], + activeEditorStates: [], + singleEditorStates: [], + + // State. + $entityElements: null, + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function() { + _.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange'); + + // VIE instance for Edit. + this.vie = new VIE(); + // Use our custom DOM parsing service until RDFa is available. + this.vie.use(new this.vie.EditService()); + this.domService = this.vie.service('edit'); + + // Instantiate configuration for state handling. + this.states = [ + null, 'inactive', 'candidate', 'highlighted', + 'activating', 'active', 'changed', 'saving', 'saved', 'invalid' + ]; + this.activeEditorStates = ['activating', 'active']; + this.singleEditorStates = _.union(['highlighted'], this.activeEditorStates); + + this.$entityElements = $([]); + + // Use Create's Storage widget. + this.$el.createStorage({ + vie: this.vie, + editableNs: 'createeditable' + }); + + // Instantiate OverlayView. + var overlayView = new Drupal.edit.views.OverlayView({ + el: (Drupal.theme('editOverlay', {})), + model: this.model + }); + + // Instantiate MenuView. + var editMenuView = new Drupal.edit.views.MenuView({ + el: this.el, + model: this.model + }); + + // When view/edit mode is toggled in the menu, update the editor widgets. + this.model.on('change:isViewing', this.appStateChange); + }, + + /** + * Finds editable properties within a given context. + * + * Finds editable properties, registers them with the app, updates their + * state to match the current app state. + * + * @param $context + * A jQuery-wrapped context DOM element within which will be searched. + */ + findEditableProperties: function($context) { + var that = this; + var newState = (this.model.get('isViewing')) ? 'inactive' : 'candidate'; + + this.domService.findSubjectElements($context).each(function() { + var $element = $(this); + + // Ignore editable properties for which we've already set up Create.js. + if (that.$entityElements.index($element) !== -1) { + return; + } + + $element + // Instantiate an EditableEntity widget. + .createEditable({ + vie: that.vie, + disabled: true, + state: 'inactive', + acceptStateChange: that.acceptEditorStateChange, + statechange: function(event, data) { + that.editorStateChange(data.previous, data.current, data.propertyEditor); + }, + decoratePropertyEditor: function(data) { + that.decorateEditor(data.propertyEditor); + } + }) + // This event is triggered just before Edit removes an EditableEntity + // widget, so that we can do proper clean-up. + .on('destroyedPropertyEditor.edit', function(event, editor) { + that.undecorateEditor(editor); + that.$entityElements = that.$entityElements.not($(this)); + + }) + // Transition the new PropertyEditor into the current state. + .createEditable('setState', newState); + + // Add this new EditableEntity widget element to the list. + that.$entityElements = that.$entityElements.add($element); + }); + }, + + /** + * Sets the state of PropertyEditor widgets when edit mode begins or ends. + * + * Should be called whenever EditAppModel's "isViewing" changes. + */ + appStateChange: function() { + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133, https://github.com/bergie/create/issues/140) + // We're currently setting the state on EditableEntity widgets instead of + // PropertyEditor widgets, because of + // https://github.com/bergie/create/issues/133. + var newState = (this.model.get('isViewing')) ? 'inactive' : 'candidate'; + this.$entityElements.each(function() { + $(this).createEditable('setState', newState); + }); + // Manage the page's tab indexes. + if (newState === 'candidate') { + this._manageDocumentFocus(); + Drupal.edit.setMessage(Drupal.t('In place edit mode is active'), Drupal.t('Page navigation is limited to editable items.'), Drupal.t('Press escape to exit')); + } + else if (newState === 'inactive') { + this._releaseDocumentFocusManagement(); + Drupal.edit.setMessage(Drupal.t('Edit mode is inactive.'), Drupal.t('Resume normal page navigation')); + } + }, + + /** + * Accepts or reject editor (PropertyEditor) state changes. + * + * This is what ensures that the app is in control of what happens. + * + * @param from + * The previous state. + * @param to + * The new state. + * @param predicate + * The predicate of the property for which the state change is happening. + * @param context + * The context that is trying to trigger the state change. + * @param callback + * The callback function that should receive the state acceptance result. + */ + acceptEditorStateChange: function(from, to, predicate, context, callback) { + var accept = true; + + // If the app is in view mode, then reject all state changes except for + // those to 'inactive'. + if (this.model.get('isViewing')) { + if (to !== 'inactive') { + accept = false; + } + } + // Handling of edit mode state changes is more granular. + else { + // In general, enforce the states sequence. Disallow going back from a + // "later" state to an "earlier" state, except in explicitly allowed + // cases. + if (_.indexOf(this.states, from) > _.indexOf(this.states, to)) { + accept = false; + // Allow: activating/active -> candidate. + // Necessary to stop editing a property. + if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { + accept = true; + } + // Allow: changed/invalid -> candidate. + // Necessary to stop editing a property when it is changed or invalid. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + accept = true; + } + // Allow: highlighted -> candidate. + // Necessary to stop highlighting a property. + else if (from === 'highlighted' && to === 'candidate') { + accept = true; + } + // Allow: saved -> candidate. + // Necessary when successfully saved a property. + else if (from === 'saved' && to === 'candidate') { + accept = true; + } + // Allow: invalid -> saving. + // Necessary to be able to save a corrected, invalid property. + else if (from === 'invalid' && to === 'saving') { + accept = true; + } + } + + // If it's not against the general principle, then here are more + // disallowed cases to check. + if (accept) { + // Ensure only one editor (field) at a time may be higlighted or active. + if (from === 'candidate' && _.indexOf(this.singleEditorStates, to) !== -1) { + if (this.model.get('highlightedEditor') || this.model.get('activeEditor')) { + accept = false; + } + } + // Reject going from activating/active to candidate because of a + // mouseleave. + else if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + } + // When attempting to stop editing a changed/invalid property, ask for + // confirmation. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + else { + // Check whether the transition has been confirmed? + if (context && context.confirmed) { + accept = true; + } + // Confirm this transition. + else { + // The callback will be called from the helper function. + this._confirmStopEditing(callback); + return; + } + } + } + } + } + + callback(accept); + }, + + /** + * Asks the user to confirm whether he wants to stop editing via a modal. + * + * @param acceptCallback + * The callback function as passed to acceptEditorStateChange(). This + * callback function will be called with the user's choice. + * + * @see acceptEditorStateChange() + */ + _confirmStopEditing: function(acceptCallback) { + // Only instantiate if there isn't a modal instance visible yet. + if (!this.model.get('activeModal')) { + var that = this; + var modal = new Drupal.edit.views.ModalView({ + model: this.model, + message: Drupal.t('You have unsaved changes'), + buttons: [ + { action: 'discard', classes: 'gray-button', label: Drupal.t('Discard changes') }, + { action: 'save', type: 'submit', classes: 'blue-button', label: Drupal.t('Save') } + ], + callback: function(action) { + // The active modal has been removed. + that.model.set('activeModal', null); + if (action === 'discard') { + acceptCallback(true); + } + else { + acceptCallback(false); + var editor = that.model.get('activeEditor'); + editor.options.widget.setState('saving', editor.options.property); + } + } + }); + this.model.set('activeModal', modal); + // The modal will set the activeModal property on the model when rendering + // to prevent multiple modals from being instantiated. + modal.render(); + } + else { + // Reject as there is still an open transition waiting for confirmation. + acceptCallback(false); + } + }, + + /** + * Reacts to editor (PropertyEditor) state changes; tracks global state. + * + * @param from + * The previous state. + * @param to + * The new state. + * @param editor + * The PropertyEditor widget object. + */ + editorStateChange: function(from, to, editor) { + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Get rid of this once that issue is solved. + if (!editor) { + return; + } + else { + editor.stateChange(from, to); + } + + // Keep track of the highlighted editor in the global state. + if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== editor) { + this.model.set('highlightedEditor', editor); + } + else if (this.model.get('highlightedEditor') === editor && to === 'candidate') { + this.model.set('highlightedEditor', null); + } + + // Keep track of the active editor in the global state. + if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== editor) { + this.model.set('activeEditor', editor); + Drupal.edit.setMessage(Drupal.t('An editor is active')); + } + else if (this.model.get('activeEditor') === editor && to === 'candidate') { + // Discarded if it transitions from a changed state to 'candidate'. + if (from === 'changed' || from === 'invalid') { + // Retrieve the storage widget from DOM. + var createStorageWidget = this.$el.data('createStorage'); + // Revert changes in the model, this will trigger the direct editable + // content to be reset and redrawn. + createStorageWidget.revertChanges(editor.options.entity); + } + this.model.set('activeEditor', null); + } + + // Propagate the state change to the decoration and toolbar views. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Uncomment this once that issue is solved. + // editor.decorationView.stateChange(from, to); + // editor.toolbarView.stateChange(from, to); + }, + + /** + * Decorates an editor (PropertyEditor). + * + * Upon the page load, all appropriate editors are initialized and decorated + * (i.e. even before anything of the editing UI becomes visible; even before + * edit mode is enabled). + * + * @param editor + * The PropertyEditor widget object. + */ + decorateEditor: function(editor) { + // Toolbars are rendered "on-demand" (highlighting or activating). + // They are a sibling element before the editor's DOM element. + editor.toolbarView = new Drupal.edit.views.ToolbarView({ + editor: editor, + $storageWidgetEl: this.$el + }); + + // Decorate the editor's DOM element depending on its state. + editor.decorationView = new Drupal.edit.views.PropertyEditorDecorationView({ + el: editor.element, + editor: editor, + toolbarId: editor.toolbarView.getId() + }); + + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Get rid of this once that issue is solved. + editor.options.widget.element.on('createeditablestatechange', function(event, data) { + editor.decorationView.stateChange(data.previous, data.current); + editor.toolbarView.stateChange(data.previous, data.current); + }); + }, + + /** + * Undecorates an editor (PropertyEditor). + * + * Whenever a property has been updated, the old HTML will be replaced by + * the new (re-rendered) HTML. The EditableEntity widget will be destroyed, + * as will be the PropertyEditor widget. This method ensures Edit's editor + * views also are removed properly. + * + * @param editor + * The PropertyEditor widget object. + */ + undecorateEditor: function(editor) { + editor.toolbarView.undelegateEvents(); + editor.toolbarView.remove(); + delete editor.toolbarView; + editor.decorationView.undelegateEvents(); + // Don't call .remove() on the decoration view, because that would remove + // a potentially rerendered field. + delete editor.decorationView; + }, + + /** + * Makes elements other than the editables unreachable via the tab key. + * + * @todo refactoring. + * + * This method is currently overloaded, handling elements of state modeling + * and application control. The state of the application is spread between + * this view, its model and aspects of the UI widgets in Create.js. In order + * to drive focus management from the application state (and have it + * influence that state of the application), we need to distall state out + * of Create.js components. + * + * This method introduces behaviors that support accessibility of the edit + * application. Although not yet integrated into the application properly, + * it does provide us with the opportunity to collect feedback from + * users who will interact with edit primarily through keyboard input. We + * want this feedback sooner than we can have a refactored application. + */ + _manageDocumentFocus: function () { + var editablesSelector = '.edit-candidate.edit-editable'; + var inputsSelector = 'a:visible, button:visible, input:visible, textarea:visible, select:visible'; + var $editables = $(editablesSelector) + .attr({ + 'tabindex': 0, + 'role': 'button' + }); + // Instantiate a variable to hold the editable element in the set. + var $currentEditable; + // We're using simple function scope to manage 'this' for the internal + // handler, so save this as that. + var that = this; + // Turn on focus management. + $(document).on('keydown.edit', function (event) { + var activeEditor, editableEntity, predicate; + // Handle esc key press. Close any active editors. + if (event.keyCode === 27) { + event.preventDefault(); + activeEditor = that.model.get('activeEditor'); + if (activeEditor) { + editableEntity = activeEditor.options.widget; + predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'overlay' }); + } + else { + $(editablesSelector).trigger('tabOut.edit'); + // This should move into the state management for the app model. + location.hash = "#view"; + that.model.set('isViewing', true); + } + return; + } + // Handle enter or space key presses. + if (event.keyCode === 13 || event.keyCode === 32) { + if ($currentEditable && $currentEditable.is(editablesSelector)) { + $currentEditable.trigger('click'); + // Squelch additional handlers. + event.preventDefault(); + return; + } + } + // Handle tab key presses. + if (event.keyCode === 9) { + var context = ''; + // Include the view mode toggle with the editables selector. + var selector = editablesSelector + ', #toolbar-tab-edit'; + activeEditor = that.model.get('activeEditor'); + var $confirmDialog = $('#edit_modal'); + // If the edit modal is active, that is the tabbing context. + if ($confirmDialog.length) { + context = $confirmDialog; + selector = inputsSelector; + if (!$currentEditable || $currentEditable.is(editablesSelector)) { + $currentEditable = $(selector, context).eq(-1); + } + } + // If an editor is active, then the tabbing context is the editor and + // its toolbar. + else if (activeEditor) { + context = $(activeEditor.$formContainer).add(activeEditor.toolbarView.$el); + // Include the view mode toggle with the editables selector. + selector = inputsSelector; + if (!$currentEditable || $currentEditable.is(editablesSelector)) { + $currentEditable = $(selector, context).eq(-1); + } + } + // Otherwise the tabbing context is the list of editable predicates. + var $editables = $(selector, context); + if (!$currentEditable) { + $currentEditable = $editables.eq(-1); + } + var count = $editables.length - 1; + var index = $editables.index($currentEditable); + // Navigate backwards. + if (event.shiftKey) { + // Beginning of the set, loop to the end. + if (index === 0) { + index = count; + } + else { + index -= 1; + } + } + // Navigate forewards. + else { + // End of the set, loop to the start. + if (index === count) { + index = 0; + } + else { + index += 1; + } + } + // Tab out of the current editable. + $currentEditable.trigger('tabOut.edit'); + // Update the current editable. + $currentEditable = $editables + .eq(index) + .focus() + .trigger('tabIn.edit'); + // Squelch additional handlers. + event.preventDefault(); + event.stopPropagation(); + } + }); + // Set focus on the edit button initially. + $('#toolbar-tab-edit').focus(); + }, + /** + * Removes key management and edit accessibility features from the DOM. + */ + _releaseDocumentFocusManagement: function () { + $(document).off('keydown.edit'); + $('.edit-allowed.edit-field').removeAttr('tabindex role'); + } + }); + +})(jQuery, _, Backbone, Drupal, VIE); diff --git a/core/modules/edit/js/backbone.drupalform.js b/core/modules/edit/js/backbone.drupalform.js new file mode 100644 index 0000000..ba79e76 --- /dev/null +++ b/core/modules/edit/js/backbone.drupalform.js @@ -0,0 +1,164 @@ +/** + * @file + * Backbone.sync implementation for Edit. This is the beating heart. + */ +(function (jQuery, Backbone, Drupal) { + +"use strict"; + +Backbone.defaultSync = Backbone.sync; +Backbone.sync = function(method, model, options) { + if (options.editor.options.editorName === 'form') { + return Backbone.syncDrupalFormWidget(method, model, options); + } + else { + return Backbone.syncDirect(method, model, options); + } +}; + +/** + * Performs syncing for "form" PredicateEditor widgets. + * + * Implemented on top of Form API and the AJAX commands framework. Sets up + * scoped AJAX command closures specifically for a given PredicateEditor widget + * (which contains a pre-existing form). By submitting the form through + * Drupal.ajax and leveraging Drupal.ajax' ability to have scoped (per-instance) + * command implementations, we are able to update the VIE model, re-render the + * form when there are validation errors and ensure no Drupal.ajax memory leaks. + * + * @see Drupal.edit.util.form + */ +Backbone.syncDrupalFormWidget = function(method, model, options) { + if (method === 'update') { + var predicate = options.editor.options.property; + + var $formContainer = options.editor.$formContainer; + var $submit = $formContainer.find('.edit-form-submit'); + var base = $submit.attr('id'); + + // Successfully saved. + Drupal.ajax[base].commands.editFieldFormSaved = function(ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + + // Call Backbone.sync's success callback with the rerendered field. + var changedAttributes = {}; + // @todo: POSTPONED_ON(Drupal core, http://drupal.org/node/1784216) + // Once full JSON-LD support in Drupal core lands, we can ensure that the + // models that VIE maintains are properly updated. + changedAttributes[predicate] = undefined; + changedAttributes[predicate + '/rendered'] = response.data; + options.success(changedAttributes); + }; + + // Unsuccessfully saved; validation errors. + Drupal.ajax[base].commands.editFieldFormValidationErrors = function(ajax, response, status) { + // Call Backbone.sync's error callback with the validation error messages. + options.error(response.data); + }; + + // The edit_field_form AJAX command is only called upon loading the form for + // the first time, and when there are validation errors in the form; Form + // API then marks which form items have errors. Therefor, we have to replace + // the existing form, unbind the existing Drupal.ajax instance and create a + // new Drupal.ajax instance. + Drupal.ajax[base].commands.editFieldForm = function(ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + + Drupal.ajax.prototype.commands.insert(ajax, { + data: response.data, + selector: '#' + $formContainer.attr('id') + ' form' + }); + + // Create a Drupa.ajax instance for the re-rendered ("new") form. + var $newSubmit = $formContainer.find('.edit-form-submit'); + Drupal.edit.util.form.ajaxifySaving({ nocssjs: false }, $newSubmit); + }; + + // Click the form's submit button; the scoped AJAX commands above will + // handle the server's response. + $submit.trigger('click.edit'); + } +}; + +/** +* Performs syncing for "direct" PredicateEditor widgets. + * + * @see Backbone.syncDrupalFormWidget() + * @see Drupal.edit.util.form + */ +Backbone.syncDirect = function(method, model, options) { + if (method === 'update') { + var fillAndSubmitForm = function(value) { + jQuery('#edit_backstage form') + // Fill in the value in any that isn't hidden or a submit button. + .find(':input[type!="hidden"][type!="submit"]:not(select)').val(value).end() + // Submit the form. + .find('.edit-form-submit').trigger('click.edit'); + }; + var entity = options.editor.options.entity; + var predicate = options.editor.options.property; + var value = model.get(predicate); + + // If form doesn't already exist, load it and then submit. + if (jQuery('#edit_backstage form').length === 0) { + var formOptions = { + propertyID: Drupal.edit.util.calcPropertyID(entity, predicate), + $editorElement: options.editor.element, + nocssjs: true + }; + Drupal.edit.util.form.load(formOptions, function(form, ajax) { + // Create a backstage area for storing forms that are hidden from view + // (hence "backstage" — since the editing doesn't happen in the form, it + // happens "directly" in the content, the form is only used for saving). + jQuery(Drupal.theme('editBackstage', { id: 'edit_backstage' })).appendTo('body'); + // Direct forms are stuffed into #edit_backstage, apparently. + jQuery('#edit_backstage').append(form); + // Disable the browser's HTML5 validation; we only care about server- + // side validation. (Not disabling this will actually cause problems + // because browsers don't like to set HTML5 validation errors on hidden + // forms.) + jQuery('#edit_backstage form').attr('novalidate', true); + var $submit = jQuery('#edit_backstage form .edit-form-submit'); + var base = Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + + // Successfully saved. + Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + jQuery('#edit_backstage form').remove(); + + // Call Backbone.sync's success callback with the rerendered field. + var changedAttributes = {}; + // @todo: POSTPONED_ON(Drupal core, http://drupal.org/node/1784216) + // Once full JSON-LD support in Drupal core lands, we can ensure that the + // models that VIE maintains are properly updated. + changedAttributes[predicate] = jQuery(response.data).find('.field-item').html(); + changedAttributes[predicate + '/rendered'] = response.data; + options.success(changedAttributes); + }; + + // Unsuccessfully saved; validation errors. + Drupal.ajax[base].commands.editFieldFormValidationErrors = function(ajax, response, status) { + // Call Backbone.sync's error callback with the validation error messages. + options.error(response.data); + }; + + // The editFieldForm AJAX command is only called upon loading the form + // for the first time, and when there are validation errors in the form; + // Form API then marks which form items have errors. This is useful for + // "form" editors, but pointless for "direct" editors: the form itself + // won't be visible at all anyway! Therefor, we ignore the new form and + // we continue to use the existing form. + Drupal.ajax[base].commands.editFieldForm = function(ajax, response, status) { + // no-op + }; + + fillAndSubmitForm(value); + }); + } + else { + fillAndSubmitForm(value); + } + } +}; + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/createjs/editable.js b/core/modules/edit/js/createjs/editable.js new file mode 100644 index 0000000..aac1ed2 --- /dev/null +++ b/core/modules/edit/js/createjs/editable.js @@ -0,0 +1,43 @@ +/** + * @file + * Determines which editor to use based on a class attribute. + */ +(function (jQuery, drupalSettings) { + +"use strict"; + + jQuery.widget('Drupal.createEditable', jQuery.Midgard.midgardEditable, { + _create: function() { + this.vie = this.options.vie; + + this.options.domService = 'edit'; + this.options.predicateSelector = '*'; //'.edit-field.edit-allowed'; + + this.options.editors.direct = { + widget: 'drupalContentEditableWidget', + options: {} + }; + this.options.editors['direct-with-wysiwyg'] = { + widget: drupalSettings.edit.wysiwygEditorWidgetName, + options: {} + }; + this.options.editors.form = { + widget: 'drupalFormWidget', + options: {} + }; + + jQuery.Midgard.midgardEditable.prototype._create.call(this); + }, + + _propertyEditorName: function(data) { + if (jQuery(this.element).hasClass('edit-type-direct')) { + if (jQuery(this.element).hasClass('edit-type-direct-with-wysiwyg')) { + return 'direct-with-wysiwyg'; + } + return 'direct'; + } + return 'form'; + } + }); + +})(jQuery, drupalSettings); diff --git a/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js b/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js new file mode 100644 index 0000000..c773e6e --- /dev/null +++ b/core/modules/edit/js/createjs/editingWidgets/drupalcontenteditablewidget.js @@ -0,0 +1,110 @@ +/** + * @file + * Override of Create.js' default "base" (plain contentEditable) widget. + */ +(function (jQuery, Drupal) { + +"use strict"; + + jQuery.widget('Drupal.drupalContentEditableWidget', jQuery.Create.editWidget, { + + /** + * 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 that = this; + + // Sets the state to 'activated' upon clicking the element. + this.element.on("click.edit", function(event) { + event.stopPropagation(); + event.preventDefault(); + that.options.activated(); + }); + + // Sets the state to 'changed' whenever the content has changed. + var before = jQuery.trim(this.element.text()); + this.element.on('keyup paste', function (event) { + if (that.options.disabled) { + return; + } + var current = jQuery.trim(that.element.text()); + if (before !== current) { + before = current; + that.options.changed(current); + } + }); + }, + + /** + * Makes this PropertyEditor widget react to state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + // Removes the "contenteditable" attribute. + this.disable(); + this._removeValidationErrors(); + this._cleanUp(); + } + break; + case 'highlighted': + break; + case 'activating': + break; + case 'active': + // Sets the "contenteditable" attribute to "true". + this.enable(); + 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); diff --git a/core/modules/edit/js/createjs/editingWidgets/formwidget.js b/core/modules/edit/js/createjs/editingWidgets/formwidget.js new file mode 100644 index 0000000..f7c77cd --- /dev/null +++ b/core/modules/edit/js/createjs/editingWidgets/formwidget.js @@ -0,0 +1,150 @@ +/** + * @file + * Form-based Create.js widget for structured content in Drupal. + */ +(function ($, Drupal) { + +"use strict"; + + $.widget('Drupal.drupalFormWidget', $.Create.editWidget, { + + id: null, + $formContainer: null, + + /** + * 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() { + // Sets the state to 'activating' upon clicking the element. + var that = this; + 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) { + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + this.disable(); + } + break; + case 'highlighted': + break; + case 'activating': + this.enable(); + break; + case 'active': + break; + case 'changed': + break; + case 'saving': + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Enables the widget. + */ + enable: function () { + var $editorElement = $(this.options.widget.element); + var propertyID = Drupal.edit.util.calcPropertyID(this.options.entity, this.options.property); + + // Generate a DOM-compatible ID for the form container DOM element. + this.id = 'edit-form-for-' + propertyID.replace(/\//g, '_'); + + // Render form container. + this.$formContainer = $(Drupal.theme('editFormContainer', { + id: this.id, + loadingMsg: Drupal.t('Loading…')} + )); + this.$formContainer + .find('.edit-form') + .addClass('edit-editable edit-highlighted edit-editing') + .attr('role', 'dialog') + .css('background-color', $editorElement.css('background-color')); + + // Insert form container in DOM. + if ($editorElement.css('display') === 'inline') { + // @todo: POSTPONED_ON(Drupal core, title/author/date as Entity Properties) + // This is untested in Drupal 8, because in Drupal 8 we don't yet + // have the ability to edit the node title/author/date, because they + // haven't been converted into Entity Properties yet, and they're the + // only examples in core of "display: inline" properties. + this.$formContainer.prependTo($editorElement.offsetParent()); + + var pos = $editorElement.position(); + this.$formContainer.css('left', pos.left).css('top', pos.top); + } + else { + this.$formContainer.insertBefore($editorElement); + } + + // Load form, insert it into the form container and attach event handlers. + var widget = this; + var formOptions = { + propertyID: propertyID, + $editorElement: $editorElement, + nocssjs: false + }; + Drupal.edit.util.form.load(formOptions, function(form, ajax) { + Drupal.ajax.prototype.commands.insert(ajax, { + data: form, + selector: '#' + widget.id + ' .placeholder' + }); + + var $submit = widget.$formContainer.find('.edit-form-submit'); + Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); + widget.$formContainer + .on('formUpdated.edit', ':input', function () { + // Sets the state to 'changed'. + widget.options.changed(); + }) + .on('keypress.edit', 'input', function (event) { + if (event.keyCode === 13) { + return false; + } + }); + + // Sets the state to 'activated'. + widget.options.activated(); + }); + }, + + /** + * Disables the widget. + */ + disable: function () { + if (this.$formContainer === null) { + return; + } + + Drupal.edit.util.form.unajaxifySaving(this.$formContainer.find('.edit-form-submit')); + this.$formContainer + .off('change.edit', ':input') + .off('keypress.edit', 'input') + .remove(); + this.$formContainer = null; + } + }); + +})(jQuery, Drupal); diff --git a/core/modules/edit/js/createjs/storage.js b/core/modules/edit/js/createjs/storage.js new file mode 100644 index 0000000..580ff82 --- /dev/null +++ b/core/modules/edit/js/createjs/storage.js @@ -0,0 +1,11 @@ +/** + * @file + * Subclasses jQuery.Midgard.midgardStorage to have consistent namespaces. + */ +(function(jQuery) { + +"use strict"; + + jQuery.widget('Drupal.createStorage', jQuery.Midgard.midgardStorage, {}); + +})(jQuery); diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js new file mode 100644 index 0000000..8b8d7af --- /dev/null +++ b/core/modules/edit/js/edit.js @@ -0,0 +1,144 @@ +/** + * @file + * Behaviors for Edit, including the one that initializes Edit's EditAppView. + */ +(function ($, _, Backbone, Drupal, drupalSettings) { + +"use strict"; + +/** + * The edit ARIA live message area. + * + * @todo Eventually the messages area should be converted into a Backbone View + * that will respond to changes in the application's model. For the initial + * implementation, we will call the Drupal.edit.setMessage method when an aural + * message should be read by the user agent. + */ +var $messages; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.metadataCache = Drupal.edit.metadataCache || {}; + +/** + * Attach toggling behavior and in-place editing. + */ +Drupal.behaviors.edit = { + attach: function(context) { + var $context = $(context); + var $fields = $context.find('[data-edit-id]'); + + // Initialize the Edit app. + $context.find('#toolbar-tab-edit').once('edit-init', Drupal.edit.init); + + var annotateField = function(field) { + if (_.has(Drupal.edit.metadataCache, field.editID)) { + var meta = Drupal.edit.metadataCache[field.editID]; + field.$el + .attr('data-edit-field-label', meta.label) + .attr('aria-label', meta.aria) + .addClass('edit-field edit-type-' + meta.editor) + .addClass((meta.access) ? 'edit-allowed' : 'edit-disallowed'); + if (meta.editor === 'direct-with-wysiwyg') { + field.$el + // This editor also uses the Backbone.syncDirect saving mechanism. + .addClass('edit-type-direct') + .attr('data-edit-text-format', meta.format) + .addClass((meta.formatHasTransformations) ? 'edit-text-with-transformation-filters' : 'edit-text-without-transformation-filters'); + } + + return true; + } + return false; + }; + + // Find all fields in the context without metadata. + var fieldsToAnnotate = _.map($fields.not('.edit-allowed, .edit-disallowed'), function(el) { + var $el = $(el); + return { $el: $el, editID: $el.attr('data-edit-id') }; + }); + + // Fields whose metadata is known (typically when they were just modified) + // can be annotated immediately, those remaining must be requested. + var remainingFieldsToAnnotate = _.reduce(fieldsToAnnotate, function(result, field) { + if (!annotateField(field)) { + result.push(field); + } + return result; + }, []); + + // Make fields that could be annotated immediately available for editing. + Drupal.edit.app.findEditableProperties($context); + + if (remainingFieldsToAnnotate.length) { + $(window).ready(function() { + $.ajax({ + url: drupalSettings.edit.metadataURL, + type: 'POST', + data: { 'fields[]' : _.pluck(remainingFieldsToAnnotate, 'editID') }, + dataType: 'json', + success: function(results) { + // Update the metadata cache. + _.each(results, function(metadata, editID) { + Drupal.edit.metadataCache[editID] = metadata; + }); + + // Annotate the remaining fields based on the updated access cache. + _.each(remainingFieldsToAnnotate, annotateField); + + // As soon as there is at least one editable field, show the Edit + // tab in the toolbar. + if ($fields.filter('.edit-allowed').length) { + $('.toolbar .icon-edit.edit-nothing-editable-hidden') + .removeClass('edit-nothing-editable-hidden'); + } + + // Find editable fields, make them editable. + Drupal.edit.app.findEditableProperties($context); + } + }); + }); + } + } +}; + +Drupal.edit.init = function() { + // Append a messages element for appending interaction updates for screen + // readers. + $messages = $(Drupal.theme('editMessageBox')).appendTo($(this).parent()); + // Instantiate EditAppView, which is the controller of it all. EditAppModel + // instance tracks global state (viewing/editing in-place). + var appModel = new Drupal.edit.models.EditAppModel(); + var app = new Drupal.edit.EditAppView({ + el: $('body'), + model: appModel + }); + + // Instantiate EditRouter. + var editRouter = new Drupal.edit.routers.EditRouter({ + appModel: appModel + }); + + // Start Backbone's history/route handling. + Backbone.history.start(); + + // For now, we work with a singleton app, because for Drupal.behaviors to be + // able to discover new editable properties that get AJAXed in, it must know + // with which app instance they should be associated. + Drupal.edit.app = app; +}; + +/** + * Places the message in the edit ARIA live message area. + * + * The message will be read by speaking User Agents. + * + * @param {String} message + * A string to be inserted into the message area. + */ +Drupal.edit.setMessage = function(message) { + var args = Array.prototype.slice.call(arguments); + args.unshift('editMessage'); + $messages.html(Drupal.theme.apply(this, args)); +}; + +})(jQuery, _, Backbone, Drupal, drupalSettings); diff --git a/core/modules/edit/js/models/edit-app-model.js b/core/modules/edit/js/models/edit-app-model.js new file mode 100644 index 0000000..b6ff36f --- /dev/null +++ b/core/modules/edit/js/models/edit-app-model.js @@ -0,0 +1,22 @@ +/** + * @file + * A Backbone Model that models the current Edit application state. + */ +(function(Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.models = Drupal.edit.models || {}; +Drupal.edit.models.EditAppModel = Backbone.Model.extend({ + defaults: { + // We always begin in view mode. + isViewing: true, + highlightedEditor: null, + activeEditor: null, + // Reference to a ModalView-instance if a transition requires confirmation. + activeModal: null + } +}); + +})(Backbone, Drupal); diff --git a/core/modules/edit/js/routers/edit-router.js b/core/modules/edit/js/routers/edit-router.js new file mode 100644 index 0000000..d160ad4 --- /dev/null +++ b/core/modules/edit/js/routers/edit-router.js @@ -0,0 +1,59 @@ +/** + * @file + * A Backbone Router enabling URLs to make the user enter edit mode directly. + */ +(function(Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.routers = {}; +Drupal.edit.routers.EditRouter = Backbone.Router.extend({ + + appModel: null, + + routes: { + "edit": "edit", + "view": "view", + "": "view" + }, + + initialize: function(options) { + this.appModel = options.appModel; + + var that = this; + this.appModel.on('change:isViewing', function() { + that.navigate(that.appModel.get('isViewing') ? '#view' : '#edit'); + }); + }, + + edit: function() { + this.appModel.set('isViewing', false); + }, + + view: function(query, page) { + var that = this; + + // If there's an active editor, attempt to set its state to 'candidate', and + // then act according to the user's choice. + var activeEditor = this.appModel.get('activeEditor'); + if (activeEditor) { + var editableEntity = activeEditor.options.widget; + var predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'menu' }, function(accepted) { + if (accepted) { + that.appModel.set('isViewing', true); + } + else { + that.appModel.set('isViewing', false); + } + }); + } + // Otherwise, we can switch to view mode directly. + else { + that.appModel.set('isViewing', true); + } + } +}); + +})(Backbone, Drupal); diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js new file mode 100644 index 0000000..80dcbef --- /dev/null +++ b/core/modules/edit/js/theme.js @@ -0,0 +1,175 @@ +/** + * @file + * Provides overridable theme functions for all of Edit's client-side HTML. + */ +(function($, Drupal) { + +"use strict"; + +/** + * Theme function for the overlay of the Edit module. + * + * @param settings + * An object with the following keys: + * - None. + * @return + * The corresponding HTML. + */ +Drupal.theme.editOverlay = function(settings) { + var html = ''; + html += '
'; + return html; +}; + +/** + * Theme function for a "backstage" for the Edit module. + * + * @param settings + * An object with the following keys: + * - id: the id to apply to the backstage. + * @return + * The corresponding HTML. + */ +Drupal.theme.editBackstage = function(settings) { + var html = ''; + html += '
'; + return html; +}; + +/** + * Theme function for a modal of the Edit module. + * + * @param settings + * An object with the following keys: + * - None. + * @return + * The corresponding HTML. + */ +Drupal.theme.editModal = function(settings) { + var classes = 'edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + var html = ''; + html += ''; + return html; +}; + +/** + * Theme function for a toolbar container of the Edit module. + * + * @param settings + * An object with the following keys: + * - id: the id to apply to the toolbar container. + * @return + * The corresponding HTML. + */ +Drupal.theme.editToolbarContainer = function(settings) { + var html = ''; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + return html; +}; + +/** + * Theme function for a toolbar toolgroup of the Edit module. + * + * @param settings + * An object with the following keys: + * - classes: the class of the toolgroup. + * - buttons: @see Drupal.theme.prototype.editButtons(). + * @return + * The corresponding HTML. + */ +Drupal.theme.editToolgroup = function(settings) { + var classes = 'edit-toolgroup edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + var html = ''; + html += '
'; + html += Drupal.theme('editButtons', { buttons: settings.buttons }); + html += '
'; + return html; +}; + +/** + * Theme function for buttons of the Edit module. + * + * Can be used for the buttons both in the toolbar toolgroups and in the modal. + * + * @param settings + * An object with the following keys: + * - buttons: an array of objects with the following keys: + * - type: the type of the button (defaults to 'button') + * - classes: the classes of the button. + * - label: the label of the button. + * - action: sets a data-edit-modal-action attribute. + * @return + * The corresponding HTML. + */ +Drupal.theme.editButtons = function(settings) { + var html = ''; + for (var i = 0; i < settings.buttons.length; i++) { + var button = settings.buttons[i]; + if (!button.hasOwnProperty('type')) { + button.type = 'button'; + } + + html += '
'; + return html; +}; + +/** + * A region to post messages that a screen reading UA will announce. + * + * @return {String} + * A string representing a DOM fragment. + */ +Drupal.theme.editMessageBox = function() { + return '
'; +}; + +/** + * Wrap message strings in p tags. + * + * @return {String} + * A string representing a DOM fragment. + */ +Drupal.theme.editMessage = function() { + var messages = Array.prototype.slice.call(arguments); + var output = ''; + for (var i = 0; i < messages.length; i++) { + output += '

' + messages[i] + '

'; + } + return output; +}; + +})(jQuery, Drupal); diff --git a/core/modules/edit/js/util.js b/core/modules/edit/js/util.js new file mode 100644 index 0000000..8ed9a2b --- /dev/null +++ b/core/modules/edit/js/util.js @@ -0,0 +1,142 @@ +/** + * @file + * Provides utility functions for Edit. + */ +(function($, Drupal, drupalSettings) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.util = Drupal.edit.util || {}; + +Drupal.edit.util.constants = {}; +Drupal.edit.util.constants.transitionEnd = "transitionEnd.edit webkitTransitionEnd.edit transitionend.edit msTransitionEnd.edit oTransitionEnd.edit"; + +Drupal.edit.util.calcPropertyID = function(entity, predicate) { + return entity.getSubjectUri() + '/' + predicate; +}; + +Drupal.edit.util.buildUrl = function(id, urlFormat) { + var parts = id.split('/'); + return Drupal.formatString(decodeURIComponent(urlFormat), { + '!entity_type': parts[0], + '!id' : parts[1], + '!field_name' : parts[2], + '!langcode' : parts[3], + '!view_mode' : parts[4] + }); +}; + +/** + * 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. + * + * 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. + * - nocssjs (required): boolean indicating whether no CSS and JS should be + * returned (necessary when the form is invisible to the user). + * @param callback + * A callback function that will receive the form to be inserted, as well as + * the ajax object, necessary if the callback wants to perform other AJAX + * commands. + */ + load: function(options, callback) { + // 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.fieldFormURL), + event: 'edit-internal.edit', + submit: { nocssjs : options.nocssjs }, + progress: { type : null } // No progress indicator. + }); + // Implement a scoped editFieldForm AJAX command: calls the callback. + Drupal.ajax[options.propertyID].commands.editFieldForm = function(ajax, response, status) { + callback(response.data, ajax); + // 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 editFieldForm AJAX command gets called. + options.$editorElement.trigger('edit-internal.edit'); + }, + + /** + * Creates a Drupal.ajax instance that is used to save a form. + * + * @param options + * An object with the following keys: + * - nocssjs (required): boolean indicating whether no CSS and JS should be + * returned (necessary when the form is invisible to the user). + * + * @return + * The key of the Drupal.ajax instance. + */ + ajaxifySaving: function(options, $submit) { + // Re-wire the form to handle submit. + var element_settings = { + url: $submit.closest('form').attr('action'), + setClick: true, + event: 'click.edit', + progress: { type:'throbber' }, + submit: { nocssjs : options.nocssjs } + }; + var base = $submit.attr('id'); + + Drupal.ajax[base] = new Drupal.ajax(base, $submit[0], element_settings); + + return base; + }, + + /** + * Cleans up the Drupal.ajax instance that is used to save the form. + * + * @param $submit + * The jQuery-wrapped submit DOM element that should be unajaxified. + */ + unajaxifySaving: function($submit) { + delete Drupal.ajax[$submit.attr('id')]; + $submit.off('click.edit'); + } +}; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/edit/js/viejs/EditService.js b/core/modules/edit/js/viejs/EditService.js new file mode 100644 index 0000000..f52a6c0 --- /dev/null +++ b/core/modules/edit/js/viejs/EditService.js @@ -0,0 +1,297 @@ +/** + * @file + * VIE DOM parsing service for Edit. + */ +(function(jQuery, _, VIE, Drupal, drupalSettings) { + +"use strict"; + + VIE.prototype.EditService = function (options) { + var defaults = { + name: 'edit', + subjectSelector: '.edit-field.edit-allowed' + }; + this.options = _.extend({}, defaults, options); + + this.views = []; + this.vie = null; + this.name = this.options.name; + }; + + VIE.prototype.EditService.prototype = { + load: function (loadable) { + var correct = loadable instanceof this.vie.Loadable; + if (!correct) { + throw new Error('Invalid Loadable passed'); + } + + var element; + if (!loadable.options.element) { + if (typeof document === 'undefined') { + return loadable.resolve([]); + } else { + element = drupalSettings.edit.context; + } + } else { + element = loadable.options.element; + } + + var entities = this.readEntities(element); + loadable.resolve(entities); + }, + + _getViewForElement:function (element, collectionView) { + var viewInstance; + + jQuery.each(this.views, function () { + if (jQuery(this.el).get(0) === element.get(0)) { + if (collectionView && !this.template) { + return true; + } + viewInstance = this; + return false; + } + }); + return viewInstance; + }, + + _registerEntityView:function (entity, element, isNew) { + if (!element.length) { + return; + } + + // Let's only have this overhead for direct types. Form-based editors are + // handled in backbone.drupalform.js and the PropertyEditor instance. + if (!jQuery(element).hasClass('edit-type-direct')) { + return; + } + + var service = this; + var viewInstance = this._getViewForElement(element); + if (viewInstance) { + return viewInstance; + } + + viewInstance = new this.vie.view.Entity({ + model:entity, + el:element, + tagName:element.get(0).nodeName, + vie:this.vie, + service:this.name + }); + + this.views.push(viewInstance); + + return viewInstance; + }, + + save: function(saveable) { + var correct = saveable instanceof this.vie.Savable; + if (!correct) { + throw "Invalid Savable passed"; + } + + if (!saveable.options.element) { + // FIXME: we could find element based on subject + throw "Unable to write entity to edit.module-markup, no element given"; + } + + if (!saveable.options.entity) { + throw "Unable to write to edit.module-markup, no entity given"; + } + + var $element = jQuery(saveable.options.element); + this._writeEntity(saveable.options.entity, saveable.options.element); + saveable.resolve(); + }, + + _writeEntity:function (entity, element) { + var service = this; + this.findPredicateElements(this.getElementSubject(element), element, true).each(function () { + var predicateElement = jQuery(this); + var predicate = service.getElementPredicate(predicateElement); + if (!entity.has(predicate)) { + return true; + } + + var value = entity.get(predicate); + if (value && value.isCollection) { + // Handled by CollectionViews separately + return true; + } + if (value === service.readElementValue(predicate, predicateElement)) { + return true; + } + // Unlike in the VIE's RdfaService no (re-)mapping needed here. + predicateElement.html(value); + }); + return true; + }, + + // The edit-id data attribute contains the full identifier of + // each entity element in the format + // `::::`. + _getID: function (element) { + var id = jQuery(element).attr('data-edit-id'); + if (!id) { + id = jQuery(element).closest('[data-edit-id]').attr('data-edit-id'); + } + return id; + }, + + // Returns the "URI" of an entity of an element in format + // `/`. + getElementSubject: function (element) { + return this._getID(element).split(':').slice(0, 2).join('/'); + }, + + // Returns the field name for an element in format + // `//`. + // (Slashes instead of colons because the field name is no namespace.) + getElementPredicate: function (element) { + if (!this._getID(element)) { + throw new Error('Could not find predicate for element'); + } + return this._getID(element).split(':').slice(2, 5).join('/'); + }, + + getElementType: function (element) { + return this._getID(element).split(':').slice(0, 1)[0]; + }, + + // Reads all editable entities (currently each Drupal field is considered an + // entity, in the future Drupal entities should be mapped to VIE entities) + // from DOM and returns the VIE enties it found. + readEntities: function (element) { + var service = this; + var entities = []; + var entityElements = jQuery(this.options.subjectSelector, element); + entityElements = entityElements.add(jQuery(element).filter(this.options.subjectSelector)); + entityElements.each(function () { + var entity = service._readEntity(jQuery(this)); + if (entity) { + entities.push(entity); + } + }); + return entities; + }, + + // Returns a filled VIE Entity instance for a DOM element. The Entity + // is also registered in the VIE entities collection. + _readEntity: function (element) { + var subject = this.getElementSubject(element); + var type = this.getElementType(element); + var entity = this._readEntityPredicates(subject, element, false); + if (jQuery.isEmptyObject(entity)) { + return null; + } + entity['@subject'] = subject; + if (type) { + entity['@type'] = this._registerType(type, element); + } + + var entityInstance = new this.vie.Entity(entity); + entityInstance = this.vie.entities.addOrUpdate(entityInstance, { + updateOptions: { + silent: true, + ignoreChanges: true + } + }); + + this._registerEntityView(entityInstance, element); + return entityInstance; + }, + + _registerType: function (typeId, element) { + typeId = ''; + var type = this.vie.types.get(typeId); + if (!type) { + this.vie.types.add(typeId, []); + type = this.vie.types.get(typeId); + } + + var predicate = this.getElementPredicate(element); + if (type.attributes.get(predicate)) { + return type; + } + + var label = element.data('edit-field-label'); + var range = 'Form'; + if (element.hasClass('edit-type-direct')) { + range = 'Direct'; + } + if (element.hasClass('edit-type-direct-with-wysiwyg')) { + range = 'Wysiwyg'; + } + type.attributes.add(predicate, [range], 0, 1, { + label: element.data('edit-field-label') + }); + + return type; + }, + + _readEntityPredicates: function (subject, element, emptyValues) { + var entityPredicates = {}; + var service = this; + this.findPredicateElements(subject, element, true).each(function () { + var predicateElement = jQuery(this); + var predicate = service.getElementPredicate(predicateElement); + if (!predicate) { + return; + } + var value = service.readElementValue(predicate, predicateElement); + if (value === null && !emptyValues) { + return; + } + + entityPredicates[predicate] = value; + entityPredicates[predicate + '/rendered'] = predicateElement[0].outerHTML; + }); + return entityPredicates; + }, + + readElementValue : function(predicate, element) { + // Unlike in RdfaService there is parsing needed here. + if (element.hasClass('edit-type-form')) { + return undefined; + } + else { + return jQuery.trim(element.html()); + } + }, + + // Subject elements are the DOM elements containing a single or multiple + // editable fields. + findSubjectElements: function (element) { + if (!element) { + element = drupalSettings.edit.context; + } + return jQuery(this.options.subjectSelector, element); + }, + + // Predicate Elements are the actual DOM elements that users will be able + // to edit. + findPredicateElements: function (subject, element, allowNestedPredicates, stop) { + var predicates = jQuery(); + // Make sure that element is wrapped by jQuery. + var $element = jQuery(element); + + // Form-type predicates + predicates = predicates.add($element.filter('.edit-type-form')); + + // Direct-type predicates + var direct = $element.filter('.edit-type-direct'); + predicates = predicates.add(direct.find('.field-item')); + + if (!predicates.length && !stop) { + var parentElement = $element.parent(this.options.subjectSelector); + if (parentElement.length) { + return this.findPredicateElements(subject, parentElement, allowNestedPredicates, true); + } + } + + return predicates; + } + }; + +})(jQuery, _, VIE, Drupal, drupalSettings); diff --git a/core/modules/edit/js/views/menu-view.js b/core/modules/edit/js/views/menu-view.js new file mode 100644 index 0000000..ac7c4e4 --- /dev/null +++ b/core/modules/edit/js/views/menu-view.js @@ -0,0 +1,82 @@ +/** + * @file + * A Backbone View that provides the app-level interactive menu. + */ +(function($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.MenuView = Backbone.View.extend({ + + events: { + 'click #toolbar-tab-edit': 'editClickHandler' + }, + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function() { + _.bindAll(this, 'stateChange'); + this.model.on('change:isViewing', this.stateChange); + // @todo + // Re-implement hook_toolbar and the corresponding JavaScript behaviors + // once https://drupal.org/node/1847198 is resolved. The toolbar tray is + // necessary when the page request is processed because its render element + // has an #attached property with the Edit module library code assigned to + // it. Currently a toolbar tab is not passed as a renderable array, so + // #attached properties are not processed. The toolbar tray DOM element is + // unnecessary right now, so it is removed. + this.$el.find('#toolbar-tray-edit').remove(); + // Respond to clicks on other toolbar tabs. This temporary pending + // improvements to the toolbar module. + $('#toolbar-administration').on('click.edit', '.bar a:not(#toolbar-tab-edit)', _.bind(function (event) { + this.model.set('isViewing', true); + }, this)); + // We have to call stateChange() here because URL fragments are not passed + // to the server, thus the wrong anchor may be marked as active. + this.stateChange(); + }, + + /** + * Listens to app state changes. + */ + stateChange: function() { + var isViewing = this.model.get('isViewing'); + // Toggle the state of the Toolbar Edit tab based on the isViewing state. + this.$el.find('#toolbar-tab-edit') + .toggleClass('active', !isViewing) + .attr('aria-pressed', !isViewing); + // Manage the toolbar state until + // https://drupal.org/node/1847198 is resolved + if (!isViewing) { + // Remove the 'toolbar-tray-open' class from the body element. + this.$el.removeClass('toolbar-tray-open'); + // Deactivate any other active tabs and trays. + this.$el + .find('.bar a', '#toolbar-administration') + .not('#toolbar-tab-edit') + .add('.tray', '#toolbar-administration') + .removeClass('active'); + // Set the height of the toolbar. + if ('toolbar' in Drupal) { + Drupal.toolbar.setHeight(); + } + } + }, + /** + * Handles clicks on the edit tab of the toolbar. + * + * @param {Object} event + */ + editClickHandler: function (event) { + var isViewing = this.model.get('isViewing'); + // Toggle the href of the Toolbar Edit tab based on the isViewing state. The + // href value should represent to state to be entered. + this.$el.find('#toolbar-tab-edit').attr('href', (isViewing) ? '#edit' : '#view'); + this.model.set('isViewing', !isViewing); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/modal-view.js b/core/modules/edit/js/views/modal-view.js new file mode 100644 index 0000000..2e3b49c --- /dev/null +++ b/core/modules/edit/js/views/modal-view.js @@ -0,0 +1,107 @@ +/** + * @file + * A Backbone View that provides an interactive modal. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.ModalView = Backbone.View.extend({ + + message: null, + buttons: null, + callback: null, + $elementsToHide: null, + + events: { + 'click button': 'onButtonClick' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - message: a message to show in the modal. + * - buttons: a set of buttons with 'action's defined, ready to be passed to + * Drupal.theme.editButtons(). + * - callback: a callback that will receive the 'action' of the clicked + * button. + * + * @see Drupal.theme.editModal() + * @see Drupal.theme.editButtons() + */ + initialize: function(options) { + this.message = options.message; + this.buttons = options.buttons; + this.callback = options.callback; + }, + + /** + * Implements Backbone Views' render() function. + */ + render: function() { + // Step 1: move certain UI elements below the overlay. + var editor = this.model.get('activeEditor'); + this.$elementsToHide = $([]) + .add((editor.element.hasClass('edit-belowoverlay')) ? null : editor.element) + .add(editor.toolbarView.$el) + .add((editor.options.editorName === 'form') ? editor.$formContainer : editor.element.next('.edit-validation-errors')); + this.$elementsToHide.addClass('edit-belowoverlay'); + + // Step 2: the modal. When the user makes a choice, the UI elements that + // were moved below the overlay will be restored, and the callback will be + // called. + this.setElement(Drupal.theme('editModal', {})); + this.$el.appendTo('body'); + // Template. + this.$('.main p').text(this.message); + var $actions = $(Drupal.theme('editButtons', { 'buttons' : this.buttons})); + this.$('.actions').append($actions); + + // Step 3; show the modal with an animation. + var that = this; + setTimeout(function() { + that.$el.removeClass('edit-animate-invisible'); + }, 0); + + Drupal.edit.setMessage(Drupal.t('Confirmation dialog open')); + }, + + /** + * When the user clicks on any of the buttons, the modal should be removed + * and the result should be passed to the callback. + * + * @param event + */ + onButtonClick: function(event) { + event.stopPropagation(); + event.preventDefault(); + + // Remove after animation. + var that = this; + this.$el + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function(e) { + that.remove(); + }); + + var action = $(event.target).attr('data-edit-modal-action'); + return this.callback(action); + }, + + /** + * Overrides Backbone Views' remove() function. + */ + remove: function() { + // Move the moved UI elements on top of the overlay again. + this.$elementsToHide.removeClass('edit-belowoverlay'); + + // Remove the modal itself. + this.$el.remove(); + } +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/overlay-view.js b/core/modules/edit/js/views/overlay-view.js new file mode 100644 index 0000000..2113ab8 --- /dev/null +++ b/core/modules/edit/js/views/overlay-view.js @@ -0,0 +1,86 @@ +/** + * @file + * A Backbone View that provides the app-level overlay. + * + * The overlay sits on top of the existing content, the properties that are + * candidates for editing sit on top of the overlay. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.OverlayView = Backbone.View.extend({ + + events: { + 'click': 'onClick' + }, + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function (options) { + _.bindAll(this, 'stateChange'); + this.model.on('change:isViewing', this.stateChange); + // Add the overlay to the page. + this.$el + .addClass('edit-animate-slow edit-animate-invisible') + .hide() + .appendTo('body'); + }, + + /** + * Listens to app state changes. + */ + stateChange: function () { + if (this.model.get('isViewing')) { + this.remove(); + return; + } + this.render(); + }, + + /** + * Equates clicks anywhere on the overlay to clicking the active editor's (if + * any) "close" button. + * + * @param {Object} event + */ + onClick: function (event) { + event.preventDefault(); + var activeEditor = this.model.get('activeEditor'); + if (activeEditor) { + var editableEntity = activeEditor.options.widget; + var predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'overlay' }); + } + else { + this.model.set('isViewing', true); + } + }, + + /** + * Reveal the overlay element. + */ + render: function () { + this.$el + .show() + .css('top', $('#navbar').outerHeight()) + .removeClass('edit-animate-invisible'); + }, + + /** + * Hide the overlay element. + */ + remove: function () { + var that = this; + this.$el + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function (event) { + that.$el.hide(); + }); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/propertyeditordecoration-view.js b/core/modules/edit/js/views/propertyeditordecoration-view.js new file mode 100644 index 0000000..269259a --- /dev/null +++ b/core/modules/edit/js/views/propertyeditordecoration-view.js @@ -0,0 +1,324 @@ +/** + * @file + * A Backbone View that decorates a Property Editor widget. + * + * It listens to state changes of the property editor. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({ + + editor: null, + entity: null, + predicate : null, + editorName: null, + toolbarId: null, + + _widthAttributeIsEmpty: null, + + events: { + 'mouseenter.edit' : 'onMouseEnter', + 'mouseleave.edit' : 'onMouseLeave', + 'tabIn.edit': 'onMouseEnter', + 'tabOut.edit': 'onMouseLeave' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - 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. + * * editorName: the editor name: 'form', 'direct' or + * 'direct-with-wysiwyg'. + * * widget: the parent EditableeEntity widget. + * - toolbarId: the ID attribute of the toolbar as rendered in the DOM. + */ + initialize: function(options) { + this.editor = options.editor; + this.toolbarId = options.toolbarId; + + this.entity = this.editor.options.entity; + this.predicate = this.editor.options.property; + this.editorName = this.editor.options.editorName; + + this.$el.css('background-color', this._getBgColor(this.$el)); + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + if (from !== null) { + this.undecorate(); + } + break; + case 'candidate': + this.decorate(); + if (from !== 'inactive') { + this.stopHighlight(); + if (from !== 'highlighted') { + this.stopEdit(this.editorName); + } + } + break; + case 'highlighted': + this.startHighlight(); + break; + case 'activating': + // NOTE: this step only exists for the 'form' editor! It is skipped by + // the 'direct' and 'direct-with-wysiwyg' editors, because no loading is + // necessary. + this.prepareEdit(this.editorName); + break; + case 'active': + if (this.editorName !== 'form') { + this.prepareEdit(this.editorName); + } + this.startEdit(this.editorName); + break; + case 'changed': + break; + case 'saving': + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Starts hover: transition to 'highlight' state. + * + * @param event + */ + onMouseEnter: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + var editableEntity = that.editor.options.widget; + editableEntity.setState('highlighted', that.predicate); + event.stopPropagation(); + }); + }, + + /** + * Stops hover: back to 'candidate' state. + * + * @param event + */ + onMouseLeave: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + var editableEntity = that.editor.options.widget; + editableEntity.setState('candidate', that.predicate, { reason: 'mouseleave' }); + event.stopPropagation(); + }); + }, + + decorate: function () { + this.$el.addClass('edit-animate-fast edit-candidate edit-editable'); + }, + + undecorate: function () { + this.$el + .removeClass('edit-candidate edit-editable edit-highlighted edit-editing edit-belowoverlay'); + }, + + startHighlight: function () { + // Animations. + var that = this; + setTimeout(function() { + that.$el.addClass('edit-highlighted'); + }, 0); + }, + + stopHighlight: function() { + this.$el + .removeClass('edit-highlighted'); + }, + + prepareEdit: function(editorName) { + this.$el.addClass('edit-editing'); + + // While editing, don't show *any* other editors. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); + + if (editorName === 'form') { + this.$el.addClass('edit-belowoverlay'); + } + }, + + startEdit: function(editorName) { + if (editorName !== 'form') { + this._pad(); + } + }, + + stopEdit: function(editorName) { + this.$el.removeClass('edit-highlighted edit-editing'); + + // Make the other editors show up again. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').addClass('edit-editable'); + + if (editorName === 'form') { + this.$el.removeClass('edit-belowoverlay'); + } + else { + this._unpad(); + } + }, + + _pad: function () { + var self = this; + + // Add 5px padding for readability. This means we'll freeze the current + // width and *then* add 5px padding, hence ensuring the padding is added "on + // the outside". + // 1) Freeze the width (if it's not already set); don't use animations. + if (this.$el[0].style.width === "") { + this._widthAttributeIsEmpty = true; + this.$el + .addClass('edit-animate-disable-width') + .css('width', this.$el.width()); + } + + // 2) Add padding; use animations. + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Pad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top - 5 + 'px', + 'left': posProp.left - 5 + 'px', + 'padding-top' : posProp['padding-top'] + 5 + 'px', + 'padding-left' : posProp['padding-left'] + 5 + 'px', + 'padding-right' : posProp['padding-right'] + 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' + }); + }, 0); + }, + + _unpad: function () { + var self = this; + + // 1) Set the empty width again. + if (this._widthAttributeIsEmpty) { + this.$el + .addClass('edit-animate-disable-width') + .css('width', ''); + } + + // 2) Remove padding; use animations (these will run simultaneously with) + // the fading out of the toolbar as its gets removed). + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Unpad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top + 5 + 'px', + 'left': posProp.left + 5 + 'px', + 'padding-top' : posProp['padding-top'] - 5 + 'px', + 'padding-left' : posProp['padding-left'] - 5 + 'px', + 'padding-right' : posProp['padding-right'] - 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' + }); + }, 0); + }, + + /** + * Gets the background color of an element (or the inherited one). + * + * @param $e + * A DOM element. + */ + _getBgColor: function($e) { + var c; + + if ($e === null || $e[0].nodeName === 'HTML') { + // Fallback to white. + return 'rgb(255, 255, 255)'; + } + c = $e.css('background-color'); + // TRICKY: edge case for Firefox' "transparent" here; this is a + // browser bug: https://bugzilla.mozilla.org/show_bug.cgi?id=635724 + if (c === 'rgba(0, 0, 0, 0)' || c === 'transparent') { + return this._getBgColor($e.parent()); + } + return c; + }, + + /** + * Gets the top and left properties of an element and convert extraneous + * values and information into numbers ready for subtraction. + * + * @param $e + * A DOM element. + */ + _getPositionProperties: function($e) { + var p, + r = {}, + props = [ + 'top', 'left', 'bottom', 'right', + 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', + 'margin-bottom' + ]; + + var propCount = props.length; + for (var i = 0; i < propCount; i++) { + p = props[i]; + r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10); + } + return r; + }, + + /** + * Replaces blank or 'auto' CSS "position: " values with "0px". + * + * @param pos + * The value for a CSS position declaration. + */ + _replaceBlankPosition: function(pos) { + if (pos === 'auto' || !pos) { + pos = '0px'; + } + return pos; + }, + + /** + * Ignores hovering to/from the given closest element, but as soon as a hover + * occurs to/from *another* element, then call the given callback. + */ + _ignoreHoveringVia: function(event, closest, callback) { + if ($(event.relatedTarget).closest(closest).length > 0) { + event.stopPropagation(); + } + else { + callback(); + } + } +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/toolbar-view.js b/core/modules/edit/js/views/toolbar-view.js new file mode 100644 index 0000000..899b9e3 --- /dev/null +++ b/core/modules/edit/js/views/toolbar-view.js @@ -0,0 +1,465 @@ +/** + * @file + * A Backbone View that provides an interactive toolbar (1 per property editor). + * + * It listens to state changes of the property editor. It also triggers state + * changes in response to user interactions with the toolbar, including saving. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; +Drupal.edit.views = Drupal.edit.views || {}; +Drupal.edit.views.ToolbarView = Backbone.View.extend({ + + editor: null, + $storageWidgetEl: null, + + entity: null, + predicate : null, + editorName: null, + + _loader: null, + _loaderVisibleStart: 0, + + _id: null, + + events: { + 'click.edit button.label': 'onClickInfoLabel', + 'mouseleave.edit': 'onMouseLeave', + 'click.edit button.field-save': 'onClickSave', + 'click.edit button.field-close': 'onClickClose' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - 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. + * * editorName: the editor name: 'form', 'direct' or + * 'direct-with-wysiwyg'. + * * element: the jQuery-wrapped editor DOM element + * - $storageWidgetEl: the DOM element on which the Create Storage widget is + * initialized. + */ + initialize: function(options) { + this.editor = options.editor; + this.$storageWidgetEl = options.$storageWidgetEl; + + this.entity = this.editor.options.entity; + this.predicate = this.editor.options.property; + this.editorName = this.editor.options.editorName; + + this._loader = null; + this._loaderVisibleStart = 0; + + // Generate a DOM-compatible ID for the toolbar DOM element. + var propertyID = Drupal.edit.util.calcPropertyID(this.entity, this.predicate); + this._id = 'edit-toolbar-for-' + propertyID.replace(/\//g, '_'); + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(from, to) { + switch (to) { + case 'inactive': + // Nothing happens in this stage. + break; + case 'candidate': + if (from !== 'inactive') { + if (from !== 'highlighted' && this.editorName !== 'form') { + this._unpad(this.editorName); + } + this.remove(); + } + break; + case 'highlighted': + // As soon as we highlight, make sure we have a toolbar in the DOM (with at least a title). + this.render(); + this.startHighlight(); + break; + case 'activating': + this.setLoadingIndicator(true); + break; + case 'active': + this.startEdit(this.editorName); + this.setLoadingIndicator(false); + if (this.editorName !== 'form') { + this._pad(this.editorName); + } + if (this.editorName === 'direct-with-wysiwyg') { + this.insertWYSIWYGToolGroups(); + } + break; + case 'changed': + this.$el + .find('button.save') + .addClass('blue-button') + .removeClass('gray-button'); + break; + case 'saving': + this.setLoadingIndicator(true); + this.save(); + break; + case 'saved': + this.setLoadingIndicator(false); + break; + case 'invalid': + this.setLoadingIndicator(false); + break; + } + }, + + /** + * Saves a property. + * + * This method deals with the complexity of the editor-dependent ways of + * inserting updated content and showing validation error messages. + * + * One might argue that this does not belong in a view. However, there is no + * actual "save" logic here, that lives in Backbone.sync. This is just some + * glue code, along with the logic for inserting updated content as well as + * showing validation error messages, the latter of which is certainly okay. + */ + save: function() { + var that = this; + var editor = this.editor; + var editableEntity = editor.options.widget; + var entity = editor.options.entity; + var predicate = editor.options.property; + + // Use Create.js' Storage widget to handle saving. (Uses Backbone.sync.) + this.$storageWidgetEl.createStorage('saveRemote', entity, { + editor: editor, + + // Successfully saved without validation errors. + success: function (model) { + editableEntity.setState('saved', predicate); + + // Now that the changes to this property have been saved, the saved + // attributes are now the "original" attributes. + entity._originalAttributes = entity._previousAttributes = _.clone(entity.attributes); + + // Get data necessary to rerender property before it is unavailable. + var updatedProperty = entity.get(predicate + '/rendered'); + var $propertyWrapper = editor.element.closest('.edit-field'); + var $context = $propertyWrapper.parent(); + + editableEntity.setState('candidate', predicate); + // Unset the property, because it will be parsed again from the DOM, iff + // its new value causes it to still be rendered. + entity.unset(predicate, { silent: true }); + entity.unset(predicate + '/rendered', { silent: true }); + // Trigger event to allow for proper clean-up of editor-specific views. + editor.element.trigger('destroyedPropertyEditor.edit', editor); + + // Replace the old content with the new content. + $propertyWrapper.replaceWith(updatedProperty); + Drupal.attachBehaviors($context); + }, + + // Save attempted but failed due to validation errors. + error: function (validationErrorMessages) { + editableEntity.setState('invalid', predicate); + + if (that.editorName === 'form') { + editor.$formContainer + .find('.edit-form') + .addClass('edit-validation-error') + .find('form') + .prepend(validationErrorMessages); + } + else { + var $errors = $('
') + .append(validationErrorMessages); + editor.element + .addClass('edit-validation-error') + .after($errors); + } + } + }); + }, + + /** + * When the user clicks the info label, nothing should happen. + * @note currently redirects the click.edit-event to the editor DOM element. + * + * @param event + */ + onClickInfoLabel: function(event) { + event.stopPropagation(); + event.preventDefault(); + // Redirects the event to the editor DOM element. + this.editor.element.trigger('click.edit'); + }, + + /** + * A mouseleave to the editor doesn't matter; a mouseleave to something else + * counts as a mouseleave on the editor itself. + * + * @param event + */ + onMouseLeave: function(event) { + var el = this.editor.element[0]; + if (event.relatedTarget != el && !$.contains(el, event.relatedTarget)) { + this.editor.element.trigger('mouseleave.edit'); + } + event.stopPropagation(); + }, + + /** + * Upon clicking "Save", trigger a custom event to save this property. + * + * @param event + */ + onClickSave: function(event) { + event.stopPropagation(); + event.preventDefault(); + this.editor.options.widget.setState('saving', this.predicate); + }, + + /** + * Upon clicking "Close", trigger a custom event to stop editing. + * + * @param event + */ + onClickClose: function(event) { + event.stopPropagation(); + event.preventDefault(); + this.editor.options.widget.setState('candidate', this.predicate, { reason: 'cancel' }); + }, + + /** + * Indicates in the 'info' toolgroup that we're waiting for a server reponse. + * + * Prevents flickering loading indicator by only showing it after 0.6 seconds + * and if it is shown, only hiding it after another 0.6 seconds. + * + * @param bool enabled + * Whether the loading indicator should be displayed or not. + */ + setLoadingIndicator: function(enabled) { + var that = this; + if (enabled) { + this._loader = setTimeout(function() { + that.addClass('info', 'loading'); + that._loaderVisibleStart = new Date().getTime(); + }, 600); + } + else { + var currentTime = new Date().getTime(); + clearTimeout(this._loader); + if (this._loaderVisibleStart) { + setTimeout(function() { + that.removeClass('info', 'loading'); + }, this._loaderVisibleStart + 600 - currentTime); + } + this._loader = null; + this._loaderVisibleStart = 0; + } + }, + + startHighlight: function() { + // We get the label to show for this property from VIE's type system. + var label = this.predicate; + var attributeDef = this.entity.get('@type').attributes.get(this.predicate); + if (attributeDef && attributeDef.metadata) { + label = attributeDef.metadata.label; + } + + this.$el + .find('.edit-toolbar') + // Append the "info" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'info edit-animate-only-background-and-padding', + buttons: [ + { label: label, classes: 'blank-button label' } + ] + })); + + // Animations. + var that = this; + setTimeout(function () { + that.show('info'); + }, 0); + }, + + startEdit: function() { + this.$el + .addClass('edit-editing') + .find('.edit-toolbar') + // Append the "ops" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'ops', + buttons: [ + { label: Drupal.t('Save'), type: 'submit', classes: 'field-save save gray-button' }, + { label: '' + Drupal.t('Close') + '', classes: 'field-close close gray-button' } + ] + })); + this.show('ops'); + }, + + /** + * Adjusts the toolbar to accomodate padding on the PropertyEditor widget. + * + * @see PropertyEditorDecorationView._pad(). + */ + _pad: function(editorName) { + // The whole toolbar must move to the top when the property's DOM element + // is displayed inline. + if (this.editor.element.css('display') === 'inline') { + this.$el.css('top', parseInt(this.$el.css('top'), 10) - 5 + 'px'); + } + + // The toolbar must move to the top and the left. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '6px', left: '-5px' }); + // When using a WYSIWYG editor, the width of the toolbar must match the + // width of the editable. + if (editorName === 'direct-with-wysiwyg') { + $hf.css({ width: this.editor.element.width() + 10 }); + } + }, + + /** + * Undoes the changes made by _pad(). + * + * @see PropertyEditorDecorationView._unpad(). + */ + _unpad: function(editorName) { + // Move the toolbar back to its original position. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '1px', left: '' }); + // When using a WYSIWYG editor, restore the width of the toolbar. + if (editorName === 'direct-with-wysiwyg') { + $hf.css({ width: '' }); + } + }, + + insertWYSIWYGToolGroups: function() { + this.$el + .find('.edit-toolbar') + .append(Drupal.theme('editToolgroup', { + classes: 'wysiwyg-tabs', + buttons: [] + })) + .append(Drupal.theme('editToolgroup', { + classes: 'wysiwyg', + buttons: [] + })); + + // Animate the toolgroups into visibility. + var that = this; + setTimeout(function () { + that.show('wysiwyg-tabs'); + that.show('wysiwyg'); + }, 0); + }, + + /** + * Renders the Toolbar's markup into the DOM. + * + * Note: depending on whether the 'display' property of the $el for which a + * toolbar is being inserted into the DOM, it will be inserted differently. + */ + render: function () { + // Render toolbar. + this.setElement($(Drupal.theme('editToolbarContainer', { + id: this.getId() + }))); + + // Insert in DOM. + if (this.$el.css('display') === 'inline') { + this.$el.prependTo(this.editor.element.offsetParent()); + var pos = this.editor.element.position(); + this.$el.css('left', pos.left).css('top', pos.top); + } + else { + this.$el.insertBefore(this.editor.element); + } + + var that = this; + // Animate the toolbar into visibility. + setTimeout(function () { + that.$el.removeClass('edit-animate-invisible'); + }, 0); + }, + + remove: function () { + if (!this.$el) { + return; + } + + // Remove after animation. + var that = this; + var $el = this.$el; + this.$el + .addClass('edit-animate-invisible') + // Prevent this toolbar from being detected *while* it is being removed. + .removeAttr('id') + .find('.edit-toolbar .edit-toolgroup') + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function (e) { + $el.remove(); + }); + }, + + /** + * Calculates the ID for this toolbar container. + * + * Only used to make sane hovering behavior possible. + * + * @return string + * A string that can be used as the ID for this toolbar container. + */ + getId: function() { + return this._id; + }, + + /** + * Shows a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this._find(toolgroup).removeClass('edit-animate-invisible'); + }, + + /** + * Adds classes to a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + addClass: function (toolgroup, classes) { + this._find(toolgroup).addClass(classes); + }, + + /** + * Removes classes from a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + removeClass: function (toolgroup, classes) { + this._find(toolgroup).removeClass(classes); + }, + + /** + * Finds a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + _find: function (toolgroup) { + return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); + } +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheck.php b/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheck.php new file mode 100644 index 0000000..82726c3 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheck.php @@ -0,0 +1,78 @@ +getRequirements()); + } + + /** + * Implements AccessCheckInterface::access(). + */ + public function access(Route $route, Request $request) { + // @todo Request argument validation and object loading should happen + // elsewhere in the request processing pipeline: + // http://drupal.org/node/1798214. + $this->validateAndUpcastRequestAttributes($request); + + return $this->accessEditEntityField($request->attributes->get('entity'), $request->attributes->get('field_name')); + } + + /** + * Implements EntityFieldAccessCheckInterface::accessEditEntityField(). + */ + public function accessEditEntityField(EntityInterface $entity, $field_name) { + $entity_type = $entity->entityType(); + // @todo Generalize to all entity types: http://drupal.org/node/1839516. + return ($entity_type == 'node' && node_access('update', $entity) && field_access('edit', $field_name, $entity_type, $entity)); + } + + /** + * Validates and upcasts request attributes. + */ + protected function validateAndUpcastRequestAttributes(Request $request) { + // Load the entity. + if (!is_object($entity = $request->attributes->get('entity'))) { + $entity_id = $entity; + $entity_type = $request->attributes->get('entity_type'); + if (!$entity_type || !entity_get_info($entity_type)) { + throw new NotFoundHttpException(); + } + $entity = entity_load($entity_type, $entity_id); + if (!$entity) { + throw new NotFoundHttpException(); + } + $request->attributes->set('entity', $entity); + } + + // Validate the field name and language. + $field_name = $request->attributes->get('field_name'); + if (!$field_name || !field_info_instance($entity->entityType(), $field_name, $entity->bundle())) { + throw new NotFoundHttpException(); + } + $langcode = $request->attributes->get('langcode'); + if (!$langcode || (field_valid_language($langcode) !== $langcode)) { + throw new NotFoundHttpException(); + } + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheckInterface.php b/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheckInterface.php new file mode 100644 index 0000000..fe6d918 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Access/EditEntityFieldAccessCheckInterface.php @@ -0,0 +1,22 @@ +command = $command; + $this->data = $data; + } + + /** + * Implements Drupal\Core\Ajax\CommandInterface:render(). + */ + public function render() { + return array( + 'command' => $this->command, + 'data' => $this->data, + ); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php new file mode 100644 index 0000000..76b01c5 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormCommand.php @@ -0,0 +1,27 @@ +register('plugin.manager.edit.processed_text_editor', 'Drupal\edit\Plugin\ProcessedTextEditorManager'); + + $container->register('access_check.edit.entity_field', 'Drupal\edit\Access\EditEntityFieldAccessCheck') + ->addTag('access_check'); + + $container->register('edit.editor.selector', 'Drupal\edit\EditorSelector') + ->addArgument(new Reference('plugin.manager.edit.processed_text_editor')); + + $container->register('edit.metadata.generator', 'Drupal\edit\MetadataGenerator') + ->addArgument(new Reference('access_check.edit.entity_field')) + ->addArgument(new Reference('edit.editor.selector')); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/EditController.php b/core/modules/edit/lib/Drupal/edit/EditController.php new file mode 100644 index 0000000..eca4201 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/EditController.php @@ -0,0 +1,146 @@ +get('edit.metadata.generator'); + + $metadata = array(); + foreach ($fields as $field) { + list($entity_type, $entity_id, $field_name, $langcode, $view_mode) = explode(':', $field); + + // Load the entity. + if (!$entity_type || !entity_get_info($entity_type)) { + throw new NotFoundHttpException(); + } + $entity = entity_load($entity_type, $entity_id); + if (!$entity) { + throw new NotFoundHttpException(); + } + + // Validate the field name and language. + if (!$field_name || !($instance = field_info_instance($entity->entityType(), $field_name, $entity->bundle()))) { + throw new NotFoundHttpException(); + } + if (!$langcode || (field_valid_language($langcode) !== $langcode)) { + throw new NotFoundHttpException(); + } + + $metadata[$field] = $metadataGenerator->generate($entity, $instance, $langcode, $view_mode); + } + + return new JsonResponse($metadata); + } + + /** + * Returns a single field edit form as an Ajax response. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being edited. + * @param string $field_name + * The name of the field that is being edited. + * @param string $langcode + * The name of the language for which the field is being edited. + * @param string $view_mode + * The view mode the field should be rerendered in. + * @return \Drupal\Core\Ajax\AjaxResponse + * The Ajax response. + */ + public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view_mode) { + $response = new AjaxResponse(); + + $form_state = array( + 'langcode' => $langcode, + 'no_redirect' => TRUE, + 'build_info' => array('args' => array($entity, $field_name)), + ); + $form = drupal_build_form('edit_field_form', $form_state); + + if (!empty($form_state['executed'])) { + // The form submission took care of saving the updated entity. Return the + // updated view of the field. + $entity = $form_state['entity']; + $output = field_view_field($entity->entityType(), $entity, $field_name, $view_mode, $langcode); + + $response->addCommand(new FieldFormSavedCommand(drupal_render($output))); + } + else { + $response->addCommand(new FieldFormCommand(drupal_render($form))); + + $errors = form_get_errors(); + if (count($errors)) { + $response->addCommand(new FieldFormValidationErrorsCommand(theme('status_messages'))); + } + } + + // When working with a hidden form, we don't want any CSS or JS to be loaded. + if (isset($_POST['nocssjs']) && $_POST['nocssjs'] === 'true') { + drupal_static_reset('drupal_add_css'); + drupal_static_reset('drupal_add_js'); + } + + 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->entityType(), $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/edit/lib/Drupal/edit/EditorSelector.php b/core/modules/edit/lib/Drupal/edit/EditorSelector.php new file mode 100644 index 0000000..5c44954 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/EditorSelector.php @@ -0,0 +1,167 @@ +processedTextEditorManager = $processed_text_editor_manager; + } + + /** + * Implements \Drupal\edit\EditorSelectorInterface::getEditor(). + */ + public function getEditor($formatter_type, FieldInstance $instance, array $items) { + // Check if the formatter defines an appropriate in-place editor. For + // example, text formatters displaying untrimmed text can choose to use the + // 'direct' editor. If the formatter doesn't specify, fall back to the + // 'form' editor, since that can work for any field. Formatter definitions + // can use 'disabled' to explicitly opt out of in-place editing. + $formatter_info = field_info_formatter_types($formatter_type); + $editor = isset($formatter_info['edit']['editor']) ? $formatter_info['edit']['editor'] : 'form'; + if ($editor == 'disabled') { + return; + } + + // The same text formatters can be used for single-valued and multivalued + // fields and for processed and unprocessed text, so we can't rely on the + // formatter definition for the final determination, because: + // - The direct editor does not work for multivalued fields. + // - Processed text can benefit from a WYSIWYG editor. + // - Empty processed text without an already selected format requires a form + // to select one. + // @todo The processed text logic is too coupled to text fields. Figure out + // how to generalize to other textual field types. + // @todo All of this might hint at formatter *definitions* not being the + // ideal place for editor specification. Moving the determination to + // something that works with instantiated formatters, not just their + // definitions, could alleviate that, but might come with its own + // challenges. + if ($editor == 'direct') { + $field = field_info_field($instance['field_name']); + if ($field['cardinality'] != 1) { + // The direct editor does not work for multivalued fields. + $editor = 'form'; + } + elseif (!empty($instance['settings']['text_processing'])) { + $format_id = $items[0]['format']; + if (isset($format_id)) { + $wysiwyg_plugin = $this->getProcessedTextEditorPlugin(); + if (isset($wysiwyg_plugin) && $wysiwyg_plugin->checkFormatCompatibility($format_id)) { + // Yay! Even though the text is processed, there's a WYSIWYG editor + // that can work with it. + $editor = 'direct-with-wysiwyg'; + } + else { + // @todo We might not have to downgrade all the way to 'form'. The + // 'direct' editor might be appropriate for some kinds of + // processed text. + $editor = 'form'; + } + } + else { + // If a format is not yet selected, a form is needed to select one. + $editor = 'form'; + } + } + } + + return $editor; + } + + /** + * Implements \Drupal\edit\EditorSelectorInterface::getAllEditorAttachments(). + */ + public function getAllEditorAttachments() { + $this->getProcessedTextEditorPlugin(); + if (!isset($this->processedTextEditorPlugin)) { + return array(); + } + + $js = array(); + + // Add library and settings for the selected processed text editor plugin. + $definition = $this->processedTextEditorPlugin->getDefinition(); + if (!empty($definition['library'])) { + $js['library'][] = array($definition['library']['module'], $definition['library']['name']); + } + $this->processedTextEditorPlugin->addJsSettings(); + + // Also add the setting to register it with Create.js + if (!empty($definition['propertyEditorName'])) { + $js['js'][] = array( + 'data' => array( + 'edit' => array( + 'wysiwygEditorWidgetName' => $definition['propertyEditorName'], + ), + ), + 'type' => 'setting' + ); + } + + return $js; + } + + /** + * Returns the plugin to use for the 'direct-with-wysiwyg' editor. + * + * @return \Drupal\edit\Plugin\ProcessedTextEditorInterface + * The editor plugin. + * + * @todo We currently only support one plugin (the first one returned by the + * manager) for the 'direct-with-wysiwyg' editor on any given page. Enhance + * this to allow different ones per element (e.g., Aloha for one text field + * and CKEditor for another one). + * + * @todo The terminology here is confusing. 'direct-with-wysiwyg' is one of + * several possible "editor"s for processed text. When using it, we need to + * integrate a particular WYSIWYG editor, which in Create.js is called a + * "PropertyEditor widget", but we're not yet including "widget" in the name + * of ProcessedTextEditorInterface to minimize confusion with Field API + * widgets. So, we're currently refering to these as "plugins", which is + * correct in that it's using Drupal's Plugin API, but less informative than + * naming it "widget" or similar. + */ + protected function getProcessedTextEditorPlugin() { + if (!isset($this->processedTextEditorPlugin)) { + $definitions = $this->processedTextEditorManager->getDefinitions(); + if (count($definitions)) { + $plugin_ids = array_keys($definitions); + $plugin_id = $plugin_ids[0]; + $this->processedTextEditorPlugin = $this->processedTextEditorManager->createInstance($plugin_id); + } + } + return $this->processedTextEditorPlugin; + } +} diff --git a/core/modules/edit/lib/Drupal/edit/EditorSelectorInterface.php b/core/modules/edit/lib/Drupal/edit/EditorSelectorInterface.php new file mode 100644 index 0000000..ed7e2c8 --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/EditorSelectorInterface.php @@ -0,0 +1,55 @@ +init($form_state, $entity, $field_name); + } + + // Add the field form. + field_attach_form($form_state['entity']->entityType(), $form_state['entity'], $form, $form_state, $form_state['langcode'], array('field_name' => $form_state['field_name'])); + + // Add a submit button. Give it a class for easy JavaScript targeting. + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + '#attributes' => array('class' => array('edit-form-submit')), + ); + + // Add validation and submission handlers. + $form['#validate'][] = array($this, 'validate'); + $form['#submit'][] = array($this, 'submit'); + + // Simplify it for optimal in-place use. + $this->simplify($form, $form_state); + + return $form; + } + + /** + * Initialize the form state and the entity before the first form build. + */ + protected function init(array &$form_state, EntityInterface $entity, $field_name) { + // @todo Rather than special-casing $node->revision, invoke prepareEdit() + // once http://drupal.org/node/1863258 lands. + if ($entity->entityType() == 'node') { + $entity->setNewRevision(in_array('revision', variable_get('node_options_' . $entity->bundle(), array()))); + $entity->log = NULL; + } + + $form_state['entity'] = $entity; + $form_state['field_name'] = $field_name; + } + + /** + * Validates the form. + */ + public function validate(array $form, array &$form_state) { + $entity = $this->buildEntity($form, $form_state); + field_attach_form_validate($entity->entityType(), $entity, $form, $form_state, array('field_name' => $form_state['field_name'])); + } + + /** + * Saves the entity with updated values for the edited field. + */ + public function submit(array $form, array &$form_state) { + $form_state['entity'] = $this->buildEntity($form, $form_state); + $form_state['entity']->save(); + } + + /** + * Returns a cloned entity containing updated field values. + * + * Calling code may then validate the returned entity, and if valid, transfer + * it back to the form state and save it. + */ + protected function buildEntity(array $form, array &$form_state) { + $entity = clone $form_state['entity']; + + // @todo field_attach_submit() only "submits" to the in-memory $entity + // object, not to anywhere persistent. Consider renaming it to minimize + // confusion: http://drupal.org/node/1846648. + field_attach_submit($entity->entityType(), $entity, $form, $form_state, array('field_name' => $form_state['field_name'])); + + // @todo Refine automated log messages and abstract them to all entity + // types: http://drupal.org/node/1678002. + if ($entity->entityType() == 'node' && $entity->isNewRevision() && !isset($entity->log)) { + $instance = field_info_instance($entity->entityType(), $form_state['field_name'], $entity->bundle()); + $entity->log = t('Updated the %field-name field through in-place editing.', array('%field-name' => $instance['label'])); + } + + return $entity; + } + + /** + * Simplifies the field edit form for in-place editing. + * + * This function: + * - Hides the field label inside the form, because JavaScript displays it + * outside the form. + * - Adjusts textarea elements to fit their content. + * + * @param array $form + * An associative array containing the structure of the form. + */ + protected function simplify(array &$form, array &$form_state) { + $field_name = $form_state['field_name']; + $langcode = $form_state['langcode']; + + $widget_element =& $form[$field_name][$langcode]; + + // Hide the field label from displaying within the form, because JavaScript + // displays the equivalent label that was provided within an HTML data + // attribute of the field's display element outside of the form. Do this for + // widgets without child elements (like Option widgets) as well as for ones + // with per-delta elements. Skip single checkboxes, because their title is + // key to their UI. Also skip widgets with multiple subelements, because in + // that case, per-element labeling is informative. + $num_children = count(element_children($widget_element)); + if ($num_children == 0 && $widget_element['#type'] != 'checkbox') { + $widget_element['#title_display'] = 'invisible'; + } + if ($num_children == 1 && isset($widget_element[0]['value'])) { + // @todo While most widgets name their primary element 'value', not all + // do, so generalize this. + $widget_element[0]['value']['#title_display'] = 'invisible'; + } + + // Adjust textarea elements to fit their content. + if (isset($widget_element[0]['value']['#type']) && $widget_element[0]['value']['#type'] == 'textarea') { + $lines = count(explode("\n", $widget_element[0]['value']['#default_value'])); + $widget_element[0]['value']['#rows'] = $lines + 1; + } + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php new file mode 100644 index 0000000..bce1e5b --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php @@ -0,0 +1,80 @@ +accessChecker = $access_checker; + $this->editorSelector = $editor_selector; + } + + /** + * Implements \Drupal\edit\MetadataGeneratorInterface::generate(). + */ + public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) { + $field_name = $instance['field_name']; + $label = $instance['label']; + $formatter_id = $instance->getFormatter($view_mode)->getPluginId(); + $items = $entity->get($field_name); + $items = $items[$langcode]; + $editor = $this->editorSelector->getEditor($formatter_id, $instance, $items); + $metadata = array( + 'label' => $label, + 'access' => $this->accessChecker->accessEditEntityField($entity, $field_name), + 'editor' => $editor, + 'aria' => t('Entity @type @id, field @field', array('@type' => $entity->entityType(), '@id' => $entity->id(), '@field' => $label)), + ); + // Additional metadata for WYSIWYG editor integration. + if ($editor === 'direct-with-wysiwyg') { + $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))); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php new file mode 100644 index 0000000..9e4fb5d --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php @@ -0,0 +1,41 @@ +discovery = new AnnotatedClassDiscovery('edit', 'processed_text_editor'); + $this->discovery = new AlterDecorator($this->discovery, 'edit_wysiwyg'); + $this->discovery = new CacheDecorator($this->discovery, 'edit:wysiwyg'); + $this->factory = new DefaultFactory($this->discovery); + } + +} diff --git a/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php b/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php new file mode 100644 index 0000000..1aca55d --- /dev/null +++ b/core/modules/edit/lib/Drupal/edit/Tests/EditorSelectionTest.php @@ -0,0 +1,240 @@ + 'In-place field editor selection', + 'description' => 'Tests in-place field editor selection.', + 'group' => 'Edit', + ); + } + + /** + * Sets the default field storage backend for fields created during tests. + */ + function setUp() { + parent::setUp(); + + $this->installSchema('system', 'variable'); + $this->enableModules(array('field', 'field_sql_storage', 'field_test')); + + // Set default storage backend. + variable_set('field_storage_default', $this->default_storage); + + // @todo Rather than using the real ProcessedTextEditorManager, which can + // find all text editor plugins in the codebase, create a mock one for + // testing that is populated with only the ones we want to test. + $text_editor_manager = new ProcessedTextEditorManager(); + + $this->editorSelector = new EditorSelector($text_editor_manager); + } + + /** + * Creates a field and an instance of it. + * + * @param string $field_name + * The field name. + * @param string $type + * The field type. + * @param int $cardinality + * The field's cardinality. + * @param string $label + * The field's label (used everywhere: widget label, formatter label). + * @param array $instance_settings + * @param string $widget_type + * The widget type. + * @param array $widget_settings + * The widget settings. + * @param string $formatter_type + * The formatter type. + * @param array $formatter_settings + * The formatter settings. + */ + function createFieldWithInstance($field_name, $type, $cardinality, $label, $instance_settings, $widget_type, $widget_settings, $formatter_type, $formatter_settings) { + $field = $field_name . '_field'; + $this->$field = array( + 'field_name' => $field_name, + 'type' => $type, + 'cardinality' => $cardinality, + ); + $this->$field_name = field_create_field($this->$field); + + $instance = $field_name . '_instance'; + $this->$instance = array( + 'field_name' => $field_name, + 'entity_type' => 'test_entity', + 'bundle' => 'test_bundle', + 'label' => $label, + 'description' => $label, + 'weight' => mt_rand(0, 127), + 'settings' => $instance_settings, + 'widget' => array( + 'type' => $widget_type, + 'label' => $label, + 'settings' => $widget_settings, + ), + 'display' => array( + 'default' => array( + 'label' => 'above', + 'type' => $formatter_type, + 'settings' => $formatter_settings + ), + ), + ); + field_create_instance($this->$instance); + } + + /** + * Retrieves the FieldInstance object for the given field and returns the + * editor that Edit selects. + */ + function getSelectedEditor($items, $field_name, $display = 'default') { + $field_instance = field_info_instance('test_entity', $field_name, 'test_bundle'); + return $this->editorSelector->getEditor($field_instance['display'][$display]['type'], $field_instance, $items); + } + + /** + * Tests a textual field, without/with text processing, with cardinality 1 and + * >1, always without a WYSIWYG editor present. + */ + function testText() { + $field_name = 'field_text'; + $this->createFieldWithInstance( + $field_name, 'text', 1, 'Simple text field', + // Instance settings. + array('text_processing' => 0), + // Widget type & settings. + 'text_textfield', + array('size' => 42), + // 'default' formatter type & settings. + 'text_default', + array() + ); + + // Pretend there is an entity with these items for the field. + $items = array(array('value' => 'Hello, world!', 'format' => 'full_html')); + + // Editor selection without text processing, with cardinality 1. + $this->assertEqual('direct', $this->getSelectedEditor($items, $field_name), "Without text processing, cardinality 1, the 'direct' editor is selected."); + + // Editor selection with text processing, cardinality 1. + $this->field_text_instance['settings']['text_processing'] = 1; + field_update_instance($this->field_text_instance); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With text processing, cardinality 1, the 'form' editor is selected."); + + // Editor selection without text processing, cardinality 1 (again). + $this->field_text_instance['settings']['text_processing'] = 0; + field_update_instance($this->field_text_instance); + $this->assertEqual('direct', $this->getSelectedEditor($items, $field_name), "Without text processing again, cardinality 1, the 'direct' editor is selected."); + + // Editor selection without text processing, cardinality >1 + $this->field_text_field['cardinality'] = 2; + field_update_field($this->field_text_field); + $items[] = array('value' => 'Hallo, wereld!', 'format' => 'full_html'); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "Without text processing, cardinality >1, the 'form' editor is selected."); + + // Editor selection with text processing, cardinality >1 + $this->field_text_instance['settings']['text_processing'] = 1; + field_update_instance($this->field_text_instance); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With text processing, cardinality >1, the 'form' editor is selected."); + } + + /** + * 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 testTextWysiwyg() { + $field_name = 'field_textarea'; + $this->createFieldWithInstance( + $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() + ); + + // ProcessedTextEditor plug-in compatible with the full_html text format. + state()->set('edit_test.compatible_format', 'full_html'); + + // Pretend there is an entity with these items for the field. + $items = array(array('value' => 'Hello, world!', 'format' => 'filtered_html')); + + // Editor selection with cardinality 1, without compatible text format. + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "Without cardinality 1, and the filtered_html text format, the 'form' editor is selected."); + + // Editor selection with cardinality 1, with compatible text format. + $items[0]['format'] = 'full_html'; + $this->assertEqual('direct-with-wysiwyg', $this->getSelectedEditor($items, $field_name), "With cardinality 1, and the full_html text format, the 'direct-with-wysiwyg' 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, $field_name), "With cardinality >1, and both items using the full_html text format, the 'form' editor is selected."); + } + + /** + * Tests a number field, with cardinality 1 and >1. + */ + function testNumber() { + $field_name = 'field_nr'; + $this->createFieldWithInstance( + $field_name, 'number_integer', 1, 'Simple number field', + // Instance settings. + array(), + // Widget type & settings. + 'number', + array(), + // 'default' formatter type & settings. + 'number_integer', + array() + ); + + // Pretend there is an entity with these items for the field. + $items = array(42, 43); + + // Editor selection with cardinality 1. + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With cardinality 1, the 'form' editor is selected."); + + // Editor selection with cardinality >1. + $this->field_nr_field['cardinality'] = 2; + field_update_field($this->field_nr_field); + $this->assertEqual('form', $this->getSelectedEditor($items, $field_name), "With cardinality >1, the 'form' editor is selected."); + } + +} diff --git a/core/modules/edit/tests/modules/edit_test.info b/core/modules/edit/tests/modules/edit_test.info new file mode 100644 index 0000000..4df4a3f --- /dev/null +++ b/core/modules/edit/tests/modules/edit_test.info @@ -0,0 +1,6 @@ +name = Edit test +description = Support module for the Edit module tests. +core = 8.x +package = Testing +version = VERSION +hidden = TRUE diff --git a/core/modules/edit/tests/modules/edit_test.module b/core/modules/edit/tests/modules/edit_test.module new file mode 100644 index 0000000..d74528d --- /dev/null +++ b/core/modules/edit/tests/modules/edit_test.module @@ -0,0 +1,6 @@ +get('edit_test.compatible_format') == $format_id; + } + +} diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php index 6b34ba9..79bdbc7 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextDefaultFormatter.php @@ -23,6 +23,9 @@ * "text", * "text_long", * "text_with_summary" + * }, + * edit = { + * "editor" = "direct" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php index 0f7b615..2695351 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextPlainFormatter.php @@ -23,6 +23,9 @@ * "text", * "text_long", * "text_with_summary" + * }, + * edit = { + * "editor" = "direct" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php index 11f0c14..b318da1 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextSummaryOrTrimmedFormatter.php @@ -22,6 +22,9 @@ * }, * settings = { * "trim_length" = "600" + * }, + * edit = { + * "editor" = "form" * } * ) */ diff --git a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php index 349cf63..05a830a 100644 --- a/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php +++ b/core/modules/field/modules/text/lib/Drupal/text/Plugin/field/formatter/TextTrimmedFormatter.php @@ -31,6 +31,9 @@ * }, * settings = { * "trim_length" = "600" + * }, + * edit = { + * "editor" = "form" * } * ) */