core/modules/edit/css/edit.css | 240 +++++-------- core/modules/edit/edit.module | 5 +- core/modules/edit/edit.routing.yml | 2 +- core/modules/edit/js/edit.js | 3 +- core/modules/edit/js/editors/directEditor.js | 4 +- core/modules/edit/js/editors/formEditor.js | 16 +- core/modules/edit/js/models/EntityModel.js | 72 +++- core/modules/edit/js/theme.js | 53 ++- core/modules/edit/js/util.js | 4 +- core/modules/edit/js/views/AppView.js | 226 ++++++++++-- core/modules/edit/js/views/EditorDecorationView.js | 57 ++-- core/modules/edit/js/views/EditorView.js | 18 +- core/modules/edit/js/views/EntityToolbarView.js | 358 ++++++++++++++++++++ core/modules/edit/js/views/FieldToolbarView.js | 284 ++-------------- core/modules/edit/js/views/ModalView.js | 2 +- .../edit/lib/Drupal/edit/EditController.php | 29 +- .../edit/lib/Drupal/edit/MetadataGenerator.php | 13 +- .../lib/Drupal/edit/MetadataGeneratorInterface.php | 17 +- .../editor/js/editor.formattedTextEditor.js | 4 +- 19 files changed, 902 insertions(+), 505 deletions(-) diff --git a/core/modules/edit/css/edit.css b/core/modules/edit/css/edit.css index 6a5ac83..6a34814 100644 --- a/core/modules/edit/css/edit.css +++ b/core/modules/edit/css/edit.css @@ -6,72 +6,40 @@ } .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; + -webkit-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; + 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; + -webkit-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; + 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; + 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; + 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; + 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; -} - - - - /** * Candidate editables + editables being edited. * @@ -89,25 +57,29 @@ } .edit-field.edit-editable, .edit-field .edit-editable { - box-shadow: 0 0 1px 1px #4d9de9; + box-shadow: 0 0 1px 2px #4d9de9; } /* Highlighted (hovered) editable. */ .edit-editable.edit-highlighted { z-index: 305; - min-width: 200px; } -.edit-field.edit-editable.edit-highlighted, -.edit-form.edit-editable.edit-highlighted, -.edit-field .edit-editable.edit-highlighted { - box-shadow: 0 0 1px 1px #0199ff, 0 0 3px 3px rgba(153, 153, 153, .5); +.edit-field.edit-highlighted, +.edit-form.edit-highlighted, +.edit-field .edit-highlighted { + box-shadow: 0 0 1px 2px #0199ff, 0 0 3px 5px 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-editable.edit-highlighted.edit-validation-error { - box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5); +.edit-field.edit-changed, +.edit-form.edit-changed, +.edit-field .edit-changed { + box-shadow: 0 0 1px 2px orange, 0 0 3px 5px rgba(153, 153, 153, .5); +} +.edit-field.edit-validation-error, +.edit-form.edit-validation-error, +.edit-field .edit-validation-error { + box-shadow: 0 0 1px 2px red, 0 0 3px 5px rgba(153, 153, 153, .5); } -.edit-form.edit-editable .form-item .error { +.edit-form .form-item .error { border: 1px solid #eea0a0; } @@ -210,11 +182,25 @@ * 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, +/** + * Entity toolbar. + */ +.edit-toolbar-container { + border: 1px solid #a8a8a8; + background-color: white; + border: 1px solid #ababab; + box-shadow: 2px 2px 4px -2px black, 2px 2px 12px 0px hsla(40, 10%, 70%, 1); + max-width: 100%; + position: absolute; + -webkit-transition: all 0.5s; + transition: all 0.5s; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 40em; + z-index: 350; +} .edit-form-container { position: relative; padding: 0; @@ -223,51 +209,21 @@ 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; - display: none; +.edit-toolgroup.ops { + float: right; /* LTR */ } -.edit-highlighted .edit-toolbar-heightfaker { - display: block; +.edit-toolbar-label { + overflow: hidden; + padding: 0.333em 0.5em; } /* 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 the toolbar is instructed to be "full width". */ -.edit-toolbar-fullwidth .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; @@ -276,97 +232,77 @@ padding-right: 35px; background-position: 90% 50%; } - -/* Operations toolgroup. */ -.edit-toolgroup.ops { - float: right; /* LTR */ - margin-left: 5px; +.edit-toolbar-fullwidth { + width: 100%; } - .edit-toolgroup.wysiwyg-floated { float: right; } .edit-toolgroup.wysiwyg-main { - clear: left; + clear: both; 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; +.edit-button { + 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: 1px solid #fff; + color: #666; + cursor: pointer; + display: inline-block; + font-size: 1em; + min-height: 29px; min-width: 29px; + opacity: 1; 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; + -webkit-transition: all .1s ease; + transition: all .1s ease; } -#edit_modal button { - float: none; - display: inline-block; +.edit-button[aria-hidden="true"] { + visibility: hidden; + opacity: 0; } - /* Button with icons. */ -#edit_modal button span, -.edit-toolbar button span { - width: 22px; - height: 19px; - display: block; - float: left; +#edit_modal .action-cancel span, +.edit-toolbar .action-cancel span { + display: inline-block; + min-width: 18px; } .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 { +.edit-button.action-save { 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-button.action-saving { + background-image: -webkit-linear-gradient(top, #dddddd 0%, #c0c0c0 100%); + background-image: -moz-linear-gradient(top, #dddddd 0%, #c0c0c0 100%); + background-image: linear-gradient(top, #dddddd 0%, #c0c0c0 100%); } - -#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-button.action-saving .ajax-progress { + padding: 0px 4px 0px 6px; } - -#edit_modal button.gray-button:hover, -.edit-toolbar button.gray-button:hover, -#edit_modal button.gray-button:active, -.edit-toolbar button.gray-button:active { +.edit-button.action-saving .ajax-progress .throbber { + padding: 0 6px; +} +.edit-button:hover, +.edit-button:active { border: 1px solid #cdcdcd; box-shadow: 0 2px 1px rgba(0,0,0,0.1); } +.edit-button.action-save:hover, +.edit-button.action-save:active { + border: 1px solid #55a5d3; + box-shadow: 0 2px 1px rgba(0,0,0,0.2); +} diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index 17d9906..993a3ed 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -53,7 +53,7 @@ function edit_page_build(&$page) { 'type' => 'setting', 'data' => array('edit' => array( 'metadataURL' => url('edit/metadata'), - 'fieldFormURL' => url('edit/form/!entity_type/!id/!field_name/!langcode/!view_mode/!reset_tempstore'), + 'fieldFormURL' => url('edit/form/!entity_type/!id/!field_name/!langcode/!view_mode'), 'entitySaveURL' => url('edit/entity/!entity_type/!id/!langcode'), 'context' => 'body', )), @@ -83,6 +83,7 @@ function edit_library_info() { // Views. $path . '/js/views/AppView.js' => $options, $path . '/js/views/EditorDecorationView.js' => $options, + $path . '/js/views/EntityToolbarView.js' => $options, $path . '/js/views/ContextualLinkView.js' => $options, $path . '/js/views/ModalView.js' => $options, $path . '/js/views/FieldToolbarView.js' => $options, @@ -99,8 +100,10 @@ function edit_library_info() { array('system', 'underscore'), array('system', 'backbone'), array('system', 'jquery.form'), + array('system', 'jquery.ui.position'), array('system', 'drupal.form'), array('system', 'drupal.ajax'), + array('system', 'drupal.debounce'), array('system', 'drupalSettings'), ), ); diff --git a/core/modules/edit/edit.routing.yml b/core/modules/edit/edit.routing.yml index f962cf7..ff2d1a5 100644 --- a/core/modules/edit/edit.routing.yml +++ b/core/modules/edit/edit.routing.yml @@ -6,7 +6,7 @@ edit_metadata: _permission: 'access in-place editing' edit_field_form: - pattern: '/edit/form/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}/{reset_tempstore}' + pattern: '/edit/form/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode}' defaults: _controller: '\Drupal\edit\EditController::fieldForm' requirements: diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index 6899125..1d618a6 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -313,7 +313,8 @@ function initializeEntityContextualLink (contextualLink) { else if (hasFieldWithPermission(fieldIDs)) { var entityModel = new Drupal.edit.EntityModel({ el: contextualLink.region, - id: contextualLink.entityID + id: contextualLink.entityID, + label: Drupal.edit.metadata.get(contextualLink.entityID, 'label') }); Drupal.edit.collections.entities.add(entityModel); diff --git a/core/modules/edit/js/editors/directEditor.js b/core/modules/edit/js/editors/directEditor.js index aafbca3..5fed240 100644 --- a/core/modules/edit/js/editors/directEditor.js +++ b/core/modules/edit/js/editors/directEditor.js @@ -46,7 +46,7 @@ Drupal.edit.editors.direct = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -78,7 +78,7 @@ Drupal.edit.editors.direct = Drupal.edit.EditorView.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved': break; diff --git a/core/modules/edit/js/editors/formEditor.js b/core/modules/edit/js/editors/formEditor.js index e143840..14bc2d2 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -14,11 +14,12 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { case 'inactive': + this.removeForm(); break; case 'candidate': if (from !== 'inactive') { @@ -38,7 +39,7 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ case 'changed': break; case 'saving': - this.save(); + this.save(options); break; case 'saved': break; @@ -82,7 +83,8 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ var formOptions = { fieldID: fieldModel.id, $el: this.$el, - nocssjs: false + nocssjs: false, + reset: Drupal.edit.app.changedFieldsInTempstore.length === 0 }; Drupal.edit.util.form.load(formOptions, function (form, ajax) { Drupal.ajax.prototype.commands.insert(ajax, { @@ -128,12 +130,13 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - save: function () { + save: function (options) { var $formContainer = this.$formContainer; var $submit = $formContainer.find('.edit-form-submit'); var base = $submit.attr('id'); var editorModel = this.model; var fieldModel = this.fieldModel; + var callback = (options || {}).callback || function () {}; // Successfully saved. Drupal.ajax[base].commands.editFieldFormSaved = function (ajax, response, status) { @@ -144,7 +147,10 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ // Then, set the 'html' attribute on the field model. This will cause the // field to be rerendered. fieldModel.set('html', response.data); - }; + + // Invoke the optional callback. + callback.call(); + }; // Unsuccessfully saved; validation errors. Drupal.ajax[base].commands.editFieldFormValidationErrors = function (ajax, response, status) { diff --git a/core/modules/edit/js/models/EntityModel.js b/core/modules/edit/js/models/EntityModel.js index e484b35..294e89c 100644 --- a/core/modules/edit/js/models/EntityModel.js +++ b/core/modules/edit/js/models/EntityModel.js @@ -14,6 +14,8 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ el: null, // An entity ID, of the form "/", e.g. "node/1". id: null, + // The label of the entity. + label: null, // A Drupal.edit.FieldCollection for all fields of this entity. fields: null, @@ -22,7 +24,13 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ // Indicates whether this instance of this entity is currently being // edited in-place. - isActive: false + isActive: false, + // + isDirty: false, + // The current processing state of an entity. + state: 'inactive', + // @see AppView.appStateChange() + entityToolbar: null }, /** @@ -30,6 +38,26 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ */ initialize: function () { this.set('fields', new Drupal.edit.FieldCollection()); + + // Respond to field view changes. + this.on('viewChanged', this.viewChange, this); + + // The state of the entity is largely dependent on the state of its + // fields. + this.get('fields').on('change:state', this.fieldStateChange, this); + + // The entity keeps its own state progression. + this.on('change:state', this.stateChange, this); + }, + + /** + * + */ + fieldStateChange: function (model, state, options) { + if (state === 'changed') { + this.set('isDirty', true); + } + this.set('state', state); }, /** @@ -39,12 +67,54 @@ Drupal.edit.EntityModel = Backbone.Model.extend({ Backbone.Model.prototype.destroy.apply(this, options); // Destroy all fields of this entity. + // @todo that app should be responisble for destroying the fields. this.get('fields').each(function (fieldModel) { fieldModel.destroy(); }); }, /** + * Listens to FieldModel editor state changes. + * + * @param Drupal.edit.FieldModel model + * @param String state + * The state of an editable element. Used to determine display and behavior. + */ + stateChange: function (model, state, options) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + case 'candidate': + break; + case 'highlighted': + break; + case 'activating': + break; + case 'active': + break; + case 'changed': + break; + case 'saving': + break; + case 'saved': + break; + case 'invalid': + break; + default: + break; + } + }, + + /** + * + */ + viewChange: function (view) { + this.trigger('fieldViewChange', view); + }, + + /** * {@inheritdoc} */ sync: function () { diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js index 3e6e250..ef37ffe 100644 --- a/core/modules/edit/js/theme.js +++ b/core/modules/edit/js/theme.js @@ -40,23 +40,37 @@ Drupal.theme.editModal = function () { /** * Theme function for a toolbar container of the Edit module. * - * @param settings + * @param Object settings * An object with the following keys: * - String id: the id to apply to the toolbar container. * @return String * The corresponding HTML. */ -Drupal.theme.editToolbarContainer = function (settings) { +Drupal.theme.editEntityToolbar = function (settings) { var html = ''; - html += '
'; - html += '
'; - html += '
'; - html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + 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.editFieldToolbar = function (settings) { + return '
'; +}; + +/** * Theme function for a toolbar toolgroup of the Edit module. * * @param Object settings @@ -68,9 +82,11 @@ Drupal.theme.editToolbarContainer = function (settings) { * The corresponding HTML. */ Drupal.theme.editToolgroup = function (settings) { - var classes = 'edit-toolgroup edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; + // Classes. + var classes = (settings.classes || []); + classes.unshift('edit-toolgroup'); var html = ''; - html += '
' + (message || '') + '
'; +} + })(jQuery, Drupal); diff --git a/core/modules/edit/js/util.js b/core/modules/edit/js/util.js index ad7136ec..e8c84bc 100644 --- a/core/modules/edit/js/util.js +++ b/core/modules/edit/js/util.js @@ -46,6 +46,8 @@ Drupal.edit.util.form = { * field for which this form will be loaded. * - Boolean nocssjs: (required) boolean indicating whether no CSS and JS * should be returned (necessary when the form is invisible to the user). + * - Boolean reset: (required) boolean indicating whether the TempStore + * entity (if any) should be reset. * @param Function 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 @@ -59,7 +61,7 @@ Drupal.edit.util.form = { Drupal.ajax[fieldID] = new Drupal.ajax(fieldID, $el, { url: Drupal.edit.util.buildUrl(fieldID, drupalSettings.edit.fieldFormURL), event: 'edit-internal.edit', - submit: { nocssjs : options.nocssjs }, + submit: { nocssjs : options.nocssjs, reset : options.reset }, progress: { type : null } // No progress indicator. }); // Implement a scoped editFieldForm AJAX command: calls the callback. diff --git a/core/modules/edit/js/views/AppView.js b/core/modules/edit/js/views/AppView.js index 2e617f6..61dd2e7 100644 --- a/core/modules/edit/js/views/AppView.js +++ b/core/modules/edit/js/views/AppView.js @@ -1,4 +1,4 @@ -(function ($, _, Backbone, Drupal) { +(function ($, _, Backbone, Drupal, drupalSettings) { "use strict"; @@ -11,6 +11,10 @@ Drupal.edit.AppView = Backbone.View.extend({ activeEditorStates: [], singleEditorStates: [], + // Ephemeral storage for changed fields that persists through field + // rerendering. + changedFieldsInTempstore: [], + /** * {@inheritdoc} * @@ -53,6 +57,13 @@ Drupal.edit.AppView = Backbone.View.extend({ appStateChange: function (entityModel, isActive) { var app = this; if (isActive) { + // Create an entity toolbar. + var entityToolbar = new Drupal.edit.EntityToolbarView({ + el: entityModel.get('el'), + model: entityModel, + appModel: this.model + }); + entityModel.set('entityToolbar', entityToolbar); // Move all fields of this entity from the 'inactive' state to the // 'candidate' state. entityModel.get('fields').each(function (fieldModel) { @@ -63,6 +74,10 @@ Drupal.edit.AppView = Backbone.View.extend({ }); } else { + // Remove the entity toolbar. + var entityToolbar = entityModel.get('entityToolbar'); + entityToolbar.remove(); + entityModel.unset('entityToolbar'); // Move all fields of this entity from whatever state they are in to // the 'inactive' state. entityModel.get('fields').each(function (fieldModel) { @@ -135,15 +150,9 @@ Drupal.edit.AppView = Backbone.View.extend({ // 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 (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') { if (context && context.reason === 'mouseleave') { accept = false; } @@ -165,7 +174,9 @@ Drupal.edit.AppView = Backbone.View.extend({ // that will ask the user to confirm his choice. accept = false; // The callback will be called from the helper function. - this._confirmStopEditing(callback); + this._confirmStopEditing({ + callback: callback + }); } } } @@ -184,6 +195,11 @@ Drupal.edit.AppView = Backbone.View.extend({ * The field for which an in-place editor must be set up. */ setupEditor: function (fieldModel) { + // Get the corresponding entity toolbar. + var entityModel = fieldModel.get('entity'); + var entityToolbar = entityModel.get('entityToolbar'); + // Get the field toolbar DOM root from the entity toolbar. + var fieldToolbarRoot = entityToolbar.getToolbarRoot(); // Create in-place editor. var editorName = fieldModel.get('metadata').editor; var editorModel = new Drupal.edit.EditorModel(); @@ -196,9 +212,14 @@ Drupal.edit.AppView = Backbone.View.extend({ // Create in-place editor's toolbar — positions appropriately above the // edited element. var toolbarView = new Drupal.edit.FieldToolbarView({ + el: fieldToolbarRoot, model: fieldModel, $editedElement: $(editorView.getEditedElement()), - editorView: editorView + // @todo editorView is needed for the getEditUISetting method. Maybe we + // can factor out this dependency and put it in the metadata of the + // Drupal.edit.Metadata object for this field. + editorView: editorView, + entityModel: entityModel }); // Create decoration for edited element: padding if necessary, sets classes @@ -206,8 +227,7 @@ Drupal.edit.AppView = Backbone.View.extend({ var decorationView = new Drupal.edit.EditorDecorationView({ el: $(editorView.getEditedElement()), model: fieldModel, - editorView: editorView, - toolbarId: toolbarView.getId() + editorView: editorView }); // Track these three views in FieldModel so that we can tear them down @@ -247,27 +267,134 @@ Drupal.edit.AppView = Backbone.View.extend({ }, /** + * + */ + save: function (event, entityModel, options) { + var that = this; + // check if there's an active editor. + var activeEditor = this.model.get('activeEditor'); + + /** + * Fires an AJAX request to the REST save URL for an entity. + */ + var saveEntity = function () { + var id = 'edit-save-entity'; + // Create a temporary element to be able to use Drupal.ajax. + var $el = $(event.target); // This is the span element inside the button. + // Create a Drupal.ajax instance to load the form. + Drupal.ajax[id] = new Drupal.ajax(id, $el, { + url: drupalSettings.basePath + 'edit/entity/' + entityModel.id, + event: 'edit-save.edit', + progress: { + type: 'none' + }, + error: function (data) { + // Clean up. + $el.unbind('edit-save.edit'); + throw new Error(); + } + }); + // Entity saved successfully. + Drupal.ajax[id].commands.editEntitySaved = function(ajax, response, status) { + // Remove the changed marker from all of the fields. + entityModel.get('fields').each(function (fieldModel) { + $(fieldModel.get('el')).find('.edit-editable').addBack().removeClass('edit-changed'); + }); + // Reset the list tracking changed fields. + that.changedFieldsInTempstore = []; + // Clear the dirty flag on the entity. + var savedEntity = Drupal.edit.collections.entities.get(response.data.entity_type + '/' + response.data.entity_id); + if (savedEntity && 'set' in savedEntity) { + savedEntity.set('isDirty', false); + } + // Clean up. + $(ajax.element).unbind('edit-save.edit'); + // Invoke the provided optional callback. + if ('callback' in (options || {})) { + options.callback.call(); + } + }; + $el.trigger('edit-save.edit'); + }; + + // If an field is currently in a changed state, save it, then invoke the + // save entity function. + if (activeEditor && activeEditor.get('state') === 'changed') { + activeEditor.set({'state': 'saving'}, { + callback: saveEntity + }); + } + // Otherwise, just save the save entity. + else if (this.changedFieldsInTempstore.length) { + saveEntity(); + } + }, + + /** + * + */ + close: function (entityModel) { + var that = this; + // check if there's an active editor. + var activeEditor = this.model.get('activeEditor'); + // Sets all fields to inactive. + function cleanup () { + entityModel.set('isActive', false); + } + + if (activeEditor) { + var state = activeEditor.get('state'); + if (state === 'changed') { + this._confirmStopEditing({ + callback: cleanup + }); + } + else { + cleanup(); + } + } + else { + cleanup(); + } + }, + + + /** * Asks the user to confirm whether he wants to stop editing via a modal. * * @see acceptEditorStateChange() */ - _confirmStopEditing: function () { + _confirmStopEditing: function (options) { // Only instantiate if there isn't a modal instance visible yet. if (!this.model.get('activeModal')) { var that = this; + var activeEntity = Drupal.edit.collections.entities.where({ isActive: true })[0]; var modal = new Drupal.edit.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') } + { action: 'discard', classes: 'action-cancel edit-button', label: Drupal.t('Discard changes') }, + { action: 'save', type: 'submit', classes: 'action-save edit-button', label: Drupal.t('Save') } ], - callback: function (action) { + callback: function (event, action) { // The active modal has been removed. that.model.set('activeModal', null); - // Set the state that matches the user's action. - var targetState = (action === 'discard') ? 'candidate' : 'saving'; - that.model.get('activeEditor').set('state', 'candidate', { confirmed: true }); + // If the targetState is saving, the field must be saved, then the + // entity must be saved. + if (action === 'save') { + that.save(event, activeEntity, { + confirmed: true, + callback: (options || {}).callback || function () {} + }); + } + else { + that.model.get('activeEditor').set('state', 'candidate', { + confirmed: true + }); + if ('callback' in options) { + options.callback.call(); + } + } } }); this.model.set('activeModal', modal); @@ -292,7 +419,7 @@ Drupal.edit.AppView = Backbone.View.extend({ if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== fieldModel) { this.model.set('highlightedEditor', fieldModel); } - else if (this.model.get('highlightedEditor') === fieldModel && to === 'candidate') { + else if (this.model.get('highlightedEditor') === fieldModel && to === 'candidate' || to === 'inactive') { this.model.set('highlightedEditor', null); } @@ -310,6 +437,47 @@ Drupal.edit.AppView = Backbone.View.extend({ }, /** + * + */ + enableEditor: function (fieldModel) { + // check if there's an active editor. + var activeEditor = this.model.get('activeEditor'); + + // Do nothing if the fieldModel is already the active editor. + if (fieldModel === activeEditor) { + return; + } + if (activeEditor) { + // If there is, check if the model is changed. + if (activeEditor.get('state') === 'changed') { + // Save a reference to the changed field so it can be marked as + // as changed until the tempStore is pushed to permanent storage. + this.changedFieldsInTempstore.push(activeEditor.id); + // Attempt to save the field. + activeEditor.set({'state': 'saving'}, { + // This callback will be invoked if the activeEditor field is + // successfully saved. + callback: function () { + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + }); + } + // else, set it to a candidate. + else { + activeEditor.set('state', 'candidate'); + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + } + else { + // Set the new fieldModel to activating. + fieldModel.set('state', 'activating'); + } + }, + + + /** * Render an updated field (a field whose 'html' attribute changed). * * @param Drupal.edit.FieldModel fieldModel @@ -363,6 +531,22 @@ Drupal.edit.AppView = Backbone.View.extend({ this.setupEditor(fieldModel); fieldModel.set('state', 'candidate'); } + + // If the field change was only saved to tempstore, mark the field as + // changed. The changed marker will be cleared when the + // Drupal.edit.app.AppView.prototype.save() method is called. + for (var i = 0, fields = this.changedFieldsInTempstore; i < fields.length; i++) { + var changedFieldModel = fields[i]; + if (changedFieldModel === fieldModel.id) { + var $field = $(fieldModel.get('el')); + if ($field.is('.edit-editable')) { + $field.addClass('edit-changed'); + } + else { + $field.find('.edit-editable').addClass('edit-changed'); + } + } + } }, /** @@ -389,4 +573,4 @@ Drupal.edit.AppView = Backbone.View.extend({ } }); -}(jQuery, _, Backbone, Drupal)); +}(jQuery, _, Backbone, Drupal, drupalSettings)); diff --git a/core/modules/edit/js/views/EditorDecorationView.js b/core/modules/edit/js/views/EditorDecorationView.js index b9efdc2..680e10e 100644 --- a/core/modules/edit/js/views/EditorDecorationView.js +++ b/core/modules/edit/js/views/EditorDecorationView.js @@ -7,7 +7,6 @@ "use strict"; Drupal.edit.EditorDecorationView = Backbone.View.extend({ - toolbarId: null, _widthAttributeIsEmpty: null, @@ -25,14 +24,10 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ * @param Object options * An object with the following keys: * - Drupal.edit.EditorView editorView: the editor object view. - * - String toolbarId: the ID attribute of the toolbar as rendered in the - * DOM. */ initialize: function (options) { this.editorView = options.editorView; - this.toolbarId = options.toolbarId; - this.model.on('change:state', this.stateChange, this); }, @@ -84,6 +79,7 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this.startEdit(); break; case 'changed': + this.markChanged(true); break; case 'saving': break; @@ -101,10 +97,8 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ onMouseEnter: function (event) { var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - that.model.set('state', 'highlighted'); - event.stopPropagation(); - }); + that.model.set('state', 'highlighted'); + event.stopPropagation(); }, /** @@ -114,10 +108,8 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ onMouseLeave: function (event) { var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - that.model.set('state', 'candidate', { reason: 'mouseleave' }); - event.stopPropagation(); - }); + that.model.set('state', 'candidate', { reason: 'mouseleave' }); + event.stopPropagation(); }, /** @@ -126,7 +118,8 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ * @param jQuery event */ onClick: function (event) { - this.model.set('state', 'activating'); + var that = this; + Drupal.edit.app.enableEditor(this.model); event.preventDefault(); event.stopPropagation(); }, @@ -169,9 +162,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ */ prepareEdit: function () { this.$el.addClass('edit-editing'); - - // While editing, do not show any other editors. - $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); }, /** @@ -184,6 +174,13 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ }, /** + * + */ + markChanged: function (toggle) { + this.$el.toggleClass('edit-changed', toggle); + }, + + /** * Removes the class that indicates that an element is being edited. * * Reapplies the class that indicates that a candidate editable element is @@ -214,7 +211,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ this._widthAttributeIsEmpty = true; this.$el .addClass('edit-animate-disable-width') - .css('width', this.$el.width()) .css('background-color', this._getBgColor(this.$el)); } @@ -257,6 +253,11 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ // the fading out of the toolbar as its gets removed). var posProp = this._getPositionProperties(this.$el); setTimeout(function () { + // If the EditorDecorationView has been removed, the el will be undefined. + // Exit without taking action. + if (!self.el) { + return; + } // Re-enable width animations (padding changes affect width too!). self.$el.removeClass('edit-animate-disable-width'); @@ -332,26 +333,6 @@ Drupal.edit.EditorDecorationView = Backbone.View.extend({ pos = '0px'; } return pos; - }, - - /** - * Ignores hovering to/from the given closest element. - * - * When a hover occurs to/from another element, invoke the callback. - * - * @param jQuery event - * @param jQuery closest - * A jQuery-wrapped DOM element or compatibale jQuery input. The element - * whose mouseenter and mouseleave events should be ignored. - * @param Function callback - */ - _ignoreHoveringVia: function (event, closest, callback) { - if ($(event.relatedTarget).closest(closest).length > 0) { - event.stopPropagation(); - } - else { - callback(); - } } }); diff --git a/core/modules/edit/js/views/EditorView.js b/core/modules/edit/js/views/EditorView.js index 221765d..84336fd 100644 --- a/core/modules/edit/js/views/EditorView.js +++ b/core/modules/edit/js/views/EditorView.js @@ -86,7 +86,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ * @param String state * The state of the associated field. One of Drupal.edit.FieldModel.states. */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var from = fieldModel.previous('state'); var to = state; switch (to) { @@ -102,6 +102,11 @@ Drupal.edit.EditorView = Backbone.View.extend({ if (from === 'invalid') { this.removeValidationErrors(); } + + // Attempt to save if the field was previously in the changed state. + if (from === 'changed') { + this.model.set('state', 'saving'); + } break; case 'highlighted': // Nothing to do for the typical in-place editor: it should not be @@ -138,7 +143,7 @@ Drupal.edit.EditorView = Backbone.View.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved': // Nothing to do for the typical in-place editor. Immediately after @@ -168,9 +173,10 @@ Drupal.edit.EditorView = Backbone.View.extend({ /** * Saves the modified value in the in-place editor for this field. */ - save: function () { + save: function (options) { var fieldModel = this.fieldModel; var editorModel = this.model; + var callback = (options || {}).callback || function () {}; function fillAndSubmitForm (value) { var $form = $('#edit_backstage form'); @@ -186,7 +192,8 @@ Drupal.edit.EditorView = Backbone.View.extend({ var formOptions = { fieldID: this.fieldModel.id, $el: this.$el, - nocssjs: true + nocssjs: true, + reset: Drupal.edit.app.changedFieldsInTempstore.length === 0 }; Drupal.edit.util.form.load(formOptions, function (form, ajax) { // Create a backstage area for storing forms that are hidden from view @@ -217,6 +224,9 @@ Drupal.edit.EditorView = Backbone.View.extend({ // Then, set the 'html' attribute on the field model. This will cause // the field to be rerendered. fieldModel.set('html', response.data); + + // Invoke the optional callback. + callback.call(); }; // Unsuccessfully saved; validation errors. diff --git a/core/modules/edit/js/views/EntityToolbarView.js b/core/modules/edit/js/views/EntityToolbarView.js new file mode 100644 index 0000000..9918d97 --- /dev/null +++ b/core/modules/edit/js/views/EntityToolbarView.js @@ -0,0 +1,358 @@ +/** + * @file + * A Backbone View that provides an entity level toolbar. + */ +(function ($, Backbone, Drupal, debounce) { + +"use strict"; + +Drupal.edit.EntityToolbarView = Backbone.View.extend({ + + _loader: null, + _loaderVisibleStart: 0, + _fieldToolbarRoot: null, + _fieldLabelRoot: null, + + events: function () { + var map = { + 'click.edit button.action-save': 'onClickSave', + 'click.edit button.action-cancel': 'onClickClose', + 'mouseenter.edit': 'onMouseenter' + }; + return map; + }, + + /** + * {@inheritdoc} + */ + initialize: function (options) { + var that = this; + + this.appModel = options.appModel; + + this.model.on('change:isActive change:isDirty', this.render, this); + this.model.on('change:state', this.stateChange, this); + this.model.on('fieldViewChange', this.fieldViewChangeHandler, this); + + this.appModel.on('change:highlightedEditor', this.render, this); + this.appModel.on('change:activeEditor', this.render, this); + + $(window).on('resize.edit scroll.edit', debounce($.proxy(this.windowChangeHandler, this), 150)); + + // Set the el into its own property. Eventually the el property will be + // replaced with the rendered toolbar. + this.$entity = this.$el; + + // Set the toolbar container to this view's el property. + this.buildToolbarEl(); + this._fieldToolbarRoot = this.$el.find('.edit-toolbar-field').get(0); + + this._loader = null; + this._loaderVisibleStart = 0; + + this.render(); + }, + + /** + * {@inheritdoc} + */ + render: function (model, changeValue) { + + if (this.model.get('isActive')) { + // If the toolbar container doesn't exist, create it. + if ($('body').children('#edit-entity-toolbar').length === 0) { + $('body').append(this.$el); + } + + this.label(); + + this.show('ops'); + // If render is being called and the toolbar is already visible, just + // reposition it. + this.position(); + } + else { + this.remove(); + } + + var $save = this.$el.find('.edit-button.action-save'); + $save.attr('aria-hidden', !this.model.get('isDirty')); + // The progress spinner will only be set when the save button is clicked. + // Remove it on any call to render. + $save.find('.ajax-progress').remove(); + $save.find('span').text(Drupal.t('Save')); + $save.removeClass('action-saving'); + + return this; + }, + + /** + * + */ + windowChangeHandler: function (event) { + this.position(); + }, + + /** + * + */ + fieldViewChangeHandler: function (view) { + this.render(this, view); + }, + + /** + * Uses the jQuery.ui.position() method to position the entity toolbar. + */ + position: function (element) { + clearTimeout(this.timer); + var that = this; + // Vary the edge of the positioning according to the direction of language + // in the document. + var edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left'; + // If a field in this entity is active, position against it. + var activeEditor = Drupal.edit.app.model.get('activeEditor'); + var activeEditorView = activeEditor && activeEditor.editorView; + var activeEditedElement = activeEditorView && activeEditorView.getEditedElement(); + + // Label of a highlighted field, if it exists. + var highlightedEditor = Drupal.edit.app.model.get('highlightedEditor'); + var highlightedEditorView = highlightedEditor && highlightedEditor.editorView; + var highlightedEditedElement = highlightedEditorView && highlightedEditorView.getEditedElement(); + // Prefer the specified element from the parameters, then the acive field + // and finally the entity itself to determine the position of the toolbar. + var of = element || activeEditedElement || highlightedEditedElement || this.$entity; + // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar + // only after the user has focused on an editable for 250ms. This prevents + // the toolbar from jumping around the screen. + this.timer = setTimeout(function () { + that.$el + .position({ + my: edge + ' bottom', + at: edge + ' top', + of: of, + // Eliminate some of the placement jitteriness by flooring the suggested + // values. + using: function (suggested, info) { + info.element.element.css({ + left: Math.floor(suggested.left), + top: Math.floor(suggested.top) + }); + } + }) + .css({ + 'max-width': $(of).outerWidth(), + 'width': '100%' + }); + }, 250); + }, + + /** + * Determines the actions to take given a change of state. + * + * @param Drupal.edit.EntityModel model + * @param String state + * The state of the associated field. One of Drupal.edit.EntityModel.states. + */ + stateChange: function (model, state, options) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + case 'candidate': + break; + case 'highlighted': + break; + case 'activating': + this.setLoadingIndicator(true); + break; + case 'active': + this.setLoadingIndicator(false); + break; + case 'changed': + this.$el + .find('button.save') + .addClass('blue-button') + .removeClass('gray-button'); + break; + case 'saving': + this.setLoadingIndicator(true); + break; + case 'saved': + this.setLoadingIndicator(false); + break; + case 'invalid': + this.setLoadingIndicator(false); + break; + default: + break; + } + }, + + /** + * Set the model state to 'saving' when the save button is clicked. + * + * @param jQuery event + */ + onClickSave: function (event) { + event.stopPropagation(); + event.preventDefault(); + var $target = $(event.target); + $target = ($target.is('.action-save')) ? $target : $target.closest('.action-save'); + $target.addClass('action-saving'); + $target.find('span') + .text(Drupal.t('Saving@ellipsis', {'@ellipsis': '...'})) + .after(Drupal.theme.editThrobber()); + Drupal.edit.app.save(event, this.model); + }, + + /** + * Sets the model state to candidate when the cancel button is clicked. + * + * @param jQuery event + */ + onClickClose: function (event) { + event.stopPropagation(); + event.preventDefault(); + Drupal.edit.app.close(this.model); + }, + + /** + * + */ + onMouseenter: function (event) { + clearTimeout(this.timer); + }, + + /** + * + */ + buildToolbarEl: function () { + var $toolbar; + $toolbar = $(Drupal.theme('editEntityToolbar', { + id: 'edit-entity-toolbar' + })); + + $toolbar + .find('.edit-toolbar-entity') + // Append the "ops" toolgroup into the toolbar. + .prepend(Drupal.theme('editToolgroup', { + classes: ['ops'], + buttons: [ + { label: Drupal.t('Save'), type: 'submit', classes: 'action-save edit-button', attributes: {'aria-hidden': true}}, + { label: '' + Drupal.t('Close') + '', classes: 'action-cancel edit-button' } + ] + })); + + // Give the toolbar a sensible starting position so that it doesn't + // animiate on to the screen from a far off corner. + $toolbar + .css({ + left: this.$entity.offset().left, + top: this.$entity.offset().top + }); + + this.setElement($toolbar); + }, + + /** + * + */ + getToolbarRoot: function () { + return this._fieldToolbarRoot; + }, + + /** + * 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 Boolean 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; + } + }, + + /** + * Generates a state-dependent label for the entity toolbar. + */ + label: function () { + // The entity label. + var label = '"' + this.model.get('label') + '"'; + + // Label of an active field, if it exists. + var activeEditor = Drupal.edit.app.model.get('activeEditor'); + var activeFieldLabel = activeEditor && activeEditor.get('metadata').label; + activeFieldLabel = activeFieldLabel && activeFieldLabel + ' — ' + label; + + // Label of a highlighted field, if it exists. + var highlightedEditor = Drupal.edit.app.model.get('highlightedEditor'); + var highlightedFieldLabel = highlightedEditor && highlightedEditor.get('metadata').label; + highlightedFieldLabel = highlightedFieldLabel && highlightedFieldLabel + ' — ' + label; + + this.$el + .find('.edit-toolbar-label') + .text(activeFieldLabel || highlightedFieldLabel || label); + }, + + /** + * 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); + }, + + /** + * Shows a toolgroup. + * + * @param String toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this.$el.removeClass('edit-animate-invisible'); + } +}); + +})(jQuery, Backbone, Drupal, Drupal.debounce); diff --git a/core/modules/edit/js/views/FieldToolbarView.js b/core/modules/edit/js/views/FieldToolbarView.js index 4cdd53d..56b7c1e 100644 --- a/core/modules/edit/js/views/FieldToolbarView.js +++ b/core/modules/edit/js/views/FieldToolbarView.js @@ -14,27 +14,15 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ // A reference to the in-place editor. editorView: 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' - }, - /** * {@inheritdoc} */ initialize: function (options) { this.$editedElement = options.$editedElement; this.editorView = options.editorView; - - this._loader = null; - this._loaderVisibleStart = 0; + this.$root = this.$el; // Generate a DOM-compatible ID for the form container DOM element. this._id = 'edit-toolbar-for-' + this.model.id.replace(/\//g, '_'); @@ -46,19 +34,17 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ * {@inheritdoc} */ render: function () { - // Render toolbar. - this.setElement($(Drupal.theme('editToolbarContainer', { + // Render toolbar and set it as the view's element. + this.setElement($(Drupal.theme('editFieldToolbar', { id: this._id }))); // Insert in DOM. if (this.$editedElement.css('display') === 'inline') { - this.$el.prependTo(this.$editedElement.offsetParent()); - var pos = this.$editedElement.position(); - this.$el.css('left', pos.left).css('top', pos.top); + this.$el.prependTo(this.$field); } else { - this.$el.insertBefore(this.$editedElement); + this.$el.prependTo(this.$root); } return this; @@ -71,7 +57,7 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ * @param String state * The state of the associated field. One of Drupal.edit.FieldModel.states. */ - stateChange: function (model, state) { + stateChange: function (model, state, options) { var from = model.previous('state'); var to = state; switch (to) { @@ -81,240 +67,52 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ } break; case 'candidate': - if (from === 'inactive') { - this.render(); - } - else { - // Remove all toolgroups; they're no longer necessary. - this.$el - .removeClass('edit-highlighted edit-editing') - .find('.edit-toolbar .edit-toolgroup').remove(); - if (from !== 'highlighted' && this.editorView.getEditUISettings().padding) { - this._unpad(); - } - } break; case 'highlighted': - // As soon as we highlight, make sure we have a toolbar in the DOM (with - // at least a title). - this.startHighlight(); break; case 'activating': - this.setLoadingIndicator(true); - break; - case 'active': - this.startEdit(); - this.setLoadingIndicator(false); + this.render(); + if (this.editorView.getEditUISettings().fullWidthToolbar) { this.$el.addClass('edit-toolbar-fullwidth'); } - if (this.editorView.getEditUISettings().padding) { - this._pad(); - } if (this.editorView.getEditUISettings().unifiedToolbar) { this.insertWYSIWYGToolGroups(); } break; + case 'active': + break; case 'changed': - this.$el - .find('button.save') - .addClass('blue-button') - .removeClass('gray-button'); break; case 'saving': - this.setLoadingIndicator(true); break; case 'saved': - this.setLoadingIndicator(false); break; case 'invalid': - this.setLoadingIndicator(false); break; } }, /** - * Redirects the click.edit-event to the editor DOM element. - * - * @param jQuery event - */ - onClickInfoLabel: function (event) { - event.stopPropagation(); - event.preventDefault(); - // Redirects the event to the editor DOM element. - this.$editedElement.trigger('click.edit'); - }, - - /** - * Controls mouseleave events. - * - * A mouseleave to the editor doesn't matter; a mouseleave to something else - * counts as a mouseleave on the editor itself. - * - * @param jQuery event - */ - onMouseLeave: function (event) { - if (event.relatedTarget !== this.$editedElement[0] && !$.contains(this.$editedElement, event.relatedTarget)) { - this.$editedElement.trigger('mouseleave.edit'); - } - event.stopPropagation(); - }, - - /** - * Set the model state to 'saving' when the save button is clicked. - * - * @param jQuery event - */ - onClickSave: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.model.set('state', 'saving'); - }, - - /** - * Sets the model state to candidate when the cancel button is clicked. - * - * @param jQuery event - */ - onClickClose: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.model.set('state', 'candidate', { 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 Boolean 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; - } - }, - - /** - * Decorate the field with markup to indicate it is highlighted. - */ - startHighlight: function () { - // Retrieve the lavel to show for this field. - var label = this.model.get('metadata').label; - - this.$el - .addClass('edit-highlighted') - .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); - }, - - /** - * Decorate the field with markup to indicate edit state; append a toolbar. - */ - 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 editor. - * - * @see EditorDecorationView._pad(). - */ - _pad: function () { - // The whole toolbar must move to the top when the property's DOM element - // is displayed inline. - if (this.$editedElement.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' }); - - if (this.editorView.getEditUISettings().fullWidthToolbar) { - $hf.css({ width: this.$editedElement.width() + 10 }); - } - }, - - /** - * Undoes the changes made by _pad(). - * - * @see EditorDecorationView._unpad(). - */ - _unpad: function () { - // Move the toolbar back to its original position. - var $hf = this.$el.find('.edit-toolbar-heightfaker'); - $hf.css({ bottom: '1px', left: '' }); - - if (this.editorView.getEditUISettings().fullWidthToolbar) { - $hf.css({ width: '' }); - } - }, - - /** * Insert WYSIWYG markup into the associated toolbar. */ insertWYSIWYGToolGroups: function () { this.$el - .find('.edit-toolbar') .append(Drupal.theme('editToolgroup', { id: this.getFloatedWysiwygToolgroupId(), - classes: 'wysiwyg-floated', + classes: ['wysiwyg-floated', 'edit-animate-slow', 'edit-animate-invisible', 'edit-animate-delay-veryfast'], buttons: [] })) .append(Drupal.theme('editToolgroup', { id: this.getMainWysiwygToolgroupId(), - classes: 'wysiwyg-main', + classes: ['wysiwyg-main', 'edit-animate-slow', 'edit-animate-invisible', 'edit-animate-delay-veryfast'], buttons: [] })); // Animate the toolgroups into visibility. - var that = this; - setTimeout(function () { - that.show('wysiwyg-floated'); - that.show('wysiwyg-main'); - }, 0); + this.show('wysiwyg-floated'); + this.show('wysiwyg-main'); }, /** @@ -354,49 +152,39 @@ Drupal.edit.FieldToolbarView = Backbone.View.extend({ }, /** - * 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. - * @param String classes - * A space delimited list of class names to add to the toolgroup. - */ - addClass: function (toolgroup, classes) { - this._find(toolgroup).addClass(classes); - }, - - /** - * Removes classes from a toolgroup. + * Finds a toolgroup. * * @param String toolgroup * A toolgroup name. - * @param String classes - * A space delimited list of class names to remove from the toolgroup. + * @return jQuery */ - removeClass: function (toolgroup, classes) { - this._find(toolgroup).removeClass(classes); + _find: function (toolgroup) { + return this.$el.find('.edit-toolgroup.' + toolgroup); }, /** - * Finds a toolgroup. + * Shows a toolgroup. * * @param String toolgroup * A toolgroup name. - * @return jQuery */ - _find: function (toolgroup) { - return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); - } + show: function (toolgroup) { + var that = this; + var $group = this._find(toolgroup); + // Attach a transitionEnd event handler to the toolbar group so that update + // events can be triggered after the animations have ended. + $group.on(Drupal.edit.util.constants.transitionEnd, function (event) { + var entityModel = that.model.get('entity'); + entityModel.trigger('viewChanged', entityModel); + $group.off(Drupal.edit.util.constants.transitionEnd); + }); + // The call to remove the class and start the animation must be started in + // the next animation frame or the event handler attached above won't be + // triggered. + window.setTimeout(function () { + $group.removeClass('edit-animate-invisible'); + }, 0); + } }); })(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/ModalView.js b/core/modules/edit/js/views/ModalView.js index 9f096ba..9db2831 100644 --- a/core/modules/edit/js/views/ModalView.js +++ b/core/modules/edit/js/views/ModalView.js @@ -73,7 +73,7 @@ Drupal.edit.ModalView = Backbone.View.extend({ }); var action = $(event.target).attr('data-edit-modal-action'); - return this.callback(action); + return this.callback(event, action); } }); diff --git a/core/modules/edit/lib/Drupal/edit/EditController.php b/core/modules/edit/lib/Drupal/edit/EditController.php index 44e8456..f5e26e3 100644 --- a/core/modules/edit/lib/Drupal/edit/EditController.php +++ b/core/modules/edit/lib/Drupal/edit/EditController.php @@ -64,8 +64,11 @@ public function metadata(Request $request) { if (!$langcode || (field_valid_language($langcode) !== $langcode)) { throw new NotFoundHttpException(); } - - $metadata[$field] = $metadataGenerator->generate($entity, $instance, $langcode, $view_mode); + $entity_id = $entity->entityType() . '/' . $entity_id; + if (!isset($metadata[$entity_id])) { + $metadata[$entity_id] = $metadataGenerator->generateEntity($entity, $langcode); + } + $metadata[$field] = $metadataGenerator->generateField($entity, $instance, $langcode, $view_mode); } $response->addCommand(new MetaDataCommand($metadata)); @@ -135,19 +138,17 @@ public function metadata(Request $request) { * 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. - * @param bool $reset_tempstore - * Set to FALSE if the existing tempstore version should be kept, or TRUE - * if the existing tempstore version should be removed. * @return \Drupal\Core\Ajax\AjaxResponse * The Ajax response. */ - public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view_mode, $reset_tempstore = FALSE) { + public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view_mode) { $response = new AjaxResponse(); - // Replace entity with tempstore copy if available, init tempstore copy - // otherwise. - if (!$reset_tempstore && ($temp_entity = Drupal::service('user.tempstore')->get('edit')->get($entity->uuid))) { - $entity = $temp_entity; + // Replace entity with tempstore copy if available and not resetting, init + // tempstore copy otherwise. + $tempstore_entity = Drupal::service('user.tempstore')->get('edit')->get($entity->uuid); + if ($tempstore_entity && !(isset($_POST['reset']) && $_POST['reset'] === 'true')) { + $entity = $tempstore_entity; } else { Drupal::service('user.tempstore')->get('edit')->set($entity->uuid, $entity); @@ -224,8 +225,12 @@ public function entitySave(EntityInterface $entity) { $tempstore->get($entity->uuid)->save(); $tempstore->delete($entity->uuid); - // @todo add response that makes sense. - $output = array(); + // Return information about the entity that allows a front end application + // to identify it. + $output = array( + 'entity_type' => $entity->entityType(), + 'entity_id' => $entity->id() + ); // Respond to client that the entity was saved properly. $response = new AjaxResponse(); diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php index cecc676..5492914 100644 --- a/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php +++ b/core/modules/edit/lib/Drupal/edit/MetadataGenerator.php @@ -56,9 +56,18 @@ public function __construct(EditEntityFieldAccessCheckInterface $access_checker, } /** - * Implements \Drupal\edit\MetadataGeneratorInterface::generate(). + * {@inheritdoc} */ - public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) { + public function generateEntity(EntityInterface $entity, $langcode) { + return array( + 'label' => $entity->label($langcode), + ); + } + + /** + * {@inheritdoc} + */ + public function generateField(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode) { $field_name = $instance['field_name']; // Early-return if user does not have access. diff --git a/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php index 16db770..6d60f33 100644 --- a/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php +++ b/core/modules/edit/lib/Drupal/edit/MetadataGeneratorInterface.php @@ -11,11 +11,24 @@ use Drupal\field\Plugin\Core\Entity\FieldInstance; /** - * Interface for generating in-place editing metadata for an entity field. + * Interface for generating in-place editing metadata. */ interface MetadataGeneratorInterface { /** + * Generates in-place editing metadata for an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being edited. + * @param string $langcode + * The name of the language for which the field is being edited. + * @return array + * An array containing metadata with the following keys: + * - label: the user-visible label for the entity. + */ + public function generateEntity(EntityInterface $entity, $langcode); + + /** * Generates in-place editing metadata for an entity field. * * @param \Drupal\Core\Entity\EntityInterface $entity @@ -34,6 +47,6 @@ * - aria: the ARIA label. * - custom: (optional) any additional metadata that the editor provides. */ - public function generate(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode); + public function generateField(EntityInterface $entity, FieldInstance $instance, $langcode, $view_mode); } diff --git a/core/modules/editor/js/editor.formattedTextEditor.js b/core/modules/editor/js/editor.formattedTextEditor.js index 91e3521..0b9aa7c 100644 --- a/core/modules/editor/js/editor.formattedTextEditor.js +++ b/core/modules/editor/js/editor.formattedTextEditor.js @@ -55,7 +55,7 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ /** * {@inheritdoc} */ - stateChange: function (fieldModel, state) { + stateChange: function (fieldModel, state, options) { var editorModel = this.model; var from = fieldModel.previous('state'); var to = state; @@ -122,7 +122,7 @@ Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ if (from === 'invalid') { this.removeValidationErrors(); } - this.save(); + this.save(options); break; case 'saved':