core/modules/edit/edit.module | 3 +- core/modules/edit/js/edit.js | 78 +-- core/modules/edit/js/editors/directEditor.js | 4 +- core/modules/edit/js/editors/formEditor.js | 212 ++++---- core/modules/edit/js/models/FieldModel.js | 46 +- core/modules/edit/js/storage.js | 518 -------------------- core/modules/edit/js/views/AppView.js | 85 +++- core/modules/edit/js/views/EditorView.js | 165 +++++++ .../edit/js/views/PropertyEditorDecorationView.js | 29 +- core/modules/editor/js/editor.createjs.js | 80 +-- 10 files changed, 423 insertions(+), 797 deletions(-) diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index 63f0a96..376aa2b 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -83,10 +83,9 @@ function edit_library_info() { $path . '/js/views/ContextualLinkView.js' => $options, $path . '/js/views/ModalView.js' => $options, $path . '/js/views/ToolbarView.js' => $options, + $path . '/js/views/EditorView.js' => $options, // Backbone.sync implementation on top of Drupal forms. $path . '/js/backbone.drupalform.js' => $options, - // Storage manager. - $path . '/js/storage.js' => $options, // Other. $path . '/js/util.js' => $options, $path . '/js/theme.js' => $options, diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index 9d677a1..e73b9df 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -15,8 +15,7 @@ Drupal.edit.metadataCache = Drupal.edit.metadataCache || {}; Drupal.behaviors.edit = { views: { - contextualLinks: {}, - entities: {} + contextualLinks: {} }, collections: { @@ -44,21 +43,9 @@ Drupal.behaviors.edit = { this.collections.fields = new Drupal.edit.FieldCollection(); } - // Respond to entity model change events. - this.collections.entities - .on('change:isActive', this.enforceSingleActiveEntity, this); - - this.collections.fields - // Respond to field model HTML representation change events. - .on('change:html', this.updateAllKnownInstances, this); - // Initialize the Edit app. $('body').once('edit-init', $.proxy(this.init, this, options)); - // @todo currently must be after the call to init, because the app needs to be initialized - this.collections.fields - .on('change:state', Drupal.edit.app.editorStateChange, Drupal.edit.app); - // Find all fields in the context without metadata. var fieldsToAnnotate = _.map($fields.not('.edit-allowed, .edit-disallowed'), function(el) { var $el = $(el); @@ -104,17 +91,18 @@ Drupal.behaviors.edit = { init: function (options) { var that = this; - // Instantiate EditAppView, which is the controller of it all. EditAppModel - // instance tracks global state (viewing/editing in-place). + // Instantiate AppModel (application state) and AppView, which is the + // controller of the whole in-place editing experience. var appModel = new Drupal.edit.AppModel(); var app = new Drupal.edit.AppView({ el: $('body').get(0), model: appModel, - entitiesCollection: this.collections.entities + entitiesCollection: this.collections.entities, + fieldsCollection: this.collections.fields }); var entityModel; - // Create a view for the Entity just once. + // Create a model for the Entity just once. $('[data-edit-entity]').once('editEntity', function (index) { var $this = $(this); var id = $this.data('edit-entity'); @@ -124,13 +112,7 @@ Drupal.behaviors.edit = { }); that.collections.entities.add(entityModel); - // Create a View for the entity. - that.views.entities[id] = new Drupal.edit.EntityView($.extend({ - el: this, - model: entityModel - }, options)); - - // Create a view for the contextual links. + // Create a view for the contextual links for this entity, if any. $this.find('.contextual-links') .each(function () { // Instantiate ContextualLinkView. @@ -142,11 +124,6 @@ Drupal.behaviors.edit = { }); }); - // Add "Quick edit" links to all contextual menus where editing the full - // node is possible. - // @todo Generalize this to work for all entities. - - // 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. @@ -162,19 +139,15 @@ Drupal.behaviors.edit = { * @param $context * A jQuery-wrapped context DOM element within which will be searched. */ + // @todo move to AppView findEditableProperties: function($context) { var that = this; // Retrieve the active entity, of which there can only ever be one. var activeEntity = this.collections.entities.where({ isActive: true })[0]; - $context.find('[data-edit-id]').each(function() { + $context.find('[data-edit-id].edit-allowed').once('edit').each(function() { var $element = $(this); var editID = $element.attr('data-edit-id'); - - if (!_.has(Drupal.edit.metadataCache, editID)) { - return; - } - var entityId = $element.closest('[data-edit-entity]').data('edit-entity'); // Early-return when no surrounding [data-edit-entity] is found. @@ -211,6 +184,7 @@ Drupal.behaviors.edit = { // being edited, then transition it to the 'candidate' state. // (This happens when a field was modified and is re-rendered.) if (entity === activeEntity) { + Drupal.edit.app.decorate(field); field.set('state', 'candidate'); } }); @@ -233,38 +207,6 @@ Drupal.behaviors.edit = { return false; }, - /** - * EntityModel Collection change handler, called on change:isActive, enforces - * a single active entity. - */ - enforceSingleActiveEntity: function (changedEntityModel) { - // When an entity is deactivated, we don't need to enforce anything. - if (changedEntityModel.get('isActive') === false) { - return; - } - - // This entity was activated; deactivate all other entities. - changedEntityModel.collection.chain() - .filter(function (entityModel) { - return entityModel.get('isActive') === true && entityModel !== changedEntityModel; - }) - .each(function (entityModel) { - entityModel.set('isActive', false); - }); - }, - - // Updates all known instances of a specific entity field whenever the HTML - // representation of one of them has changed. - // @todo: this is currently prototype-level code, test this. The principle is - // sound, but the tricky thing is that an editID includes the view mode, but - // we actually want to update the same field in other view modes too, which - // means this method will have to check if there are any such instances, and - // if so, go to the server and re-render those too. - updateAllKnownInstances: function (changedModel) { - changedModel.collection.where({ editID: changedModel.get('editID') }) - .set('html', changedModel.get('html')); - }, - defaults: { strings: { quickEdit: Drupal.t('Quick edit'), diff --git a/core/modules/edit/js/editors/directEditor.js b/core/modules/edit/js/editors/directEditor.js index 4864a4b..5bc496a 100644 --- a/core/modules/edit/js/editors/directEditor.js +++ b/core/modules/edit/js/editors/directEditor.js @@ -12,14 +12,14 @@ Drupal.edit.editors = Drupal.edit.editors || {}; Drupal.edit.editors.direct = Backbone.View.extend({ /** - * Implements getEditUISettings() method. + * {@inheritdoc} */ getEditUISettings: function () { return { padding: true, unifiedToolbar: false, fullWidthToolbar: false }; }, /** - * Implements Backbone.View.prototype.initialize(). + * {@inheritdoc} */ initialize: function () { var that = this; diff --git a/core/modules/edit/js/editors/formEditor.js b/core/modules/edit/js/editors/formEditor.js index 9625230..b356135 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -1,6 +1,6 @@ /** * @file - * Form-based Create.js widget for structured content in Drupal. + * Form-based in-place editor. Works for any field type. */ (function ($, Drupal) { @@ -9,34 +9,57 @@ Drupal.edit = Drupal.edit || {}; Drupal.edit.editors = Drupal.edit.editors || {}; -Drupal.edit.editors.form = Backbone.View.extend({ - id: null, - $formContainer: null, +Drupal.edit.editors.form = Drupal.edit.EditorView.extend({ - /** - * Implements getEditUISettings() method. - */ - getEditUISettings: function () { - return { padding: false, unifiedToolbar: false, fullWidthToolbar: false }; - }, + $formContainer: null, /** - * Implements Backbone.View.prototype.initialize(). + * {@inheritdoc} */ - initialize: function (options) { - this.model.on('change:state', this.stateChange, this); - - // Generate a DOM-compatible ID for the form container DOM element. - this.elementId = 'edit-form-for-' + this.model.get('editID').replace(/\//g, '_'); + stateChange: function (model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + break; + case 'candidate': + if (from !== 'inactive') { + this.disable(); + } + if (from === 'invalid') { + // No need to call removeValidationErrors() for this in-place editor! + } + break; + case 'highlighted': + break; + case 'activating': + this.enable(); + break; + case 'active': + break; + case 'changed': + break; + case 'saving': + this.save(); + break; + case 'saved': + break; + case 'invalid': + this.showValidationErrors(); + break; + } }, /** * Enables the widget. */ enable: function () { + // Generate a DOM-compatible ID for the form container DOM element. + var id = 'edit-form-for-' + this.model.get('editID').replace(/\//g, '_'); + // Render form container. this.$formContainer = $(Drupal.theme('editFormContainer', { - id: this.elementId, + id: id, loadingMsg: Drupal.t('Loading…')} )); this.$formContainer @@ -65,14 +88,13 @@ Drupal.edit.editors.form = Backbone.View.extend({ Drupal.edit.util.form.load(formOptions, function(form, ajax) { Drupal.ajax.prototype.commands.insert(ajax, { data: form, - selector: '#' + that.elementId + ' .placeholder' + selector: '#' + id + ' .placeholder' }); var $submit = that.$formContainer.find('.edit-form-submit'); Drupal.edit.util.form.ajaxifySaving(formOptions, $submit); that.$formContainer .on('formUpdated.edit', ':input', function () { - // Sets the state to 'changed'. that.model.set('state', 'changed'); }) .on('keypress.edit', 'input', function (event) { @@ -105,115 +127,63 @@ Drupal.edit.editors.form = Backbone.View.extend({ }, /** - * Saves a property. - * - * This method deals with the complexity of the editor-dependent ways of - * inserting updated content and showing validation error messages. - * - * One might argue that this does not belong in a view. However, there is no - * actual "save" logic here, that lives in Backbone.sync. This is just some - * glue code, along with the logic for inserting updated content as well as - * showing validation error messages, the latter of which is certainly okay. + * {@inheritdoc} */ save: function () { + var $formContainer = this.model.attributes.editorView.$formContainer; + var $submit = $formContainer.find('.edit-form-submit'); + var base = $submit.attr('id'); var that = this; - var editor = this; - var editableEntity = this.el; - var entity = this.entity; - var predicate = this.predicate; - - // Use Create.js' Storage widget to handle saving. (Uses Backbone.sync.) - var storage = new Drupal.edit.Storage({ - vie: that.vie, - editableNs: 'createeditable', - element: this.$el - }); - storage.invoke('saveRemote', entity, { - editor: that, - - // Successfully saved without validation errors. - success: function (model) { - //editableEntity.setState('saved', predicate); - - // Now that the changes to this property have been saved, the saved - // attributes are now the "original" attributes. - entity._originalAttributes = entity._previousAttributes = _.clone(entity.attributes); - - // Get data necessary to rerender property before it is unavailable. - var updatedProperty = entity.get(predicate + '/rendered'); - var $propertyWrapper = editor.$el.closest('.edit-field'); - var $context = $propertyWrapper.parent(); - - //editableEntity.setState('candidate', predicate); - // Unset the property, because it will be parsed again from the DOM, iff - // its new value causes it to still be rendered. - entity.unset(predicate, { silent: true }); - entity.unset(predicate + '/rendered', { silent: true }); - // Trigger event to allow for proper clean-up of editor-specific views. - editor.$el.trigger('destroyedPropertyEditor.edit', editor); - - // Replace the old content with the new content. - $propertyWrapper.replaceWith(updatedProperty); - Drupal.attachBehaviors($context); - }, - - // Save attempted but failed due to validation errors. - error: function (validationErrorMessages) { - editableEntity.setState('invalid', predicate); - - if (that.editorName === 'form') { - editor.$formContainer - .find('.edit-form') - .addClass('edit-validation-error') - .find('form') - .prepend(validationErrorMessages); - } - else { - var $errors = $('
') - .append(validationErrorMessages); - editor.element - .addClass('edit-validation-error') - .after($errors); - } - } - }); + + // Successfully saved. + Drupal.ajax[base].commands.editFieldFormSaved = function(ajax, response, status) { + Drupal.edit.util.form.unajaxifySaving(jQuery(ajax.element)); + + // First, transition the state to 'saved'. + that.model.set('state', 'saved'); + // Then, set the 'html' attribute on the field model. This will cause the + // field to be rerendered. + that.model.set('html', response.data); + }; + + // Unsuccessfully saved; validation errors. + Drupal.ajax[base].commands.editFieldFormValidationErrors = function(ajax, response, status) { + that.model.set('validationErrors', response.data); + that.model.set('state', 'invalid'); + }; + + // 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 Drupal.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'); }, /** - * Determines the actions to take given a change of state. - * - * @param Drupal.edit.FieldModel model - * @param String state - * The state of the associated field. One of Drupal.edit.FieldModel.states. + * {@inheritdoc} */ - stateChange: function (model, state) { - var from = model.previous('state'); - var to = state; - 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': - this.save(); - break; - case 'saved': - break; - case 'invalid': - break; - } + showValidationErrors: function () { + this.$formContainer + .find('.edit-form') + .addClass('edit-validation-error') + .find('form') + .prepend(this.model.get('validationErrors')); } }); diff --git a/core/modules/edit/js/models/FieldModel.js b/core/modules/edit/js/models/FieldModel.js index 7703d2f..0181b9b 100644 --- a/core/modules/edit/js/models/FieldModel.js +++ b/core/modules/edit/js/models/FieldModel.js @@ -14,24 +14,11 @@ $.extend(Drupal.edit, { // @todo Try to get rid of this, but it's hard; see appStateChange() $el: null, - // Possible states: - // - inactive - // - candidate - // - highlighted - // - activating - // - active - // - changed - // - saving - // - saved - // - saved - // - invalid - // @see http://createjs.org/guide/#states + // Possible states: @see Drupal.edit.FieldModel.states. state: 'inactive', // A Drupal.edit.EntityModel. Its "fields" attribute, which is a // FieldCollection, is automatically updated to include this FieldModel. entity: null, - // A place to store any decoration views. - decorationViews: {}, // @@ -53,13 +40,34 @@ $.extend(Drupal.edit, { // this field instance to other instances of the same field. html: null, + // Not the full HTML representation of this field, but the "actual" + // original value of the field, stored by the used in-place editor, and + // in a representation that can be chosen by the in-place editor. + originalValue: null, + // Analogous to originalValue, but the current value. + currentValue: null, + // Optional, editor-specific custom value to store for this field. + editorSpecific: null, + + validationErrors: null, + // // Callbacks. // // Callback function for validating changes between states. Receives the // previous state, new state, context, and a callback - acceptStateChange: null + acceptStateChange: null, + + + // + // Attached views. + // + + editorView: null, + toolbarView: null, + decorationView: null + }, initialize: function () { this.get('entity').get('fields').add(this); @@ -70,6 +78,12 @@ $.extend(Drupal.edit, { console.log('%c' + model.get("editID") + " " + error, 'color: orange'); }); }, + destroy: function(options) { + if (this.get('state') !== 'inactive') { + throw new Error("FieldModel cannot be destroyed if it is not inactive state."); + } + Backbone.Model.prototype.destroy.apply(this, options); + }, validate: function (attrs, options) { // We only care about validating the 'state' attribute. if (!_.has(attrs, 'state')) { @@ -89,12 +103,10 @@ $.extend(Drupal.edit, { } } }, - // @see VIE.prototype.EditService.prototype.getElementSubject() // Parses the `/` part from the edit ID. getEntityID: function() { return this.get('editID').split('/').slice(0, 2).join('/'); }, - // @see VIE.prototype.EditService.prototype.getElementPredicate() // Parses the `//` part from the edit ID. getFieldID: function() { return this.get('editID').split('/').slice(2, 5).join('/'); diff --git a/core/modules/edit/js/storage.js b/core/modules/edit/js/storage.js deleted file mode 100644 index 196337e..0000000 --- a/core/modules/edit/js/storage.js +++ /dev/null @@ -1,518 +0,0 @@ -/** - * @file - * Subclasses jQuery.Midgard.midgardStorage to have consistent namespaces. - */ -(function($, Drupal) { - -"use strict"; - -Drupal.edit = Drupal.edit || {}; - -Drupal.edit.Storage = function (options) { - $.extend(this, { - saveEnabled: true, - // VIE instance to use for storage handling - vie: null, - // Whether to use localstorage - localStorage: false, - removeLocalstorageOnIgnore: true, - // URL callback for Backbone.sync - url: '', - // Whether to enable automatic saving - autoSave: false, - // How often to autosave in milliseconds - autoSaveInterval: 5000, - // Whether to save entities that are referenced by entities - // we're saving to the server. - saveReferencedNew: false, - saveReferencedChanged: false, - // Namespace used for events from midgardEditable-derived widget - editableNs: 'midgardeditable', - // CSS selector for the Edit button, leave to null to not bind - // notifications to any element - editSelector: '#midgardcreate-edit a', - localize: function (id, language) { - return window.midgardCreate.localize(id, language); - }, - language: null - }, options); - this._create(); -}; - -$.extend(Drupal.edit.Storage.prototype, { - - invoke: function(method) { - var args = Array.prototype.slice.call(arguments, 1); - if (method in this && typeof this[method] === 'function') { - return this[method].apply(this, args); - } - }, - _create: function () { - var widget = this; - this.changedModels = []; - - if (window.localStorage) { - this.localStorage = true; - } - - this.vie.entities.on('add', function (model) { - // Add the back-end URL used by Backbone.sync - model.url = widget.url; - model.toJSON = model.toJSONLD; - }); - - widget._bindEditables(); - if (widget.autoSave) { - widget._autoSave(); - } - }, - - _autoSave: function () { - var widget = this; - widget.saveEnabled = true; - - var doAutoSave = function () { - if (!widget.saveEnabled) { - return; - } - - if (widget.changedModels.length === 0) { - return; - } - - widget.saveRemoteAll({ - // We make autosaves silent so that potential changes from server - // don't disrupt user while writing. - silent: true - }); - }; - - var timeout = window.setInterval(doAutoSave, widget.options.autoSaveInterval); - - this.element.on('startPreventSave', function () { - if (timeout) { - window.clearInterval(timeout); - timeout = null; - } - widget.disableAutoSave(); - }); - this.element.on('stopPreventSave', function () { - if (!timeout) { - timeout = window.setInterval(doAutoSave, widget.options.autoSaveInterval); - } - widget.enableAutoSave(); - }); - - }, - - enableAutoSave: function () { - this.saveEnabled = true; - }, - - disableAutoSave: function () { - this.saveEnabled = false; - }, - - _bindEditables: function () { - var widget = this; - this.restorables = []; - var restorer; - - widget.element.on(widget.editableNs + 'changed', function (event, options) { - if (_.indexOf(widget.changedModels, options.instance) === -1) { - widget.changedModels.push(options.instance); - } - widget._saveLocal(options.instance); - }); - - widget.element.on(widget.editableNs + 'disable', function (event, options) { - widget.revertChanges(options.instance); - }); - - widget.element.on(widget.editableNs + 'enable', function (event, options) { - if (!options.instance._originalAttributes) { - options.instance._originalAttributes = _.clone(options.instance.attributes); - } - - if (!options.instance.isNew() && widget._checkLocal(options.instance)) { - // We have locally-stored modifications, user needs to be asked - widget.restorables.push(options.instance); - } - - /*_.each(options.instance.attributes, function (attributeValue, property) { - if (attributeValue instanceof widget.vie.Collection) { - widget._readLocalReferences(options.instance, property, attributeValue); - } - });*/ - }); - - widget.element.on('midgardcreatestatechange', function (event, options) { - if (options.state === 'browse' || widget.restorables.length === 0) { - widget.restorables = []; - if (restorer) { - restorer.close(); - } - return; - } - restorer = widget.checkRestore(); - }); - - widget.element.on('midgardstorageloaded', function (event, options) { - if (_.indexOf(widget.changedModels, options.instance) === -1) { - widget.changedModels.push(options.instance); - } - }); - }, - - checkRestore: function () { - var widget = this; - if (widget.restorables.length === 0) { - return; - } - - var message; - var restorer; - if (widget.restorables.length === 1) { - message = _.template(widget.options.localize('localModification', widget.options.language), { - label: widget.restorables[0].getSubjectUri() - }); - } else { - message = _.template(widget.options.localize('localModifications', widget.options.language), { - number: widget.restorables.length - }); - } - - var doRestore = function (event, notification) { - widget.restoreLocalAll(); - restorer.close(); - }; - - var doIgnore = function (event, notification) { - widget.ignoreLocal(); - restorer.close(); - }; - - restorer = jQuery('body').midgardNotifications('create', { - bindTo: widget.options.editSelector, - gravity: 'TR', - body: message, - timeout: 0, - actions: [ - { - name: 'restore', - label: widget.options.localize('Restore', widget.options.language), - cb: doRestore, - className: 'create-ui-btn' - }, - { - name: 'ignore', - label: widget.options.localize('Ignore', widget.options.language), - cb: doIgnore, - className: 'create-ui-btn' - } - ], - callbacks: { - beforeShow: function () { - if (!window.Mousetrap) { - return; - } - window.Mousetrap.bind(['command+shift+r', 'ctrl+shift+r'], function (event) { - event.preventDefault(); - doRestore(); - }); - window.Mousetrap.bind(['command+shift+i', 'ctrl+shift+i'], function (event) { - event.preventDefault(); - doIgnore(); - }); - }, - afterClose: function () { - if (!window.Mousetrap) { - return; - } - window.Mousetrap.unbind(['command+shift+r', 'ctrl+shift+r']); - window.Mousetrap.unbind(['command+shift+i', 'ctrl+shift+i']); - } - } - }); - return restorer; - }, - - restoreLocalAll: function () { - _.each(this.restorables, function (instance) { - this.readLocal(instance); - }, this); - this.restorables = []; - }, - - ignoreLocal: function () { - if (this.options.removeLocalstorageOnIgnore) { - _.each(this.restorables, function (instance) { - this._removeLocal(instance); - }, this); - } - this.restorables = []; - }, - - saveReferences: function (model) { - _.each(model.attributes, function (value, property) { - if (!value || !value.isCollection) { - return; - } - - value.each(function (referencedModel) { - if (this.changedModels.indexOf(referencedModel) !== -1) { - // The referenced model is already in the save queue - return; - } - - if (referencedModel.isNew() && this.options.saveReferencedNew) { - return referencedModel.save(); - } - - if (referencedModel.hasChanged() && this.options.saveReferencedChanged) { - return referencedModel.save(); - } - }, this); - }, this); - }, - - saveRemote: function (model, options) { - // Optionally handle entities referenced in this model first - this.saveReferences(model); - - // this._trigger('saveentity', null, { - // entity: model, - // options: options - // }); - - var widget = this; - model.save(null, _.extend({}, options, { - success: function (m, response) { - // From now on we're going with the values we have on server - model._originalAttributes = _.clone(model.attributes); - widget._removeLocal(model); - window.setTimeout(function () { - // Remove the model from the list of changed models after saving - widget.changedModels.splice(widget.changedModels.indexOf(model), 1); - }, 0); - if (_.isFunction(options.success)) { - options.success(m, response); - } - // widget._trigger('savedentity', null, { - // entity: model, - // options: options - // }); - }, - error: function (m, response) { - if (_.isFunction(options.error)) { - options.error(m, response); - } - } - })); - }, - - saveRemoteAll: function (options) { - var widget = this; - if (widget.changedModels.length === 0) { - return; - } - - widget._trigger('save', null, { - entities: widget.changedModels, - options: options, - // Deprecated - models: widget.changedModels - }); - - var notification_msg; - var needed = widget.changedModels.length; - if (needed > 1) { - notification_msg = _.template(widget.options.localize('saveSuccessMultiple', widget.options.language), { - number: needed - }); - } else { - notification_msg = _.template(widget.options.localize('saveSuccess', widget.options.language), { - label: widget.changedModels[0].getSubjectUri() - }); - } - - widget.disableAutoSave(); - _.each(widget.changedModels, function (model) { - this.saveRemote(model, { - success: function (m, response) { - needed--; - if (needed <= 0) { - // All models were happily saved - widget._trigger('saved', null, { - options: options - }); - if (options && _.isFunction(options.success)) { - options.success(m, response); - } - jQuery('body').midgardNotifications('create', { - body: notification_msg - }); - widget.enableAutoSave(); - } - }, - error: function (m, err) { - if (options && _.isFunction(options.error)) { - options.error(m, err); - } - jQuery('body').midgardNotifications('create', { - body: _.template(widget.options.localize('saveError', widget.options.language), { - error: err.responseText || '' - }), - timeout: 0 - }); - - widget._trigger('error', null, { - instance: model - }); - } - }); - }, this); - }, - - _saveLocal: function (model) { - if (!this.options.localStorage) { - return; - } - - if (model.isNew()) { - // Anonymous object, save as refs instead - if (!model.primaryCollection) { - return; - } - return this._saveLocalReferences(model.primaryCollection.subject, model.primaryCollection.predicate, model); - } - window.localStorage.setItem(model.getSubjectUri(), JSON.stringify(model.toJSONLD())); - }, - - _getReferenceId: function (model, property) { - return model.id + ':' + property; - }, - - _saveLocalReferences: function (subject, predicate, model) { - if (!this.options.localStorage) { - return; - } - - if (!subject || !predicate) { - return; - } - - var widget = this; - var identifier = subject + ':' + predicate; - var json = model.toJSONLD(); - if (window.localStorage.getItem(identifier)) { - var referenceList = JSON.parse(window.localStorage.getItem(identifier)); - var index = _.pluck(referenceList, '@').indexOf(json['@']); - if (index !== -1) { - referenceList[index] = json; - } else { - referenceList.push(json); - } - window.localStorage.setItem(identifier, JSON.stringify(referenceList)); - return; - } - window.localStorage.setItem(identifier, JSON.stringify([json])); - }, - - _checkLocal: function (model) { - if (!this.options.localStorage) { - return false; - } - - var local = window.localStorage.getItem(model.getSubjectUri()); - if (!local) { - return false; - } - - return true; - }, - - hasLocal: function (model) { - if (!this.options.localStorage) { - return false; - } - - if (!window.localStorage.getItem(model.getSubjectUri())) { - return false; - } - return true; - }, - - readLocal: function (model) { - if (!this.options.localStorage) { - return; - } - - var local = window.localStorage.getItem(model.getSubjectUri()); - if (!local) { - return; - } - if (!model._originalAttributes) { - model._originalAttributes = _.clone(model.attributes); - } - var parsed = JSON.parse(local); - var entity = this.vie.entities.addOrUpdate(parsed, { - overrideAttributes: true - }); - - this._trigger('loaded', null, { - instance: entity - }); - }, - - _readLocalReferences: function (model, property, collection) { - if (!this.options.localStorage) { - return; - } - - var identifier = this._getReferenceId(model, property); - var local = window.localStorage.getItem(identifier); - if (!local) { - return; - } - collection.add(JSON.parse(local)); - }, - - revertChanges: function (model) { - var widget = this; - - // Remove unsaved collection members - if (!model) { return; } - _.each(model.attributes, function (attributeValue, property) { - if (attributeValue instanceof widget.vie.Collection) { - var removables = []; - attributeValue.forEach(function (model) { - if (model.isNew()) { - removables.push(model); - } - }); - attributeValue.remove(removables); - } - }); - - // Restore original object properties - if (!model.changedAttributes()) { - if (model._originalAttributes) { - model.set(model._originalAttributes); - } - return; - } - - model.set(model.previousAttributes()); - }, - - _removeLocal: function (model) { - if (!this.localStorage) { - return; - } - - window.localStorage.removeItem(model.getSubjectUri()); - } -}); - -})(jQuery, Drupal); diff --git a/core/modules/edit/js/views/AppView.js b/core/modules/edit/js/views/AppView.js index 900e910..f460f10 100644 --- a/core/modules/edit/js/views/AppView.js +++ b/core/modules/edit/js/views/AppView.js @@ -20,6 +20,7 @@ $.extend(Drupal.edit, { */ initialize: function (options) { this.entitiesCollection = options.entitiesCollection; + this.fieldsCollection = options.fieldsCollection; _.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange'); @@ -28,7 +29,19 @@ $.extend(Drupal.edit, { this.activeEditorStates = ['activating', 'active']; this.singleEditorStates = _.union(['highlighted'], this.activeEditorStates); - this.entitiesCollection.on('change:isActive', this.appStateChange, this); + this.entitiesCollection + // Track app state. + .on('change:isActive', this.appStateChange, this) + .on('change:isActive', this.enforceSingleActiveEntity, this); + + this.fieldsCollection + // Track app state. + .on('change:state', this.editorStateChange, this) + // Respond to field model HTML representation change events. + .on('change:html', this.propagateUpdatedField, this) + .on('change:html', this.renderUpdatedField, this) + // Respond to destruction + .on('destroy', this.undecorate, this); }, /** @@ -79,7 +92,7 @@ $.extend(Drupal.edit, { // If the app is in view mode, then reject all state changes except for // those to 'inactive'. - if (context && context.reason === 'stop') { + if (context && (context.reason === 'stop' || context.reason === 'rerender')) { if (from === 'candidate' && to === 'inactive') { accept = true; } @@ -276,16 +289,72 @@ $.extend(Drupal.edit, { else if (this.model.get('activeEditor') === fieldModel && to === 'candidate') { // Discarded if it transitions from a changed state to 'candidate'. if (from === 'changed' || from === 'invalid') { - // @todo FIX THIS! - // Retrieve the storage widget from DOM. - // var createStorageWidget = this.$el.data('DrupalCreateStorage'); - // Revert changes in the model, this will trigger the direct editable - // content to be reset and redrawn. - // createStorageWidget.revertChanges(fieldModel.options.entity); + fieldModel.get('editorView').revert(); } this.model.set('activeEditor', null); } + }, + + // Updates all known instances of a specific entity field whenever the HTML + // representation of one of them has changed. + // @todo: this is currently prototype-level code, test this. The principle is + // sound, but the tricky thing is that an editID includes the view mode, but + // we actually want to update the same field in other view modes too, which + // means this method will have to check if there are any such instances, and + // if so, go to the server and re-render those too. + propagateUpdatedField: function (changedModel) { + // changedModel.collection.where({ editID: changedModel.get('editID') }) + // .set('html', changedModel.get('html')); + }, + + renderUpdatedField: function (field) { + // Get data necessary to rerender property before it is unavailable. + var html = field.get('html'); + var $fieldWrapper = field.get('$el').closest('[data-edit-id]'); + var $context = $fieldWrapper.parent(); + + // First set the state to 'candidate', to allow all attached views to + // clean up all their "active state"-related changes. + // @todo: get rid of this, ensure all views do the proper clean-up when + // going directly to the 'inactive' state. + field.set('state', 'candidate'); + + // Set the field's state to 'inactive', to enable the updating of its DOM + // value. + field.set('state', 'inactive', { reason: 'rerender' }); + + // Destroy the field model; this will cause all attached views to be + // destroyed too, and removal from all collections in which it exists. + field.destroy(); + + // Replace the old content with the new content. + $fieldWrapper.replaceWith(html); + + // Attach behaviors again to the modified piece of HTML; this will cause + // a new field model to be created. + Drupal.attachBehaviors($context); + }, + + /** + * EntityModel Collection change handler, called on change:isActive, enforces + * a single active entity. + */ + enforceSingleActiveEntity: function (changedEntityModel) { + // When an entity is deactivated, we don't need to enforce anything. + if (changedEntityModel.get('isActive') === false) { + return; + } + + // This entity was activated; deactivate all other entities. + changedEntityModel.collection.chain() + .filter(function (entityModel) { + return entityModel.get('isActive') === true && entityModel !== changedEntityModel; + }) + .each(function (entityModel) { + entityModel.set('isActive', false); + }); } + }) }); diff --git a/core/modules/edit/js/views/EditorView.js b/core/modules/edit/js/views/EditorView.js new file mode 100644 index 0000000..8a3d3a6 --- /dev/null +++ b/core/modules/edit/js/views/EditorView.js @@ -0,0 +1,165 @@ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit = Drupal.edit || {}; + +$.extend(Drupal.edit, { + + /** + * A base implementation that outlines the structure for in-place editors. + * + * Specific in-place editor implementations should subclass (extend) this View + * and override whichever method they deem necessary to override. + * + * Look at Drupal.edit.editors.form and Drupal.edit.editors.direct for + * examples. + */ + EditorView: Backbone.View.extend({ + + /** + * Implements Backbone.View.prototype.initialize(). + * + * If you override this method, you should call this method (the parent + * class' initialize()) first, like this: + * Drupal.edit.EditorView.prototype.initialize.call(this, options); + * + * For an example, @see Drupal.edit.editors.direct. + */ + initialize: function (options) { + this.model.on('change:state', this.stateChange, this); + }, + + /** + * Returns 3 Edit UI settings that depend on the in-place editor: + * - padding: @todo + * - unifiedToolbar: @todo + * - fullWidthToolbar: @todo + */ + getEditUISettings: function () { + return { padding: false, unifiedToolbar: false, fullWidthToolbar: false }; + }, + + /** + * Determines the actions to take given a change of state. + * + * @param Drupal.edit.FieldModel model + * @param String state + * The state of the associated field. One of Drupal.edit.FieldModel.states. + */ + stateChange: function (model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + // An in-place editor view will not yet exist in this state, hence + // this will never be reached. Listed for sake of completeness. + break; + case 'candidate': + // Nothing to do for the typical in-place editor: it should not be + // visible yet. + + // Except when we come from the 'invalid' state, then we clean up. + if (from === 'invalid') { + this.removeValidationErrors(); + } + break; + case 'highlighted': + // Nothing to do for the typical in-place editor: it should not be + // visible yet. + break; + case 'activating': + // The user has indicated he wants to do in-place editing: if + // something needs to be loaded (CSS/JavaScript/server data/…), then + // do so at this stage, and once the in-place editor is ready, + // set the 'active' state. + // A "loading" indicator will be shown in the UI for as long as the + // field remains in this state. + var that = this; + var loadDependencies = function (callback) { + // Do the loading here. + callback(); + }; + loadDependencies(function () { + that.model.set('state', 'active'); + }); + break; + case 'active': + // The user can now actually use the in-place editor. + break; + case 'changed': + // Nothing to do for the typical in-place editor. The UI will show an + // indicator that the field has changed. + break; + case 'saving': + // When the user has indicated he wants to save his changes to this + // field, this state will be entered. + // If the previous saving attempt resulted in validation errors, the + // previous state will be 'invalid'. Clean up those validation errors + // while the user is saving. + if (from === 'invalid') { + this.removeValidationErrors(); + } + this.save(); + break; + case 'saved': + // Nothing to do for the typical in-place editor. Immediately after + // being saved, a field will go to the 'candidate' state, where it + // should no longer be visible (after all, the field will then again + // just be a *candidate* to be in-place edited). + break; + case 'invalid': + // The modified field value was attempted to be saved, but there were + // validation errors. + this.showValidationErrors(); + break; + } + }, + + /** + * Reverts the modified value back to the original value (before editing + * started). + */ + revert: function () { + // @todo Should we implement a default here? + }, + + /** + * Saves the modified value in the in-place editor for this field. + */ + save: function () { + // @todo implement here the "direct" case, i.e. the hidden form case + alert('not yet done'); + }, + + /** + * Shows validation error messages. + * + * Should be called when the state is changed to 'invalid'. + */ + showValidationErrors: function () { + var $errors = $('
') + .append(this.model.get('validationErrors')); + this.model.get('$el') + .addClass('edit-validation-error') + .after($errors); + }, + + /** + * Cleans up validation error messages. + * + * Should be called when the state is changed to 'candidate' or 'saving'. In + * the case of the latter: the user has modified the value in the in-place + * editor again to attempt to save again. In the case of the latter: the + * invalid value was discarded. + */ + removeValidationErrors: function () { + this.model.get('$el') + .removeClass('edit-validation-error') + .next('.edit-validation-errors') + .remove(); + } + }) +}); + +}(jQuery, _, Backbone, Drupal)); diff --git a/core/modules/edit/js/views/PropertyEditorDecorationView.js b/core/modules/edit/js/views/PropertyEditorDecorationView.js index 6eb7205..b989053 100644 --- a/core/modules/edit/js/views/PropertyEditorDecorationView.js +++ b/core/modules/edit/js/views/PropertyEditorDecorationView.js @@ -53,12 +53,7 @@ Drupal.edit.PropertyEditorDecorationView = Backbone.View.extend({ var to = state; switch (to) { case 'inactive': - if (from !== null) { - this.undecorate(); - if (from === 'invalid') { - this._removeValidationErrors(); - } - } + this.undecorate(); break; case 'candidate': this.decorate(); @@ -66,9 +61,6 @@ Drupal.edit.PropertyEditorDecorationView = Backbone.View.extend({ this.stopHighlight(); if (from !== 'highlighted') { this.stopEdit(); - if (from === 'invalid') { - this._removeValidationErrors(); - } } } break; @@ -89,9 +81,6 @@ Drupal.edit.PropertyEditorDecorationView = Backbone.View.extend({ case 'changed': break; case 'saving': - if (from === 'invalid') { - this._removeValidationErrors(); - } break; case 'saved': break; @@ -371,22 +360,6 @@ Drupal.edit.PropertyEditorDecorationView = Backbone.View.extend({ else { callback(); } - }, - - /** - * Removes validation error's 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 () { - if (this.model.get('editor') !== 'form') { - this.$el - .removeClass('edit-validation-error') - .next('.edit-validation-errors') - .remove(); - } } }); diff --git a/core/modules/editor/js/editor.createjs.js b/core/modules/editor/js/editor.createjs.js index 0b734fb..1912e22 100644 --- a/core/modules/editor/js/editor.createjs.js +++ b/core/modules/editor/js/editor.createjs.js @@ -1,6 +1,6 @@ /** * @file - * Text editor-based Create.js widget for processed text content in Drupal. + * Text editor-based in-place editor for processed text content in Drupal. * * Depends on editor.module. Works with any (WYSIWYG) editor that implements the * editor.js API, including the optional attachInlineEditor() and onChange() @@ -16,38 +16,34 @@ "use strict"; -Drupal.edit.editors.editor = Backbone.View.extend({ +Drupal.edit.editors.editor = Drupal.edit.EditorView.extend({ textFormat: null, textFormatHasTransformations: null, textEditor: null, + $textElement: null, /** - * Implements Create.editWidget.getEditUISettings. + * {@inheritdoc} */ - getEditUISettings: function () { - return { padding: true, unifiedToolbar: true, fullWidthToolbar: true }; - }, + initialize: function (options) { + Drupal.edit.EditorView.prototype.initialize.call(this, options); - /** - * Implements Create.editWidget._initialize. - */ - initialize: function () { var editID = this.model.get('editID'); var metadata = Drupal.edit.metadataCache[editID].custom; this.textFormat = drupalSettings.editor.formats[metadata.format]; this.textFormatHasTransformations = metadata.formatHasTransformations; this.textEditor = Drupal.editors[this.textFormat.editor]; - this.model.on('change:state', this.stateChange, this); + // Store the actual value of this field. We'll need this to restore the + // original value when the user discards his modifications. + // @todo: figure out a more nicely abstracted way to handle this. + this.$textElement = this.$el.find('.field-item:first'); + this.model.set('originalValue', this.$textElement.html()); }, /** - * Determines the actions to take given a change of state. - * - * @param Drupal.edit.FieldModel model - * @param String state - * The state of the associated field. One of Drupal.edit.FieldModel.states. + * {@inheritdoc} */ stateChange: function (model, state) { var from = model.previous('state'); @@ -61,7 +57,10 @@ Drupal.edit.editors.editor = Backbone.View.extend({ // Detach the text editor when entering the 'candidate' state from one // of the states where it could have been attached. if (from !== 'inactive' && from !== 'highlighted') { - this.textEditor.detach(this.$el.get(0), this.textFormat); + this.textEditor.detach(this.$textElement.get(0), this.textFormat); + } + if (from === 'invalid') { + this.removeValidationErrors(); } break; @@ -77,7 +76,7 @@ Drupal.edit.editors.editor = Backbone.View.extend({ this._getUntransformedText(editID, this.$el, function (untransformedText) { // @todo update this debugger; - that.element.html(untransformedText); + that.$textElement.set(untransformedText); that.model.set('state', 'active'); }); } @@ -94,19 +93,15 @@ Drupal.edit.editors.editor = Backbone.View.extend({ case 'active': this.textEditor.attachInlineEditor( - this.$el.get(0), + this.$textElement.get(0), this.textFormat, this.model.get('toolbarView').getMainWysiwygToolgroupId(), this.model.get('toolbarView').getFloatedWysiwygToolgroupId() ); // Set the state to 'changed' whenever the content has changed. - this.textEditor.onChange(this.$el.get(0), function (value) { + this.textEditor.onChange(this.$textElement.get(0), function (htmlText) { + that.model.set('currentValue', htmlText); that.model.set('state', 'changed'); - - // @todo we have yet to set this value originally (before the editing - // starts) AND we have to handle the reverting aspect when editing is - // canceled, see editorStateChange(). - that.model.set('value', value); }); break; @@ -114,24 +109,43 @@ Drupal.edit.editors.editor = Backbone.View.extend({ break; case 'saving': + if (from === 'invalid') { + this.removeValidationErrors(); + } + this.save(); break; case 'saved': break; case 'invalid': + this.showValidationErrors(); break; } }, /** - * Loads untransformed text for a given property. + * {@inheritdoc} + */ + getEditUISettings: function () { + return { padding: true, unifiedToolbar: true, fullWidthToolbar: true }; + }, + + /** + * {@inheritdoc} + */ + revert: function () { + this.$textElement.html(this.model.get('originalValue')); + }, + + /** + * Loads untransformed text for a given field. * * More accurately: it re-processes processed text to exclude transformation * filters used by the text format. * - * @param String propertyID - * A property ID that uniquely identifies the given property. + * @param String editID + * A edit ID that uniquely identifies the given field. * @param jQuery $editorElement * The property's PropertyEditor DOM element. * @param Function callback @@ -139,20 +153,20 @@ Drupal.edit.editors.editor = Backbone.View.extend({ * * @see \Drupal\editor\Ajax\GetUntransformedTextCommand */ - _getUntransformedText: function (propertyID, $editorElement, callback) { + _getUntransformedText: function (editID, $editorElement, callback) { // Create a Drupal.ajax instance to load the form. - Drupal.ajax[propertyID] = new Drupal.ajax(propertyID, $editorElement, { - url: Drupal.edit.util.buildUrl(propertyID, drupalSettings.editor.getUntransformedTextURL), + Drupal.ajax[editID] = new Drupal.ajax(editID, $editorElement, { + url: Drupal.edit.util.buildUrl(editID, drupalSettings.editor.getUntransformedTextURL), event: 'editor-internal.editor', submit: { nocssjs : true }, progress: { type : null } // No progress indicator. }); // Implement a scoped editorGetUntransformedText AJAX command: calls the // callback. - Drupal.ajax[propertyID].commands.editorGetUntransformedText = function(ajax, response, status) { + Drupal.ajax[editID].commands.editorGetUntransformedText = function(ajax, response, status) { callback(response.data); // Delete the Drupal.ajax instance that called this very function. - delete Drupal.ajax[propertyID]; + delete Drupal.ajax[editID]; $editorElement.off('editor-internal.editor'); }; // This will ensure our scoped editorGetUntransformedText AJAX command