core/modules/edit/css/edit.css | 410 +++++++++++++++ core/modules/edit/edit.info | 6 + core/modules/edit/edit.module | 153 ++++++ 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 | 132 +++++ 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 | 36 ++ .../edit/lib/Drupal/edit/EditController.php | 151 ++++++ .../edit/lib/Drupal/edit/EditorAttacher.php | 65 +++ .../lib/Drupal/edit/EditorAttacherInterface.php | 24 + .../edit/lib/Drupal/edit/EditorSelector.php | 152 ++++++ .../lib/Drupal/edit/EditorSelectorInterface.php | 46 ++ .../edit/lib/Drupal/edit/Form/EditFieldForm.php | 142 ++++++ .../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, 4779 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..fffdfe2 --- /dev/null +++ b/core/modules/edit/edit.module @@ -0,0 +1,153 @@ + 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'), + ), + ), + ), + ); + + 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( + 'accessURL' => url('edit/access'), + '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) { + drupal_container()->get('edit.editor.attacher')->preprocessField($variables); +} + +/** + * 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..6705078 --- /dev/null +++ b/core/modules/edit/edit.routing.yml @@ -0,0 +1,22 @@ +edit_access: + pattern: '/edit/access' + defaults: + _controller: '\Drupal\edit\EditController::access' + 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%Pv s=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```00 mi IDATx^= H4ͼ!KsfQGx"LCyל(ux;z KA.Jo +E wy/2cdD@ҔLO%8F ?Q IENDB` \ 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 + + IHDR j `PLTE [ tRNS@ P00p`ϟ Dƙ IDATxe DQ8Ϩ/BDU9xV+D\?x@qWcF8wicS B}?v;Vf.V$JgX=Kضp0XS"iRw\:LL\~;Z5wu 5E)L IENDB` \ 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 + + IHDR j PLTE̻ʪ̡̜ˠ̣̽¼Ƿ ʨªZ(e +tRNSϟ `π@`0p0p`0Pϟϟ c IDATxeW0 {W +H"ʵ,y{Hpyo?mf,RBRxBvL;&LPJaRb\(Tbn(1wϔJ)ԈkS +58äT^4 P6c}[i <ާ'-+HP>K IENDB` \ 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{lQ zK ! , `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 ! , h9C 4kk\ &I&l )F-P@chH!ш4< + x@R`0C"8h0BfgX(rc}Y +1 ! , aйҚX]mKl[)"aĢ\*"$t	@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..1e8b6ab --- /dev/null +++ b/core/modules/edit/js/edit.js @@ -0,0 +1,132 @@ +/** + * @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.accessCache = Drupal.edit.accessCache || {}; + +/** + * Attach toggling behavior and in-place editing. + */ +Drupal.behaviors.edit = { + attach: function(context) { + var $context = $(context); + var $fields = $context.find('.edit-field'); + + // Initialize the Edit app. + $context.find('#toolbar-tab-edit').once('edit-init', Drupal.edit.init); + + var annotateFieldAccess = function(field) { + if (_.has(Drupal.edit.accessCache, field.editID)) { + var access = Drupal.edit.accessCache[field.editID]; + field.$el.addClass((access) ? 'edit-allowed' : 'edit-disallowed'); + return true; + } + return false; + }; + + // Find all fields in the context without access 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 access is known (typically when they were just modified) can + // be annotated immediately, those remaining must be checked on the server. + var remainingFieldsToAnnotate = _.reduce(fieldsToAnnotate, function(result, field) { + if (!annotateFieldAccess(field)) { + result.push(field); + } + return result; + }, []); + + // Make fields that could be annotated immediately available for editing. + Drupal.edit.app.findEditableProperties($context); + + if (remainingFieldsToAnnotate.length) { + $(function() { + $.ajax({ + url: drupalSettings.edit.accessURL, + type: 'POST', + data: { 'fields[]' : _.pluck(remainingFieldsToAnnotate, 'editID') }, + dataType: 'json', + success: function(results) { + // Update the access cache. + _.each(results, function(access, editID) { + Drupal.edit.accessCache[editID] = access; + }); + + // Annotate the remaining fields based on the updated access cache. + _.each(remainingFieldsToAnnotate, annotateFieldAccess); + + // 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 += '' + 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 + // `