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