core/modules/edit/edit.module | 10 +- core/modules/edit/js/edit.js | 1468 ++++---------------- core/modules/edit/js/editable.js | 395 ------ core/modules/edit/js/editors/directEditor.js | 3 +- core/modules/edit/js/editors/editor.js | 5 +- core/modules/edit/js/editors/formEditor.js | 25 +- core/modules/edit/js/viejs/EditService.js | 289 ---- core/modules/edit/js/views/contextuallink-view.js | 122 ++ core/modules/edit/js/views/modal-view.js | 81 ++ .../edit/js/views/propertyeditordecoration-view.js | 352 +++++ core/modules/edit/js/views/toolbar-view.js | 397 ++++++ 11 files changed, 1228 insertions(+), 1919 deletions(-) diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index c2eaa4c..3cec929 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -65,7 +65,6 @@ function edit_library_info() { $path = drupal_get_path('module', 'edit'); $options = array( 'scope' => 'footer', - 'attributes' => array('defer' => TRUE), ); $libraries['edit'] = array( 'title' => 'Edit: in-place editing', @@ -73,13 +72,15 @@ function edit_library_info() { 'version' => VERSION, 'js' => array( // Core. - $path . '/js/editable.js' => $options, $path . '/js/editors/editor.js' => $options, $path . '/js/edit.js' => $options, + // Views. + $path . '/js/views/propertyeditordecoration-view.js' => $options, + $path . '/js/views/contextuallink-view.js' => $options, + $path . '/js/views/modal-view.js' => $options, + $path . '/js/views/toolbar-view.js' => $options, // Backbone.sync implementation on top of Drupal forms. $path . '/js/backbone.drupalform.js' => $options, - // VIE service. - $path . '/js/viejs/EditService.js' => $options, // Storage manager. $path . '/js/storage.js' => $options, // Other. @@ -102,7 +103,6 @@ function edit_library_info() { array('system', 'jquery'), array('system', 'underscore'), array('system', 'backbone'), - array('system', 'vie.core'), array('system', 'jquery.form'), array('system', 'drupal.form'), array('system', 'drupal.ajax'), diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index cdec296..1f836c1 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -49,23 +49,20 @@ Drupal.behaviors.edit = { // Store a collection of editables models. if (!this.collections.editables) { - Collection = Backbone.Collection.extend({ - model: Drupal.edit.EditableModel - }); - this.collections.editables = new Collection(); + this.collections.editables = new Drupal.edit.EditablesCollection(); } - // Respond to entity model change events. - this.collections.entities - .on('change:isActive', this.onEntityActiveChange, this); - - // Respond to editable model change events. this.collections.editables - .on('change:isActive', this.onEditableActiveChange, this); + // Respond to editable 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.editables + .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); @@ -96,6 +93,8 @@ Drupal.behaviors.edit = { // Update the metadata cache. _.each(results, function(metadata, editID) { Drupal.edit.metadataCache[editID] = metadata; + // @temporary HACK: use the 'form' editor for all fields + Drupal.edit.metadataCache[editID].editor = 'form'; }); // Annotate the remaining fields based on the updated access cache. @@ -116,7 +115,8 @@ Drupal.behaviors.edit = { var appModel = new Drupal.edit.AppModel(); var app = new Drupal.edit.AppView({ el: $('body').get(0), - model: appModel + model: appModel, + entitiesCollection: this.collections.entities }); var entityModel; @@ -126,7 +126,7 @@ Drupal.behaviors.edit = { var id = $this.data('edit-entity'); entityModel = new Drupal.edit.EntityModel({ - uri: id + id: id }); that.collections.entities.add(entityModel); @@ -142,7 +142,8 @@ Drupal.behaviors.edit = { // Instantiate ContextualLinkView. that.views.contextualLinks[id] = new Drupal.edit.ContextualLinkView($.extend({ el: this, - model: entityModel + model: entityModel, + appModel: appModel }, options)); }); }); @@ -174,93 +175,41 @@ Drupal.behaviors.edit = { var activeEntity = model.get('activeEntity'); options = options || {}; - Drupal.edit.app.domService.findSubjectElements($context).each(function() { + $context.find('[data-edit-id]').each(function() { var $element = $(this); - var entityId = $element.closest('[data-edit-entity]').data('edit-entity'); + var editID = $element.attr('data-edit-id'); - // The EditableModel stores the state of each Editable. - var editableModel = new Drupal.edit.EditableModel({ - isActive: false, - uri: entityId, - predicate: null, - propertyId: null - }); - that.collections.editables.add(editableModel); - - var editable = new Drupal.edit.Editable({ - element: this, - editableModel: editableModel, - entityModel: that.collections.entities.where({uri: entityId})[0], - vie: Drupal.edit.app.vie, - domService: 'edit', - predicateSelector: '*', //'.edit-field.edit-allowed' - // The Create.js PropertyEditor widget configuration is not hardcoded; it - // is generated by the server. - propertyEditorWidgetsConfiguration: drupalSettings.edit.editors, - // Callback function for validating changes between states. Receives the previous state, new state, possibly property, and a callback - acceptStateChange: true, - propertyEditors: {}, - // Callback function for decorating the full editable. Will be called on instantiation - decorateEditableEntity: null, - // Callback function for decorating a single property editor widget. Will - // be called on editing widget instantiation. - decoratePropertyEditor: null - }); - - var fullId = entityId + '/' + editable.predicate; - that.controllers.editables[fullId] = editable; - var editor = Drupal.edit.metadataCache[fullId]; - editableModel.set('predicate', editable.predicate); - editableModel.set('propertyId', fullId); - - var editableView = new Drupal.edit.EditableView($.extend({ - el: this, - model: editableModel, - predicateModel: editable.predicateModel, - entityModel: that.collections.entities.where({uri: entityId})[0], - uri: entityId - }, options)); - that.views.editables[fullId] = editableView; - - // Create a new Editor. - var editorName = Drupal.edit.metadataCache[fullId].editor; - var editorView = new Drupal.edit[that.getEditorType(editorName)]({ - el: this, - model: editableModel, - editorName: editorName, - predicateModel: editable.predicateModel, - entityModel: that.collections.entities.where({uri: entityId})[0], - vie: Drupal.edit.app.vie, - // Required by VIE and referenced by Drupal.syncDirect in backbone.drupalform.js - property: editable.predicate, - entity: editable.predicateModel - }); - that.views.propertyEditors[fullId] = editorView; + if (!_.has(Drupal.edit.metadataCache, editID)) { + return; + } - // Toolbars are rendered "on-demand" (highlighting or activating). - // They are a sibling element before the editor's DOM element. - var toolbarView = new Drupal.edit.ToolbarView({ - el: this, - model: editableModel, - predicateModel: editable.predicateModel, - entityModel: that.collections.entities.where({uri: entityId})[0], - uri: entityId, - editor: editorView + var entityId = $element.closest('[data-edit-entity]').data('edit-entity'); + // @todo Note that 1) not every field that has a data-edit-id also has a + // surrounding data-edit-entity, 2) also note that whether the "Quick Edit" + // link should appear depends on whether the user has access to edit any + // of the entity's fields. So, it is blocked on the metadata callback to + // the server. This is not yet implemented in the D8 HEAD Edit, but it is + // in http://drupal.org/node/1971108. We should take this into account if + // possible. If not, then we'll just have to refactor later. + // For now, this assumption is acceptable. + var entity = that.collections.entities.where({id: entityId})[0]; + + // The EditableModel stores the state of an editable entity field. + var editableModel = new Drupal.edit.EditableModel({ + entity: entity, + $el: $element, + editID: editID, + label: Drupal.edit.metadataCache[editID].label, + editor: Drupal.edit.metadataCache[editID].editor, + html: $element[0].outerHTML, + acceptStateChange: _.bind(Drupal.edit.app.acceptEditorStateChange, Drupal.edit.app) }); - that.views.toolbars[fullId] = toolbarView; - // Decorate the editor's DOM element depending on its state. - var decorationView = new Drupal.edit.PropertyEditorDecorationView({ - el: this, - model: editableModel, - predicateModel: editable.predicateModel, - entityModel: that.collections.entities.where({uri: entityId})[0], - editor: editorView, - toolbarId: toolbarView.getId(), - uri: entityId - }); - that.views.decorators[fullId] = decorationView; + // Store this editable field model in the collection where we track all + // fields on the page. + that.collections.editables.add(editableModel); + // @todo make the below work return; // If the new PropertyEditor is for the entity that's currently being @@ -290,56 +239,16 @@ Drupal.behaviors.edit = { return false; }, - /** - * - */ - onEntityActiveChange: function (changedModel, index, options) { - var that = this; - var uri = changedModel.get('uri'); - _.each(changedModel.collection.models, function (model, index, collection, options) { - // Don't set the state of the changed model, just the others. - if (model.get('uri') !== uri && !('isActive' in model.changed)) { - model.set({'isActive': false}); - // Turn off all the editables for this entity as well. - _.each(that.collections.editables.where({'uri': uri}), function (model) { - model.set('isActive', false); - }); - } - }); - }, - - /** - * - */ - onEditableActiveChange: function (changedModel, index, options) { - var id = changedModel.get('propertyId'); - _.each(changedModel.collection.models, function (model, index, collection, options) { - // Don't set the state of the changed model, just the others. - if (model.get('propertyId') !== id && !('isActive' in model.changed)) { - model.set({'isActive': false}); - } - }); - }, - - /** - * @todo This is a hack the maps the editor name to the name of the class for - * that editor type. We should just make these equivalent in the code rather - * than map them. That probably means changing some PHP, but I can't find at - * the moment where the values are set in configuration. - */ - getEditorType: function (name) { - var type = ''; - switch (name) { - case 'direct': - type = 'DirectEditor'; - break; - case 'form': - type = 'FormEditor'; - break - default: - type = 'FormEditor'; - } - return type; + // 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: { @@ -385,14 +294,10 @@ $.extend(Drupal.edit, { /** * Implements Backbone Views' initialize() function. */ - initialize: function() { - _.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange'); + initialize: function (options) { + this.entitiesCollection = options.entitiesCollection; - // VIE instance for Edit. - this.vie = new VIE(); - // Use our custom DOM parsing service until RDFa is available. - this.vie.use(new this.vie.EditService()); - this.domService = this.vie.service('edit'); + _.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange'); // Instantiate configuration for state handling. this.states = [ @@ -402,37 +307,33 @@ $.extend(Drupal.edit, { this.activeEditorStates = ['activating', 'active']; this.singleEditorStates = _.union(['highlighted'], this.activeEditorStates); - // When view/edit mode is toggled in the menu, update the editor widgets. - this.model.on('change:activeEntity', this.appStateChange); + this.entitiesCollection.on('change:isActive', this.appStateChange, this); }, /** - * Sets the state of PropertyEditor widgets when edit mode begins or ends. - * - * Should be called whenever EditAppModel's "activeEntity" changes. + * Handles setup/teardown and state changes when the active entity changes. */ - appStateChange: function() { - // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133, https://github.com/bergie/create/issues/140) - // We're currently setting the state on EditableEntity widgets instead of - // PropertyEditor widgets, because of - // https://github.com/bergie/create/issues/133. - - var editables = this.editables; - var activeEntity = this.model.get('activeEntity'); - - // First, change the status of all PropertyEditor widgets to 'inactive'. - for (var i = 0, il = editables.length; i < il; i++) { - var editable = editables[i]; - editable.invoke('setState', 'inactive', null, {reason: 'stop'}); - // Then, change the status of PropertyEditor widgets of the currently - // active entity to 'candidate'. - var entityOfProperty = editable.invoke('get', 'model'); - // If the new PropertyEditor is for the entity that's currently being - // edited, then transition it to the 'candidate' state. - // (This happens when a field was modified and is re-rendered.) - if (entityOfProperty.getSubjectUri() === activeEntity) { - editable.invoke('setState', 'candidate'); - } + appStateChange: function (entityModel, isActive) { + var app = this; + if (isActive) { + // Move all fields of this entity from the 'inactive' state to the + // 'candidate' state. + entityModel.get('fields').each(function (fieldModel) { + // First, set up decoration views. + app.decorate(fieldModel); + // Second, change the field's state. + fieldModel.setState('candidate'); + }); + } + else { + // Move all fields of this entity from whatever state they are in to + // the 'inactive' state. + entityModel.get('fields').each(function (fieldModel) { + // First, change the field's state. + fieldModel.setState('inactive', { reason: 'stop' }); + // Second, tear down decoration views. + app.undecorate(fieldModel); + }); } }, @@ -441,20 +342,20 @@ $.extend(Drupal.edit, { * * This is what ensures that the app is in control of what happens. * - * @param from + * @param String from * The previous state. - * @param to + * @param String to * The new state. - * @param predicate - * The predicate of the property for which the state change is happening. - * @param context + * @param null|Object context * The context that is trying to trigger the state change. - * @param callback + * @param Function callback * The callback function that should receive the state acceptance result. */ - acceptEditorStateChange: function(from, to, predicate, context, callback) { + acceptEditorStateChange: function(from, to, context, callback) { var accept = true; + console.log("accept? %s → %s (reason: %s)", from, to, (context && context.reason) ? context.reason : 'NONE'); + // If the app is in view mode, then reject all state changes except for // those to 'inactive'. if (context && context.reason === 'stop') { @@ -537,6 +438,61 @@ $.extend(Drupal.edit, { callback(accept); }, + // @todo rename to decorateField + decorate: function (fieldModel) { + var editID = fieldModel.get('editID'); + var $el = fieldModel.get('$el'); + + // Create a new Editor. + var editorName = fieldModel.get('editor'); + var editorView = new Drupal.edit.editors[editorName]({ + el: $el, + model: fieldModel + }); + + // Toolbars are rendered "on-demand" (highlighting or activating). + // They are a sibling element before the editor's DOM element. + var toolbarView = new Drupal.edit.ToolbarView({ + model: fieldModel, + editor: editorView + }); + + // Decorate the editor's DOM element depending on its state. + var decorationView = new Drupal.edit.PropertyEditorDecorationView({ + el: $el, + model: fieldModel, + editor: editorView, + toolbarId: toolbarView.getId() + }); + + // Create references; necessary for undecorate(). + fieldModel.set('decorationViews', { + editorView: editorView, + toolbarView: toolbarView, + decorationView: decorationView + }); + }, + + // @todo rename to undecorateField + undecorate: function (fieldModel) { + var decorationViews = fieldModel.get('decorationViews'); + + // Unbind event handlers; remove toolbar element; delete toolbar view. + decorationViews.toolbarView.undelegateEvents(); + decorationViews.toolbarView.remove(); + delete decorationViews.toolbarView; + + // Unbind event handlers; delete decoration view. Don't remove the element + // because that would remove the field itself. + decorationViews.decorationView.undelegateEvents(); + delete decorationViews.decorationView; + + // Unbind event handlers; delete editor view. Don't remove the element + // because that would remove the field itself. + decorationViews.editorView.undelegateEvents(); + delete decorationViews.editorView; + }, + /** * Asks the user to confirm whether he wants to stop editing via a modal. * @@ -591,45 +547,35 @@ $.extend(Drupal.edit, { * @param editor * The PropertyEditor widget object. */ - editorStateChange: function(from, to, editor) { - // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) - // Get rid of this once that issue is solved. - if (!editor) { - return; - } - else { - editor.stateChange(from, to); - } + editorStateChange: function(fieldModel, state) { + var from = fieldModel.previous('state'); + var to = state; + + console.log("%c %s → %s %s", "background-color: black; color: white", from, to, fieldModel.get('editID')); // Keep track of the highlighted editor in the global state. - if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== editor) { - this.model.set('highlightedEditor', editor); + if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== fieldModel) { + this.model.set('highlightedEditor', fieldModel); } - else if (this.model.get('highlightedEditor') === editor && to === 'candidate') { + else if (this.model.get('highlightedEditor') === fieldModel && to === 'candidate') { this.model.set('highlightedEditor', null); } // Keep track of the active editor in the global state. - if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== editor) { - this.model.set('activeEditor', editor); + if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== fieldModel) { + this.model.set('activeEditor', fieldModel); } - else if (this.model.get('activeEditor') === editor && to === 'candidate') { + else if (this.model.get('activeEditor') === fieldModel && to === 'candidate') { // Discarded if it transitions from a changed state to 'candidate'. if (from === 'changed' || from === 'invalid') { // Retrieve the storage widget from DOM. var createStorageWidget = this.$el.data('DrupalCreateStorage'); // Revert changes in the model, this will trigger the direct editable // content to be reset and redrawn. - createStorageWidget.revertChanges(editor.options.entity); + createStorageWidget.revertChanges(fieldModel.options.entity); } this.model.set('activeEditor', null); } - - // Propagate the state change to the decoration and toolbar views. - // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) - // Uncomment this once that issue is solved. - // editor.decorationView.stateChange(from, to); - // editor.toolbarView.stateChange(from, to); } }), @@ -638,7 +584,16 @@ $.extend(Drupal.edit, { */ EntityModel: Backbone.Model.extend({ defaults: { - isActive: false + // An entity ID, of the form "/", e.g. "node/1". + id: null, + // Indicates whether this instance of this entity is currently being + // edited. + isActive: false, + // A Drupal.edit.EditablesCollection for all fields of this entity. + fields: null + }, + initialize: function () { + this.set('fields', new Drupal.edit.EditablesCollection()); } }), @@ -665,1035 +620,116 @@ $.extend(Drupal.edit, { this.$el.toggleClass('edit-active', isActive); return this; - }, - - /** - * Listens to editor state changes. - */ - stateChange: function (from, to) { - switch (to) { - case 'inactive': - console.log(to); - break; - case 'candidate': - console.log(to); - break; - case 'highlighted': - console.log(to); - break; - case 'activating': - console.log(to); - break; - case 'active': - console.log(to); - break; - case 'changed': - console.log(to); - break; - case 'saving': - console.log(to); - break; - case 'saved': - console.log(to); - break; - case 'invalid': - console.log(to); - break; - } } }), /** * */ + // @todo rename to FieldModel? EditableModel: Backbone.Model.extend({ defaults: { - isActive: false, - isDirty: false, - state: 'saved', - uri: null, - predicate: null, - propertyId: null - } - }), - - /** - * - */ - EditableView: Backbone.View.extend({ - events: {}, - initialize: function () { - this.entityModel = this.options.entityModel; - this.entityModel.on('change:isActive', this.render, this); - }, - render: function () { - var isEntityActive = this.entityModel.get('isActive'); - var isActive = this.model.get('isActive'); - this.$el.toggleClass('edit-editable', isEntityActive); - if (isActive) { - this.$el.css({'background-color': 'yellow'}); - } - else { - this.$el.css({'background-color': 'transparent'}); - } - } - }), - - /** - * - */ - ToolbarView: Backbone.View.extend({ - - editor: null, - - entity: null, - predicate : null, - editorName: null, - - _loader: null, - _loaderVisibleStart: 0, - - _id: null, - - events: { - 'click.edit button.label': 'onClickInfoLabel', - 'mouseleave.edit': 'onMouseLeave', - 'click.edit button.field-save': 'onClickSave', - 'click.edit button.field-close': 'onClickClose' - }, - - /** - * Implements Backbone Views' initialize() function. - */ - initialize: function(options) { - - this.model.on('change:isActive', this.render, this); - this.model.on('change:state', this.stateChange, this); - - this.predicate = this.model.get('predicate'); - - // !HACK! - this.entity = options.predicateModel; - - // this.entity = this.editor.options.entity; - // this.predicate = this.editor.options.property; - // this.editorName = this.editor.options.editorName; - - this._loader = null; - this._loaderVisibleStart = 0; - - // Generate a DOM-compatible ID for the toolbar DOM element. - var propertyId = this.model.get('propertyId'); - - // Generate a DOM-compatible ID for the form container DOM element. - this.elementId = 'edit-toolbar-for-' + propertyId.replace(/\//g, '_'); - }, - - /** - * Renders the Toolbar's markup into the DOM. - * - * Note: depending on whether the 'display' property of the $el for which a - * toolbar is being inserted into the DOM, it will be inserted differently. - */ - render: function () { - var $toolbar = $(Drupal.theme('editToolbarContainer', { - id: this.elementId - })); - // Insert in DOM. - if (false && this.editor.element.css('display') === 'inline') { - this.$el.prependTo(this.editor.element.offsetParent()); - var pos = this.editor.element.position(); - this.$el.css('left', pos.left).css('top', pos.top); - } - else { - $toolbar.insertBefore(this.$el); - this.setElement($toolbar); - this.startHighlight(); - this.startEdit(); - } - - return this; - }, - - /** - * Listens to editor state changes. - */ - stateChange: function(model, value) { - switch (value) { - case 'inactive': - if (from) { - this.remove(); - if (this.editorName !== 'form') { - Backbone.syncDirectCleanUp(); - } - } - break; - case 'candidate': - if (from === 'inactive') { - this.render(); - } - else { - if (this.editorName !== 'form') { - Backbone.syncDirectCleanUp(); - } - // Remove all toolgroups; they're no longer necessary. - this.$el - .removeClass('edit-highlighted edit-editing') - .find('.edit-toolbar .edit-toolgroup').remove(); - if (from !== 'highlighted' && this.getEditUISetting('padding')) { - this._unpad(); - } - } - break; - case 'highlighted': - // As soon as we highlight, make sure we have a toolbar in the DOM (with at least a title). - this.startHighlight(); - break; - case 'activating': - this.setLoadingIndicator(true); - break; - case 'active': - this.startEdit(); - this.setLoadingIndicator(false); - if (this.getEditUISetting('fullWidthToolbar')) { - this.$el.addClass('edit-toolbar-fullwidth'); - } - - if (this.getEditUISetting('padding')) { - this._pad(); - } - if (this.getEditUISetting('unifiedToolbar')) { - this.insertWYSIWYGToolGroups(); - } - break; - case 'changed': - this.$el - .find('button.save') - .addClass('blue-button') - .removeClass('gray-button'); - break; - case 'saving': - this.setLoadingIndicator(true); - break; - case 'saved': - this.setLoadingIndicator(false); - break; - case 'invalid': - this.setLoadingIndicator(false); - break; - } - }, - - /** - * When the user clicks the info label, nothing should happen. - * @note currently redirects the click.edit-event to the editor DOM element. - * - * @param event - */ - onClickInfoLabel: function(event) { - event.stopPropagation(); - event.preventDefault(); - // Redirects the event to the editor DOM element. - this.editor.element.trigger('click.edit'); - }, - - /** - * A mouseleave to the editor doesn't matter; a mouseleave to something else - * counts as a mouseleave on the editor itself. - * - * @param event - */ - onMouseLeave: function(event) { - return false; - var el = this.editor.element[0]; - if (event.relatedTarget != el && !$.contains(el, event.relatedTarget)) { - this.editor.element.trigger('mouseleave.edit'); - } - event.stopPropagation(); - }, - - /** - * Upon clicking "Save", trigger a custom event to save this property. - * - * @param event - */ - onClickSave: function(event) { - this.model.set('state', 'saving'); - }, - - /** - * Upon clicking "Close", trigger a custom event to stop editing. - * - * @param event - */ - onClickClose: function(event) { - event.stopPropagation(); - event.preventDefault(); - this.editor.options.widget.setState('candidate', this.predicate, { reason: 'cancel' }); - }, - - /** - * Indicates in the 'info' toolgroup that we're waiting for a server reponse. - * - * Prevents flickering loading indicator by only showing it after 0.6 seconds - * and if it is shown, only hiding it after another 0.6 seconds. - * - * @param bool enabled - * Whether the loading indicator should be displayed or not. - */ - setLoadingIndicator: function(enabled) { - var that = this; - if (enabled) { - this._loader = setTimeout(function() { - that.addClass('info', 'loading'); - that._loaderVisibleStart = new Date().getTime(); - }, 600); - } - else { - var currentTime = new Date().getTime(); - clearTimeout(this._loader); - if (this._loaderVisibleStart) { - setTimeout(function() { - that.removeClass('info', 'loading'); - }, this._loaderVisibleStart + 600 - currentTime); - } - this._loader = null; - this._loaderVisibleStart = 0; - } - }, - - startHighlight: function() { - // We get the label to show for this property from VIE's type system. - var label = this.predicate; - var attributeDef = this.entity.get('@type').attributes.get(this.predicate); - if (attributeDef && attributeDef.metadata) { - label = attributeDef.metadata.label; - } - - this.$el - .addClass('edit-highlighted') - .find('.edit-toolbar') - // Append the "info" toolgroup into the toolbar. - .append(Drupal.theme('editToolgroup', { - classes: 'info edit-animate-only-background-and-padding', - buttons: [ - { label: label, classes: 'blank-button label' } - ] - })); - - // Animations. - var that = this; - setTimeout(function () { - that.show('info'); - }, 0); + // @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 + state: 'inactive', + // A Drupal.edit.EntityModel. Its "properties" attribute, which is an + // EditablesCollection, is automatically updated to include this + // EditableModel. + entity: null, + // A place to store any decoration views. + decorationViews: {}, + + + // + // Data set by the metadata callback. + // + // The ID of the in-place editor to use. + editor: null, + // The label to use. + label: null, + + // + // Data derived from the information in the DOM. + // + + // The edit ID, format: `::::`. + editID: null, + // The full HTML representation of this field (with the element that has + // the data-edit-id as the outer element). Used to propagate changes from + // this field instance to other instances of the same field. + html: null, + + // + // Callbacks. + // + + // Callback function for validating changes between states. Receives the + // previous state, new state, context, and a callback + acceptStateChange: null }, - - startEdit: function() { - this.$el - .addClass('edit-editing') - .find('.edit-toolbar') - // Append the "ops" toolgroup into the toolbar. - .append(Drupal.theme('editToolgroup', { - classes: 'ops', - buttons: [ - { label: Drupal.t('Save'), type: 'submit', classes: 'field-save save gray-button' }, - { label: '' + Drupal.t('Close') + '', classes: 'field-close close gray-button' } - ] - })); - this.show('ops'); - }, - - /** - * Retrieves a setting of the editor-specific Edit UI integration. - * - * @see Drupal.edit.util.getEditUISetting(). - */ - getEditUISetting: function(setting) { - return Drupal.edit.util.getEditUISetting(this.editor, setting); - }, - - /** - * Adjusts the toolbar to accomodate padding on the PropertyEditor widget. - * - * @see PropertyEditorDecorationView._pad(). - */ - _pad: function() { - // The whole toolbar must move to the top when the property's DOM element - // is displayed inline. - if (this.editor.element.css('display') === 'inline') { - this.$el.css('top', parseInt(this.$el.css('top'), 10) - 5 + 'px'); - } - - // The toolbar must move to the top and the left. - var $hf = this.$el.find('.edit-toolbar-heightfaker'); - $hf.css({ bottom: '6px', left: '-5px' }); - - if (this.getEditUISetting('fullWidthToolbar')) { - $hf.css({ width: this.editor.element.width() + 10 }); - } - }, - - /** - * Undoes the changes made by _pad(). - * - * @see PropertyEditorDecorationView._unpad(). - */ - _unpad: function() { - // Move the toolbar back to its original position. - var $hf = this.$el.find('.edit-toolbar-heightfaker'); - $hf.css({ bottom: '1px', left: '' }); - - if (this.getEditUISetting('fullWidthToolbar')) { - $hf.css({ width: '' }); - } - }, - - insertWYSIWYGToolGroups: function() { - this.$el - .find('.edit-toolbar') - .append(Drupal.theme('editToolgroup', { - id: this.getFloatedWysiwygToolgroupId(), - classes: 'wysiwyg-floated', - buttons: [] - })) - .append(Drupal.theme('editToolgroup', { - id: this.getMainWysiwygToolgroupId(), - classes: 'wysiwyg-main', - buttons: [] - })); - - // Animate the toolgroups into visibility. - var that = this; - setTimeout(function () { - that.show('wysiwyg-floated'); - that.show('wysiwyg-main'); - }, 0); - }, - - /** - * Retrieves the ID for this toolbar's container. - * - * Only used to make sane hovering behavior possible. - * - * @return string - * A string that can be used as the ID for this toolbar's container. - */ - getId: function () { - return 'edit-toolbar-for-' + this._id; - }, - - /** - * Retrieves the ID for this toolbar's floating WYSIWYG toolgroup. - * - * Used to provide an abstraction for any WYSIWYG editor to plug in. - * - * @return string - * A string that can be used as the ID. - */ - getFloatedWysiwygToolgroupId: function () { - return 'edit-wysiwyg-floated-toolgroup-for-' + this._id; - }, - - /** - * Retrieves the ID for this toolbar's main WYSIWYG toolgroup. - * - * Used to provide an abstraction for any WYSIWYG editor to plug in. - * - * @return string - * A string that can be used as the ID. - */ - getMainWysiwygToolgroupId: function () { - return 'edit-wysiwyg-main-toolgroup-for-' + this._id; - }, - - /** - * Shows a toolgroup. - * - * @param string toolgroup - * A toolgroup name. - */ - show: function (toolgroup) { - this._find(toolgroup).removeClass('edit-animate-invisible'); - }, - - /** - * Adds classes to a toolgroup. - * - * @param string toolgroup - * A toolgroup name. - */ - addClass: function (toolgroup, classes) { - this._find(toolgroup).addClass(classes); - }, - - /** - * Removes classes from a toolgroup. - * - * @param string toolgroup - * A toolgroup name. - */ - removeClass: function (toolgroup, classes) { - this._find(toolgroup).removeClass(classes); - }, - - /** - * Finds a toolgroup. - * - * @param string toolgroup - * A toolgroup name. - */ - _find: function (toolgroup) { - return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); - } - }), - - /** - * - */ - ContextualLinkView: Backbone.View.extend({ - - entity: null, - - events: { - 'click .quick-edit a': 'onClick' - }, - - /** - * Implements Backbone Views' initialize() function. - * - * @param options - * An object with the following keys: - * - entity: the entity ID (e.g. node/1) of the entity - */ - initialize: function (options) { - this.entity = options.entity; - this.strings = options.strings; - - // Build the DOM elements. - this.$el - .find('li.node-edit, li.taxonomy-edit, li.comment-edit, li.custom-block-edit') - .before('
  • '); - - // Initial render. - this.render(); - - // Re-render whenever the app state's active entity changes. - this.model.on('change:isActive', this.render, this); - - // Hide the contextual links whenever an in-place editor is active. - this.model.on('change:activeEditor', this.toggleContextualLinksVisibility, this); - }, - - /** - * Equates clicks anywhere on the overlay to clicking the active editor's (if - * any) "close" button. - * - * @param {Object} event - */ - onClick: function (event) { - event.preventDefault(); - - var that = this; - - this.model.set('isActive', !this.model.get('isActive')); - - return; - - var updateActiveEntity = function() { - // The active entity is the current entity, i.e. stop editing the current - // entity. - if (that.model.get('activeEntity') === that.entity) { - that.model.set('activeEntity', null); - } - // The active entity is different from the current entity, i.e. start - // editing this entity instead of the previous one. - else { - that.model.set('activeEntity', that.entity); - } - }; - - // If there's an active editor, attempt to set its state to 'candidate', and - // only then do what the user asked. - // (Only when all PropertyEditor widgets of an entity are in the 'candidate' - // state, it is possible to stop editing it.) - var activeEditor = this.model.get('activeEditor'); - if (activeEditor) { - var editableEntity = activeEditor.options.widget; - var predicate = activeEditor.options.property; - editableEntity.setState('candidate', predicate, { reason: 'stop or switch' }, function(accepted) { + initialize: function () { + this.get('entity').get('fields').add(this); + }, + // @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('/'); + }, + // Always call this method, never call .set('state'), because we need to call + // the acceptStateChange callback first + // @see Create.js' setState() + setState: function (state, context, callback) { + // @todo propagate to the entity in some way? I don't think this is + // actually necessary; the entity can listen on its collection of fields + // for changes to the state of any of its fields! + var current = this.get('state'); + var next = state; + // Only attempt to change the state if it's a different state. + if (current !== next) { + var acceptStateChange = this.get('acceptStateChange'); + var that = this; + acceptStateChange(current, next, context, function (accepted) { if (accepted) { - updateActiveEntity(); + that.set('state', next); } - else { - // No change. + // @todo can't we get rid of this? + if (_.isFunction(callback)) { + callback(accepted); } }); } - // Otherwise, we can immediately do what the user asked. - else { - updateActiveEntity(); - } - }, - - /** - * Render the "Quick edit" contextual link. - */ - render: function () { - var isActive = this.model.get('isActive'); - this.$el.find('.quick-edit a').text((!isActive) ? this.strings.quickEdit : this.strings.stopQuickEdit); return this; - }, - - /** - * Model change handler; hides the contextual links if an editor is active. - * - * @param Drupal.edit.models.EditAppModel model - * An EditAppModel model. - * @param jQuery|null activeEditor - * The active in-place editor (jQuery object) or, if none, null. - */ - toggleContextualLinksVisibility: function (model, activeEditor) { - this.$el.parents('.contextual').toggle(activeEditor === null); } }), - /** - * - */ - ModalView: Backbone.View.extend({ - - message: null, - buttons: null, - callback: null, - $elementsToHide: null, - - events: { - 'click button': 'onButtonClick' - }, - - /** - * Implements Backbone Views' initialize() function. - * - * @param options - * An object with the following keys: - * - message: a message to show in the modal. - * - buttons: a set of buttons with 'action's defined, ready to be passed to - * Drupal.theme.editButtons(). - * - callback: a callback that will receive the 'action' of the clicked - * button. - * - * @see Drupal.theme.editModal() - * @see Drupal.theme.editButtons() - */ - initialize: function(options) { - this.message = options.message; - this.buttons = options.buttons; - this.callback = options.callback; - }, - - /** - * Implements Backbone Views' render() function. - */ - render: function() { - this.setElement(Drupal.theme('editModal', {})); - this.$el.appendTo('body'); - // Template. - this.$('.main p').text(this.message); - var $actions = $(Drupal.theme('editButtons', { 'buttons' : this.buttons})); - this.$('.actions').append($actions); - - // Show the modal with an animation. - var that = this; - setTimeout(function() { - that.$el.removeClass('edit-animate-invisible'); - }, 0); - }, - - /** - * When the user clicks on any of the buttons, the modal should be removed - * and the result should be passed to the callback. - * - * @param event - */ - onButtonClick: function(event) { - event.stopPropagation(); - event.preventDefault(); - - // Remove after animation. - var that = this; - this.$el - .addClass('edit-animate-invisible') - .on(Drupal.edit.util.constants.transitionEnd, function(e) { - that.remove(); - }); - - var action = $(event.target).attr('data-edit-modal-action'); - return this.callback(action); - } + // A collection of EditableModels. + // @todo link back to the entity at the collection level (not the collection element level)? + EditablesCollection: Backbone.Collection.extend({ + model: Drupal.edit.EditableModel }), - /** - * - */ - PropertyEditorDecorationView: Backbone.View.extend({ - - toolbarId: null, - - _widthAttributeIsEmpty: null, - - events: { - //'mouseenter.edit' : 'onMouseEnter', - //'mouseleave.edit' : 'onMouseLeave', - 'click': 'onClick', - 'tabIn.edit': 'onMouseEnter', - 'tabOut.edit': 'onMouseLeave' - }, - - /** - * Implements Backbone Views' initialize() function. - * - * @param options - * An object with the following keys: - * - editor: the editor object with an 'options' object that has these keys: - * * entity: the VIE entity for the property. - * * property: the predicate of the property. - * * widget: the parent EditableEntity widget. - * * editorName: the name of the PropertyEditor widget - * - toolbarId: the ID attribute of the toolbar as rendered in the DOM. - */ - initialize: function(options) { + // @todo provide a base class for formEditor/directEditor/…, migrate them away + // from editor.js + EditorView: Backbone.View.extend({ - this.model.on('change:isActive', this.render, this); - - this.editor = options.editor; - this.toolbarId = options.toolbarId; - - this.predicate = options.predicate; - }, - - /** - * - */ - render: function () { - var isActive = this.model.get('isActive'); - - if (isActive) { - this.$el.css('border-width', '2px'); - } - else { - this.$el.css('border-width', '1px'); - } - - - return this; - }, - - /** - * Listens to editor state changes. - */ - stateChange: function(from, to) { - switch (to) { - case 'inactive': - if (from !== null) { - this.undecorate(); - if (from === 'invalid') { - this._removeValidationErrors(); - } - } - break; - case 'candidate': - this.decorate(); - if (from !== 'inactive') { - this.stopHighlight(); - if (from !== 'highlighted') { - this.stopEdit(); - if (from === 'invalid') { - this._removeValidationErrors(); - } - } - } - break; - case 'highlighted': - this.startHighlight(); - break; - case 'activating': - // NOTE: this state is not used by every editor! It's only used by those - // that need to interact with the server. - this.prepareEdit(); - break; - case 'active': - if (from !== 'activating') { - this.prepareEdit(); - } - this.startEdit(); - break; - case 'changed': - break; - case 'saving': - if (from === 'invalid') { - this._removeValidationErrors(); - } - break; - case 'saved': - break; - case 'invalid': - break; - } - }, - - /** - * Starts hover: transition to 'highlight' state. - * - * @param event - */ - onMouseEnter: function(event) { - var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - var editableEntity = that.editor.options.widget; - editableEntity.setState('highlighted', that.predicate); - event.stopPropagation(); - }); - }, - - /** - * Stops hover: back to 'candidate' state. - * - * @param event - */ - onMouseLeave: function(event) { - var that = this; - this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { - var editableEntity = that.editor.options.widget; - editableEntity.setState('candidate', that.predicate, { reason: 'mouseleave' }); - event.stopPropagation(); - }); - }, - - /** - * Clicks: transition to 'activating' stage. - * - * @param event - */ - onClick: function(event) { - this.model.set('isActive', true); - }, - - decorate: function () { - this.$el.addClass('edit-animate-fast edit-candidate edit-editable'); - this.delegateEvents(); - }, - - undecorate: function () { - this.$el - .removeClass('edit-candidate edit-editable edit-highlighted edit-editing'); - }, - - startHighlight: function () { - // Animations. - var that = this; - setTimeout(function() { - that.$el.addClass('edit-highlighted'); - }, 0); - }, - - stopHighlight: function() { - this.$el - .removeClass('edit-highlighted'); - }, - - prepareEdit: function() { - this.$el.addClass('edit-editing'); - - // While editing, don't show *any* other editors. - // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) - // Revisit this. - $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); - }, - - startEdit: function() { - if (this.getEditUISetting('padding')) { - this._pad(); - } - }, - - stopEdit: function() { - this.$el.removeClass('edit-highlighted edit-editing'); - - // Make the other editors show up again. - // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) - // Revisit this. - $('.edit-candidate').addClass('edit-editable'); - - if (this.getEditUISetting('padding')) { - this._unpad(); - } - }, - - /** - * Retrieves a setting of the editor-specific Edit UI integration. - * - * @see Drupal.edit.util.getEditUISetting(). - */ - getEditUISetting: function(setting) { - return Drupal.edit.util.getEditUISetting(this.editor, setting); - }, - - _pad: function () { - var self = this; - - // Add 5px padding for readability. This means we'll freeze the current - // width and *then* add 5px padding, hence ensuring the padding is added "on - // the outside". - // 1) Freeze the width (if it's not already set); don't use animations. - if (this.$el[0].style.width === "") { - this._widthAttributeIsEmpty = true; - this.$el - .addClass('edit-animate-disable-width') - .css('width', this.$el.width()) - .css('background-color', this._getBgColor(this.$el)); - } - - // 2) Add padding; use animations. - var posProp = this._getPositionProperties(this.$el); - setTimeout(function() { - // Re-enable width animations (padding changes affect width too!). - self.$el.removeClass('edit-animate-disable-width'); - - // Pad the editable. - self.$el - .css({ - 'position': 'relative', - 'top': posProp.top - 5 + 'px', - 'left': posProp.left - 5 + 'px', - 'padding-top' : posProp['padding-top'] + 5 + 'px', - 'padding-left' : posProp['padding-left'] + 5 + 'px', - 'padding-right' : posProp['padding-right'] + 5 + 'px', - 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', - 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' - }); - }, 0); - }, - - _unpad: function () { - var self = this; - - // 1) Set the empty width again. - if (this._widthAttributeIsEmpty) { - this.$el - .addClass('edit-animate-disable-width') - .css('width', '') - .css('background-color', ''); - } - - // 2) Remove padding; use animations (these will run simultaneously with) - // the fading out of the toolbar as its gets removed). - var posProp = this._getPositionProperties(this.$el); - setTimeout(function() { - // Re-enable width animations (padding changes affect width too!). - self.$el.removeClass('edit-animate-disable-width'); - - // Unpad the editable. - self.$el - .css({ - 'position': 'relative', - 'top': posProp.top + 5 + 'px', - 'left': posProp.left + 5 + 'px', - 'padding-top' : posProp['padding-top'] - 5 + 'px', - 'padding-left' : posProp['padding-left'] - 5 + 'px', - 'padding-right' : posProp['padding-right'] - 5 + 'px', - 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', - 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' - }); - }, 0); - }, - - /** - * Gets the background color of an element (or the inherited one). - * - * @param $e - * A DOM element. - */ - _getBgColor: function($e) { - var c; - - if ($e === null || $e[0].nodeName === 'HTML') { - // Fallback to white. - return 'rgb(255, 255, 255)'; - } - c = $e.css('background-color'); - // TRICKY: edge case for Firefox' "transparent" here; this is a - // browser bug: https://bugzilla.mozilla.org/show_bug.cgi?id=635724 - if (c === 'rgba(0, 0, 0, 0)' || c === 'transparent') { - return this._getBgColor($e.parent()); - } - return c; - }, - - /** - * Gets the top and left properties of an element and convert extraneous - * values and information into numbers ready for subtraction. - * - * @param $e - * A DOM element. - */ - _getPositionProperties: function($e) { - var p, - r = {}, - props = [ - 'top', 'left', 'bottom', 'right', - 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', - 'margin-bottom' - ]; - - var propCount = props.length; - for (var i = 0; i < propCount; i++) { - p = props[i]; - r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10); - } - return r; - }, - - /** - * Replaces blank or 'auto' CSS "position: " values with "0px". - * - * @param pos - * The value for a CSS position declaration. - */ - _replaceBlankPosition: function(pos) { - if (pos === 'auto' || !pos) { - pos = '0px'; - } - return pos; - }, - - /** - * Ignores hovering to/from the given closest element, but as soon as a hover - * occurs to/from *another* element, then call the given callback. - */ - _ignoreHoveringVia: function(event, closest, callback) { - if ($(event.relatedTarget).closest(closest).length > 0) { - event.stopPropagation(); - } - else { - callback(); - } - }, - - /** - * Removes validation errors' markup changes, if any. - * - * Note: this only needs to happen for type=direct, because for type=direct, - * the property DOM element itself is modified; this is not the case for - * type=form. - */ - _removeValidationErrors: function() { - if (this.editorName !== 'form') { - this.$el - .removeClass('edit-validation-error') - .next('.edit-validation-errors') - .remove(); - } - } }) }); diff --git a/core/modules/edit/js/editable.js b/core/modules/edit/js/editable.js deleted file mode 100644 index f497f0d..0000000 --- a/core/modules/edit/js/editable.js +++ /dev/null @@ -1,395 +0,0 @@ -(function ($, Drupal, drupalSettings) { - - 'use strict'; - - Drupal.edit = Drupal.edit || {}; - - // Define Create's EditableEntity widget. - Drupal.edit.Editable = function (options) { - $.extend(this, { - editableModel: null, - entityModel: null, - vie: null, - domService: 'edit', - predicateSelector: '*', //'.edit-field.edit-allowed' - predicateModel: null, // Loaded from VIE. - // The Create.js PropertyEditor widget configuration is not hardcoded; it - // is generated by the server. - propertyEditorWidgetsConfiguration: drupalSettings.edit.editors, - // Callback function for validating changes between states. Receives the previous state, new state, possibly property, and a callback - acceptStateChange: true, - propertyEditors: {}, - // Callback function for decorating the full editable. Will be called on instantiation - decorateEditableEntity: null, - // Callback function for decorating a single property editor widget. Will - // be called on editing widget instantiation. - decoratePropertyEditor: null - }, options); - - this.initialize(); - }; - - $.extend(Drupal.edit.Editable.prototype, { - // Aids in consistently passing parameters to events and callbacks. - _params: function(predicate, extended) { - var entityParams = { - entity: this.model, - editableEntity: this, - entityElement: this.element - }; - var propertyParams = (predicate) ? { - predicate: predicate, - propertyEditor: this.propertyEditors[predicate], - propertyElement: this.propertyEditors[predicate].element, - - // Deprecated. - property: predicate, - element: this.propertyEditors[predicate].element - } : {}; - - return _.extend(entityParams, propertyParams, extended); - }, - - initialize: function () { - var that = this; - if (this.widgets) { - this.propertyEditorWidgets = _.extend(this.propertyEditorWidgets, this.widgets); - } - if (this.editors) { - this.propertyEditorWidgetsConfiguration = _.extend(this.propertyEditorWidgetsConfiguration, this.options.editors); - } - - this.domService = this.vie.service(this.domService); - this.vie - .load({ - element: this.element - }) - .from(this.domService) - .execute() - .done(function (entities) { - that.predicateModel = entities[0]; - }); - if (_.isFunction(this.decorateEditableEntity)) { - this.decorateEditableEntity(this._params()); - } - - this.predicate = this.domService.getElementPredicate(this.element); - }, - - 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); - } - }, - - get: function (property) { - return this.options[property]; - }, - - // Method used for cycling between the different states of the Editable widget: - // - // * Inactive: editable is loaded but disabled - // * Candidate: editable is enabled but not activated - // * Highlight: user is hovering over the editable (not set by Editable widget directly) - // * Activating: an editor widget is being activated for user to edit with it (skipped for editors that activate instantly) - // * Active: user is actually editing something inside the editable - // * Changed: user has made changes to the editable - // * Invalid: the contents of the editable have validation errors - // - // In situations where state changes are triggered for a particular property editor, the `predicate` - // argument will provide the name of that property. - // - // State changes may carry optional context information in a JavaScript object. The payload of these context objects is not - // standardized, and is meant to be set and used by the application controller - // - // The callback parameter is optional and will be invoked after a state change has been accepted (after the 'statechange' - // event) or rejected. - setState: function (state, predicate, context, callback) { - var previous = this.options.state; - var current = state; - if (current === previous) { - return; - } - - if (this.options.acceptStateChange === undefined || !_.isFunction(this.options.acceptStateChange)) { - // Skip state transition validation - this._doSetState(previous, current, predicate, context); - if (_.isFunction(callback)) { - callback(true); - } - return; - } - - var widget = this; - this.options.acceptStateChange(previous, current, predicate, context, function (accepted) { - if (accepted) { - widget._doSetState(previous, current, predicate, context); - } - if (_.isFunction(callback)) { - callback(accepted); - } - return; - }); - }, - - getState: function () { - return this.options.state; - }, - - _doSetState: function (previous, current, predicate, context) { - this.options.state = current; - if (current === 'inactive') { - this.disable(); - } else if ((previous === null || previous === 'inactive') && current !== 'inactive') { - this.enable(); - } - - // Huge hack. - this.options.stateChange.fire(this._params(predicate, { - previous: previous, - current: current, - context: context - })); - }, - - findEditablePredicateElements: function (callback) { - this.domService.findPredicateElements(this.options.model.id, jQuery(this.options.predicateSelector, this.element), false).each(callback); - }, - - getElementPredicate: function (element) { - return this.domService.getElementPredicate(element); - }, - - _propertyEditorName: function(data) { - // Pick a PropertyEditor widget for a property depending on its metadata. - var propertyID = Drupal.edit.util.calcPropertyID(data.entity, data.property); - return Drupal.edit.metadataCache[propertyID].editor; - }, - - enable: function () { - var editableEntity = this; - if (!this.options.model) { - return; - } - - this.findEditablePredicateElements(function () { - editableEntity._enablePropertyEditor(jQuery(this)); - }); - - // @jessebeach: Probably just a hook; not needed. - // this.options.enable.fire(this._params()); - - if (!this.vie.view || !this.vie.view.Collection) { - return; - } - - _.each(this.domService.views, function (view) { - if (view instanceof this.vie.view.Collection && this.options.model === view.owner) { - var predicate = view.collection.predicate; - var editableOptions = _.clone(this.options); - editableOptions.state = null; - var collection = this.enableCollection({ - model: this.options.model, - collection: view.collection, - property: predicate, - definition: this.getAttributeDefinition(predicate), - view: view, - element: view.el, - vie: editableEntity.vie, - editableOptions: editableOptions - }); - editableEntity.options.collections.push(collection); - } - }, this); - }, - - disable: function () { - _.each(this.options.propertyEditors, function (editable) { - this.disablePropertyEditor({ - widget: this, - editable: editable, - entity: this.options.model, - element: editable.element - }); - }, this); - this.options.propertyEditors = {}; - - // Deprecated. - this.options.editables = []; - - _.each(this.options.collections, function (collectionWidget) { - var editableOptions = _.clone(this.options); - editableOptions.state = 'inactive'; - this.disableCollection({ - widget: this, - model: this.options.model, - element: collectionWidget, - vie: this.vie, - editableOptions: editableOptions - }); - }, this); - this.options.collections = []; - - this.options.disable.fire(this._params()); - }, - - _enablePropertyEditor: function (element) { - var widget = this; - var predicate = this.getElementPredicate(element); - if (!predicate) { - return true; - } - if (this.options.model.get(predicate) instanceof Array) { - // For now we don't deal with multivalued properties in the editable - return true; - } - - var propertyElement = this.enablePropertyEditor({ - widget: this, - element: element, - entity: this.options.model, - property: predicate, - vie: this.vie, - decorate: this.options.decoratePropertyEditor, - decorateParams: _.bind(this._params, this), - changed: function (content) { - widget.setState('changed', predicate); - - var changedProperties = {}; - changedProperties[predicate] = content; - widget.options.model.set(changedProperties, { - silent: true - }); - - widget.options.changed.fire(widget._params(predicate)); - }, - activating: function () { - widget.setState('activating', predicate); - }, - activated: function () { - widget.setState('active', predicate); - jQuery(document).trigger('editStateChange', 'active', predicate); - }, - deactivated: function () { - widget.setState('candidate', predicate); - widget.options.deactivated.fire(widget._params(predicate)); - } - }); - - if (!propertyElement) { - return; - } - var widgetType = propertyElement.data('createWidgetName'); - this.options.propertyEditors[predicate] = propertyElement.data('Midgard-' + widgetType); - }, - - _propertyEditorWidget: function (editor) { - return this.options.propertyEditorWidgetsConfiguration[editor].widget; - }, - - _propertyEditorOptions: function (editor) { - return this.options.propertyEditorWidgetsConfiguration[editor].options; - }, - - getAttributeDefinition: function (property) { - var type = this.options.model.get('@type'); - if (!type) { - return; - } - if (!type.attributes) { - return; - } - return type.attributes.get(property); - }, - - // Deprecated. - enableEditor: function (data) { - return this.enablePropertyEditor(data); - }, - - enablePropertyEditor: function (data) { - var editorName = this._propertyEditorName(data); - if (editorName === null) { - return; - } - - var editorWidget = this._propertyEditorWidget(editorName); - - data.editorOptions = this._propertyEditorOptions(editorName); - data.toolbarState = this.options.toolbarState; - data.disabled = false; - // Pass metadata that could be useful for some implementations. - data.editorName = editorName; - data.editorWidget = editorWidget; - - if (typeof jQuery(data.element)[editorWidget] !== 'function') { - throw new Error(editorWidget + ' widget is not available'); - } - - jQuery(data.element)[editorWidget](data); - jQuery(data.element).data('createWidgetName', editorWidget); - return jQuery(data.element); - }, - - // Deprecated. - disableEditor: function (data) { - return this.disablePropertyEditor(data); - }, - - disablePropertyEditor: function (data) { - data.element[data.editable.widgetName]({ - disabled: true - }); - jQuery(data.element).removeClass('ui-state-disabled'); - - if (data.element.is(':focus')) { - data.element.blur(); - } - }, - - collectionWidgetName: function (data) { - if (this.options.collectionWidgets[data.property] !== undefined) { - // Widget configuration set for specific RDF predicate - return this.options.collectionWidgets[data.property]; - } - - var propertyType = 'default'; - var attributeDefinition = this.getAttributeDefinition(data.property); - if (attributeDefinition) { - propertyType = attributeDefinition.range[0]; - } - if (this.options.collectionWidgets[propertyType] !== undefined) { - return this.options.collectionWidgets[propertyType]; - } - return this.options.collectionWidgets['default']; - }, - - enableCollection: function (data) { - var widgetName = this.collectionWidgetName(data); - if (widgetName === null) { - return; - } - data.disabled = false; - if (typeof jQuery(data.element)[widgetName] !== 'function') { - throw new Error(widgetName + ' widget is not available'); - } - jQuery(data.element)[widgetName](data); - jQuery(data.element).data('createCollectionWidgetName', widgetName); - return jQuery(data.element); - }, - - disableCollection: function (data) { - var widgetName = jQuery(data.element).data('createCollectionWidgetName'); - if (widgetName === null) { - return; - } - data.disabled = true; - if (widgetName) { - // only if there has been an editing widget registered - jQuery(data.element)[widgetName](data); - jQuery(data.element).removeClass('ui-state-disabled'); - } - } - }); -})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/edit/js/editors/directEditor.js b/core/modules/edit/js/editors/directEditor.js index 1e81e4a..ebf1650 100644 --- a/core/modules/edit/js/editors/directEditor.js +++ b/core/modules/edit/js/editors/directEditor.js @@ -7,8 +7,9 @@ "use strict"; Drupal.edit = Drupal.edit || {}; +Drupal.edit.editors = Drupal.edit.editors || {}; -Drupal.edit.DirectEditor = Backbone.View.extend({ +Drupal.edit.editors.direct = Backbone.View.extend({ /** * Implements getEditUISettings() method. diff --git a/core/modules/edit/js/editors/editor.js b/core/modules/edit/js/editors/editor.js index a038ce3..e9a3696 100644 --- a/core/modules/edit/js/editors/editor.js +++ b/core/modules/edit/js/editors/editor.js @@ -3,10 +3,11 @@ 'use strict'; Drupal.edit = Drupal.edit || {}; + Drupal.edit.editors = Drupal.edit.editors || {}; - Drupal.edit.Editor = function () {}; + Drupal.edit.editors.editor = function () {}; - $.extend(Drupal.edit.Editor.prototype, { + $.extend(Drupal.edit.editors.editor.prototype, { // override to enable the widget enable: function () { this.element.attr('contenteditable', 'true'); diff --git a/core/modules/edit/js/editors/formEditor.js b/core/modules/edit/js/editors/formEditor.js index 50f30e9..11b4fbb 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -7,8 +7,9 @@ "use strict"; Drupal.edit = Drupal.edit || {}; +Drupal.edit.editors = Drupal.edit.editors || {}; -Drupal.edit.FormEditor = Backbone.View.extend({ +Drupal.edit.editors.form = Backbone.View.extend({ id: null, $formContainer: null, @@ -18,22 +19,18 @@ Drupal.edit.FormEditor = Backbone.View.extend({ getEditUISettings: function() { return { padding: false, unifiedToolbar: false, fullWidthToolbar: false }; }, + /** * */ initialize: function (options) { - this.model.on('change:isActive', this.render, this); this.model.on('change:state', this.stateChange, this); - var propertyId = this.model.get('propertyId'); - this.entity = options.predicateModel; - this.predicate = this.property = this.model.get('predicate'); - this.editorName = options.editorName; - this.vie = options.vie; - // Generate a DOM-compatible ID for the form container DOM element. - this.elementId = 'edit-form-for-' + propertyId.replace(/\//g, '_'); + this.elementId = 'edit-form-for-' + this.model.get('editID').replace(/\//g, '_'); }, + + render: function () { var isActive = this.model.get('isActive'); if (isActive) { @@ -199,15 +196,21 @@ Drupal.edit.FormEditor = Backbone.View.extend({ /** * Makes this PropertyEditor widget react to state changes. */ - stateChange: function(model, value) { - switch (value) { + 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; diff --git a/core/modules/edit/js/viejs/EditService.js b/core/modules/edit/js/viejs/EditService.js deleted file mode 100644 index 00cb04b..0000000 --- a/core/modules/edit/js/viejs/EditService.js +++ /dev/null @@ -1,289 +0,0 @@ -/** - * @file - * VIE DOM parsing service for Edit. - */ -(function(jQuery, _, VIE, Drupal, drupalSettings) { - -"use strict"; - - VIE.prototype.EditService = function (options) { - var defaults = { - name: 'edit', - subjectSelector: '.edit-field.edit-allowed' - }; - this.options = _.extend({}, defaults, options); - - this.views = []; - this.vie = null; - this.name = this.options.name; - }; - - VIE.prototype.EditService.prototype = { - load: function (loadable) { - var correct = loadable instanceof this.vie.Loadable; - if (!correct) { - throw new Error('Invalid Loadable passed'); - } - - var element; - if (!loadable.options.element) { - if (typeof document === 'undefined') { - return loadable.resolve([]); - } else { - element = drupalSettings.edit.context; - } - } else { - element = loadable.options.element; - } - - var entities = this.readEntities(element); - loadable.resolve(entities); - }, - - _getViewForElement:function (element, collectionView) { - var viewInstance; - - jQuery.each(this.views, function () { - if (jQuery(this.el).get(0) === element.get(0)) { - if (collectionView && !this.template) { - return true; - } - viewInstance = this; - return false; - } - }); - return viewInstance; - }, - - _registerEntityView:function (entity, element, isNew) { - if (!element.length) { - return; - } - - // Let's only have this overhead for direct types. Form-based editors are - // handled in backbone.drupalform.js and the PropertyEditor instance. - if (jQuery(element).hasClass('edit-type-form')) { - return; - } - - var service = this; - var viewInstance = this._getViewForElement(element); - if (viewInstance) { - return viewInstance; - } - - viewInstance = new this.vie.view.Entity({ - model:entity, - el:element, - tagName:element.get(0).nodeName, - vie:this.vie, - service:this.name - }); - - this.views.push(viewInstance); - - return viewInstance; - }, - - save: function(saveable) { - var correct = saveable instanceof this.vie.Savable; - if (!correct) { - throw "Invalid Savable passed"; - } - - if (!saveable.options.element) { - // FIXME: we could find element based on subject - throw "Unable to write entity to edit.module-markup, no element given"; - } - - if (!saveable.options.entity) { - throw "Unable to write to edit.module-markup, no entity given"; - } - - var $element = jQuery(saveable.options.element); - this._writeEntity(saveable.options.entity, saveable.options.element); - saveable.resolve(); - }, - - _writeEntity:function (entity, element) { - var service = this; - this.findPredicateElements(this.getElementSubject(element), element, true).each(function () { - var predicateElement = jQuery(this); - var predicate = service.getElementPredicate(predicateElement); - if (!entity.has(predicate)) { - return true; - } - - var value = entity.get(predicate); - if (value && value.isCollection) { - // Handled by CollectionViews separately - return true; - } - if (value === service.readElementValue(predicate, predicateElement)) { - return true; - } - // Unlike in the VIE's RdfaService no (re-)mapping needed here. - predicateElement.html(value); - }); - return true; - }, - - // The edit-id data attribute contains the full identifier of - // each entity element in the format - // `::::`. - _getID: function (element) { - var id = jQuery(element).attr('data-edit-id'); - if (!id) { - id = jQuery(element).closest('[data-edit-id]').attr('data-edit-id'); - } - return id; - }, - - // Returns the "URI" of an entity of an element in format - // `/`. - getElementSubject: function (element) { - return this._getID(element).split('/').slice(0, 2).join('/'); - }, - - // Returns the field name for an element in format - // `//`. - // (Slashes instead of colons because the field name is no namespace.) - getElementPredicate: function (element) { - if (!this._getID(element)) { - throw new Error('Could not find predicate for element'); - } - return this._getID(element).split('/').slice(2, 5).join('/'); - }, - - getElementType: function (element) { - return this._getID(element).split('/').slice(0, 1)[0]; - }, - - // Reads all editable entities (currently each Drupal field is considered an - // entity, in the future Drupal entities should be mapped to VIE entities) - // from DOM and returns the VIE enties it found. - readEntities: function (element) { - var service = this; - var entities = []; - var entityElements = jQuery(this.options.subjectSelector, element); - entityElements = entityElements.add(jQuery(element).filter(this.options.subjectSelector)); - entityElements.each(function () { - var entity = service._readEntity(jQuery(this)); - if (entity) { - entities.push(entity); - } - }); - return entities; - }, - - // Returns a filled VIE Entity instance for a DOM element. The Entity - // is also registered in the VIE entities collection. - _readEntity: function (element) { - var subject = this.getElementSubject(element); - var type = this.getElementType(element); - var entity = this._readEntityPredicates(subject, element, false); - if (jQuery.isEmptyObject(entity)) { - return null; - } - entity['@subject'] = subject; - if (type) { - entity['@type'] = this._registerType(type, element); - } - - var entityInstance = new this.vie.Entity(entity); - entityInstance = this.vie.entities.addOrUpdate(entityInstance, { - updateOptions: { - silent: true, - ignoreChanges: true - } - }); - - this._registerEntityView(entityInstance, element); - return entityInstance; - }, - - _registerType: function (typeId, element) { - typeId = ''; - var type = this.vie.types.get(typeId); - if (!type) { - this.vie.types.add(typeId, []); - type = this.vie.types.get(typeId); - } - - var predicate = this.getElementPredicate(element); - if (type.attributes.get(predicate)) { - return type; - } - var range = predicate.split('/')[0]; - type.attributes.add(predicate, [range], 0, 1, { - label: element.data('edit-field-label') - }); - - return type; - }, - - _readEntityPredicates: function (subject, element, emptyValues) { - var entityPredicates = {}; - var service = this; - this.findPredicateElements(subject, element, true).each(function () { - var predicateElement = jQuery(this); - var predicate = service.getElementPredicate(predicateElement); - if (!predicate) { - return; - } - var value = service.readElementValue(predicate, predicateElement); - if (value === null && !emptyValues) { - return; - } - - entityPredicates[predicate] = value; - entityPredicates[predicate + '/rendered'] = predicateElement[0].outerHTML; - }); - return entityPredicates; - }, - - readElementValue : function(predicate, element) { - // Unlike in RdfaService there is parsing needed here. - if (element.hasClass('edit-type-form')) { - return undefined; - } - else { - return jQuery.trim(element.html()); - } - }, - - // Subject elements are the DOM elements containing a single or multiple - // editable fields. - findSubjectElements: function (element) { - if (!element) { - element = drupalSettings.edit.context; - } - return jQuery(this.options.subjectSelector, element); - }, - - // Predicate Elements are the actual DOM elements that users will be able - // to edit. - findPredicateElements: function (subject, element, allowNestedPredicates, stop) { - var predicates = jQuery(); - // Make sure that element is wrapped by jQuery. - var $element = jQuery(element); - - // Form-type predicates - predicates = predicates.add($element.filter('.edit-type-form')); - - // Direct-type predicates - var direct = $element.filter('.edit-type-direct'); - predicates = predicates.add(direct.find('.field-item')); - - if (!predicates.length && !stop) { - var parentElement = $element.parent(this.options.subjectSelector); - if (parentElement.length) { - return this.findPredicateElements(subject, parentElement, allowNestedPredicates, true); - } - } - - return predicates; - } - }; - -})(jQuery, _, VIE, Drupal, drupalSettings); diff --git a/core/modules/edit/js/views/contextuallink-view.js b/core/modules/edit/js/views/contextuallink-view.js new file mode 100644 index 0000000..7de0472 --- /dev/null +++ b/core/modules/edit/js/views/contextuallink-view.js @@ -0,0 +1,122 @@ +/** + * @file + * A Backbone View that a dynamic contextual link. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit.ContextualLinkView = Backbone.View.extend({ + + entity: null, + + events: { + 'click .quick-edit a': 'onClick' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - appModel: the application state model + * - strings: the strings for the "Quick edit" link + */ + initialize: function (options) { + this.appModel = options.appModel; + this.strings = options.strings; + + // Build the DOM elements. + this.$el + .find('li.node-edit, li.taxonomy-edit, li.comment-edit, li.custom-block-edit') + .before('
  • '); + + // Initial render. + this.render(); + + // Re-render whenever this entity's isActive attribute changes. + this.model.on('change:isActive', this.render, this); + + // Hide the contextual links whenever an in-place editor is active. + this.appModel.on('change:activeEditor', this.toggleContextualLinksVisibility, this); + }, + + /** + * Equates clicks anywhere on the overlay to clicking the active editor's (if + * any) "close" button. + * + * @param {Object} event + */ + onClick: function (event) { + event.preventDefault(); + + var that = this; + var updateActiveEntity = function() { + // The active entity is the current entity, i.e. stop editing the current + // entity. + if (that.model.get('isActive') === true) { + that.model.set('isActive', false); + } + // The active entity is different from the current entity, i.e. start + // editing this entity instead of the previous one. + else { + // Stop editing the currently active entity, if any. + var currentlyActiveEntity = that.model.collection.where({ isActive: true }); + if (currentlyActiveEntity.length > 0) { + currentlyActiveEntity[0].set('isActive', false); + } + + // Start the editing of this entity. + that.model.set('isActive', true); + } + }; + + // If there's an active editor, attempt to set its state to 'candidate', and + // only then do what the user asked. + // (Only when all PropertyEditor widgets of an entity are in the 'candidate' + // state, it is possible to stop editing it.) + var activeEditor = this.model.get('activeEditor'); + if (activeEditor) { + debugger; + // @todo this branch has not yet been updated. + var editableEntity = activeEditor.options.widget; + var predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'stop or switch' }, function(accepted) { + if (accepted) { + updateActiveEntity(); + } + else { + // No change. + } + }); + } + // Otherwise, we can immediately do what the user asked. + else { + updateActiveEntity(); + } + }, + + /** + * Render the "Quick edit" contextual link. + */ + render: function () { + var isActive = this.model.get('isActive'); + this.$el.find('.quick-edit a').text((!isActive) ? this.strings.quickEdit : this.strings.stopQuickEdit); + return this; + }, + + /** + * Model change handler; hides the contextual links if an editor is active. + * + * @param Drupal.edit.models.EditAppModel model + * An EditAppModel model. + * @param jQuery|null activeEditor + * The active in-place editor (jQuery object) or, if none, null. + */ + toggleContextualLinksVisibility: function (model, activeEditor) { + this.$el.parents('.contextual').toggle(activeEditor === null); + } + +}); + +})(jQuery, _, Backbone, Drupal); diff --git a/core/modules/edit/js/views/modal-view.js b/core/modules/edit/js/views/modal-view.js new file mode 100644 index 0000000..51ec5ce --- /dev/null +++ b/core/modules/edit/js/views/modal-view.js @@ -0,0 +1,81 @@ +/** + * @file + * A Backbone View that provides an interactive modal. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit.ModalView = Backbone.View.extend({ + + message: null, + buttons: null, + callback: null, + $elementsToHide: null, + + events: { + 'click button': 'onButtonClick' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - message: a message to show in the modal. + * - buttons: a set of buttons with 'action's defined, ready to be passed to + * Drupal.theme.editButtons(). + * - callback: a callback that will receive the 'action' of the clicked + * button. + * + * @see Drupal.theme.editModal() + * @see Drupal.theme.editButtons() + */ + initialize: function(options) { + this.message = options.message; + this.buttons = options.buttons; + this.callback = options.callback; + }, + + /** + * Implements Backbone Views' render() function. + */ + render: function() { + this.setElement(Drupal.theme('editModal', {})); + this.$el.appendTo('body'); + // Template. + this.$('.main p').text(this.message); + var $actions = $(Drupal.theme('editButtons', { 'buttons' : this.buttons})); + this.$('.actions').append($actions); + + // Show the modal with an animation. + var that = this; + setTimeout(function() { + that.$el.removeClass('edit-animate-invisible'); + }, 0); + }, + + /** + * When the user clicks on any of the buttons, the modal should be removed + * and the result should be passed to the callback. + * + * @param event + */ + onButtonClick: function(event) { + event.stopPropagation(); + event.preventDefault(); + + // Remove after animation. + var that = this; + this.$el + .addClass('edit-animate-invisible') + .on(Drupal.edit.util.constants.transitionEnd, function(e) { + that.remove(); + }); + + var action = $(event.target).attr('data-edit-modal-action'); + return this.callback(action); + } +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/propertyeditordecoration-view.js b/core/modules/edit/js/views/propertyeditordecoration-view.js new file mode 100644 index 0000000..afef6c1 --- /dev/null +++ b/core/modules/edit/js/views/propertyeditordecoration-view.js @@ -0,0 +1,352 @@ +/** + * @file + * A Backbone View that decorates a Property Editor widget. + * + * It listens to state changes of the property editor. + */ +(function($, Backbone, Drupal) { + +"use strict"; + +Drupal.edit.PropertyEditorDecorationView = Backbone.View.extend({ + toolbarId: null, + + _widthAttributeIsEmpty: null, + + events: { + 'mouseenter.edit' : 'onMouseEnter', + 'mouseleave.edit' : 'onMouseLeave', + 'click': 'onClick', + 'tabIn.edit': 'onMouseEnter', + 'tabOut.edit': 'onMouseLeave' + }, + + /** + * Implements Backbone Views' initialize() function. + * + * @param options + * An object with the following keys: + * - editor: the editor object with an 'options' object that has these keys: + * * entity: the VIE entity for the property. + * * property: the predicate of the property. + * * widget: the parent EditableEntity widget. + * * editorName: the name of the PropertyEditor widget + * - toolbarId: the ID attribute of the toolbar as rendered in the DOM. + */ + initialize: function(options) { + this.model.on('change:state', this.stateChange, this); + + this.editor = options.editor; + this.toolbarId = options.toolbarId; + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + if (from !== null) { + this.undecorate(); + if (from === 'invalid') { + this._removeValidationErrors(); + } + } + break; + case 'candidate': + this.decorate(); + if (from !== 'inactive') { + this.stopHighlight(); + if (from !== 'highlighted') { + this.stopEdit(); + if (from === 'invalid') { + this._removeValidationErrors(); + } + } + } + break; + case 'highlighted': + this.startHighlight(); + break; + case 'activating': + // NOTE: this state is not used by every editor! It's only used by those + // that need to interact with the server. + this.prepareEdit(); + break; + case 'active': + if (from !== 'activating') { + this.prepareEdit(); + } + this.startEdit(); + break; + case 'changed': + break; + case 'saving': + if (from === 'invalid') { + this._removeValidationErrors(); + } + break; + case 'saved': + break; + case 'invalid': + break; + } + }, + + /** + * Starts hover: transition to 'highlight' state. + * + * @param event + */ + onMouseEnter: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + that.model.setState('highlighted'); + event.stopPropagation(); + }); + }, + + /** + * Stops hover: back to 'candidate' state. + * + * @param event + */ + onMouseLeave: function(event) { + var that = this; + this._ignoreHoveringVia(event, '#' + this.toolbarId, function () { + that.model.setState('candidate', { reason: 'mouseleave' }); + event.stopPropagation(); + }); + }, + + /** + * Clicks: transition to 'activating' stage. + * + * @param event + */ + onClick: function(event) { + this.model.set('isActive', true); + }, + + decorate: function () { + this.$el.addClass('edit-animate-fast edit-candidate edit-editable'); + this.delegateEvents(); + }, + + undecorate: function () { + this.$el + .removeClass('edit-candidate edit-editable edit-highlighted edit-editing'); + }, + + startHighlight: function () { + // Animations. + var that = this; + setTimeout(function() { + that.$el.addClass('edit-highlighted'); + }, 0); + }, + + stopHighlight: function() { + this.$el + .removeClass('edit-highlighted'); + }, + + prepareEdit: function() { + this.$el.addClass('edit-editing'); + + // While editing, don't show *any* other editors. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').not('.edit-editing').removeClass('edit-editable'); + }, + + startEdit: function() { + if (this.getEditUISetting('padding')) { + this._pad(); + } + }, + + stopEdit: function() { + this.$el.removeClass('edit-highlighted edit-editing'); + + // Make the other editors show up again. + // @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133) + // Revisit this. + $('.edit-candidate').addClass('edit-editable'); + + if (this.getEditUISetting('padding')) { + this._unpad(); + } + }, + + /** + * Retrieves a setting of the editor-specific Edit UI integration. + * + * @see Drupal.edit.util.getEditUISetting(). + */ + getEditUISetting: function(setting) { + return Drupal.edit.util.getEditUISetting(this.editor, setting); + }, + + _pad: function () { + var self = this; + + // Add 5px padding for readability. This means we'll freeze the current + // width and *then* add 5px padding, hence ensuring the padding is added "on + // the outside". + // 1) Freeze the width (if it's not already set); don't use animations. + if (this.$el[0].style.width === "") { + this._widthAttributeIsEmpty = true; + this.$el + .addClass('edit-animate-disable-width') + .css('width', this.$el.width()) + .css('background-color', this._getBgColor(this.$el)); + } + + // 2) Add padding; use animations. + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Pad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top - 5 + 'px', + 'left': posProp.left - 5 + 'px', + 'padding-top' : posProp['padding-top'] + 5 + 'px', + 'padding-left' : posProp['padding-left'] + 5 + 'px', + 'padding-right' : posProp['padding-right'] + 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] + 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] - 10 + 'px' + }); + }, 0); + }, + + _unpad: function () { + var self = this; + + // 1) Set the empty width again. + if (this._widthAttributeIsEmpty) { + this.$el + .addClass('edit-animate-disable-width') + .css('width', '') + .css('background-color', ''); + } + + // 2) Remove padding; use animations (these will run simultaneously with) + // the fading out of the toolbar as its gets removed). + var posProp = this._getPositionProperties(this.$el); + setTimeout(function() { + // Re-enable width animations (padding changes affect width too!). + self.$el.removeClass('edit-animate-disable-width'); + + // Unpad the editable. + self.$el + .css({ + 'position': 'relative', + 'top': posProp.top + 5 + 'px', + 'left': posProp.left + 5 + 'px', + 'padding-top' : posProp['padding-top'] - 5 + 'px', + 'padding-left' : posProp['padding-left'] - 5 + 'px', + 'padding-right' : posProp['padding-right'] - 5 + 'px', + 'padding-bottom': posProp['padding-bottom'] - 5 + 'px', + 'margin-bottom': posProp['margin-bottom'] + 10 + 'px' + }); + }, 0); + }, + + /** + * Gets the background color of an element (or the inherited one). + * + * @param $e + * A DOM element. + */ + _getBgColor: function($e) { + var c; + + if ($e === null || $e[0].nodeName === 'HTML') { + // Fallback to white. + return 'rgb(255, 255, 255)'; + } + c = $e.css('background-color'); + // TRICKY: edge case for Firefox' "transparent" here; this is a + // browser bug: https://bugzilla.mozilla.org/show_bug.cgi?id=635724 + if (c === 'rgba(0, 0, 0, 0)' || c === 'transparent') { + return this._getBgColor($e.parent()); + } + return c; + }, + + /** + * Gets the top and left properties of an element and convert extraneous + * values and information into numbers ready for subtraction. + * + * @param $e + * A DOM element. + */ + _getPositionProperties: function($e) { + var p, + r = {}, + props = [ + 'top', 'left', 'bottom', 'right', + 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', + 'margin-bottom' + ]; + + var propCount = props.length; + for (var i = 0; i < propCount; i++) { + p = props[i]; + r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10); + } + return r; + }, + + /** + * Replaces blank or 'auto' CSS "position: " values with "0px". + * + * @param pos + * The value for a CSS position declaration. + */ + _replaceBlankPosition: function(pos) { + if (pos === 'auto' || !pos) { + pos = '0px'; + } + return pos; + }, + + /** + * Ignores hovering to/from the given closest element, but as soon as a hover + * occurs to/from *another* element, then call the given callback. + */ + _ignoreHoveringVia: function(event, closest, callback) { + if ($(event.relatedTarget).closest(closest).length > 0) { + event.stopPropagation(); + } + else { + callback(); + } + }, + + /** + * Removes validation errors' markup changes, if any. + * + * Note: this only needs to happen for type=direct, because for type=direct, + * the property DOM element itself is modified; this is not the case for + * type=form. + */ + _removeValidationErrors: function() { + if (this.editorName !== 'form') { + this.$el + .removeClass('edit-validation-error') + .next('.edit-validation-errors') + .remove(); + } + } + +}); + +})(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/views/toolbar-view.js b/core/modules/edit/js/views/toolbar-view.js new file mode 100644 index 0000000..79455f1 --- /dev/null +++ b/core/modules/edit/js/views/toolbar-view.js @@ -0,0 +1,397 @@ +/** + * @file + * A Backbone View that provides an interactive toolbar (1 per property editor). + * + * It listens to state changes of the property editor. It also triggers state + * changes in response to user interactions with the toolbar, including saving. + */ +(function ($, _, Backbone, Drupal) { + +"use strict"; + +Drupal.edit.ToolbarView = Backbone.View.extend({ + editor: null, + + _loader: null, + _loaderVisibleStart: 0, + + _id: null, + + events: { + 'click.edit button.label': 'onClickInfoLabel', + 'mouseleave.edit': 'onMouseLeave', + 'click.edit button.field-save': 'onClickSave', + 'click.edit button.field-close': 'onClickClose' + }, + + /** + * Implements Backbone Views' initialize() function. + */ + initialize: function(options) { + this.editor = options.editor; + + this._loader = null; + this._loaderVisibleStart = 0; + + // Generate a DOM-compatible ID for the form container DOM element. + this.elementId = 'edit-toolbar-for-' + this.model.get('editID').replace(/\//g, '_'); + + this.model.on('change:state', this.stateChange, this); + }, + + /** + * Renders the Toolbar's markup into the DOM. + * + * Note: depending on whether the 'display' property of the $el for which a + * toolbar is being inserted into the DOM, it will be inserted differently. + */ + render: function () { + // Render toolbar. + this.setElement($(Drupal.theme('editToolbarContainer', { + id: this.elementId + }))); + + // Insert in DOM. + var $fieldElement = this.editor.$el; + if ($fieldElement.css('display') === 'inline') { + this.$el.prependTo($fieldElement.offsetParent()); + var pos = $fieldElement.position(); + this.$el.css('left', pos.left).css('top', pos.top); + } + else { + this.$el.insertBefore($fieldElement); + } + + return this; + }, + + /** + * Listens to editor state changes. + */ + stateChange: function(model, state) { + var from = model.previous('state'); + var to = state; + switch (to) { + case 'inactive': + if (from) { + this.remove(); + if (this.model.get('editor') !== 'form') { + Backbone.syncDirectCleanUp(); + } + } + break; + case 'candidate': + if (from === 'inactive') { + this.render(); + } + else { + if (this.model.get('editor') !== 'form') { + Backbone.syncDirectCleanUp(); + } + // Remove all toolgroups; they're no longer necessary. + this.$el + .removeClass('edit-highlighted edit-editing') + .find('.edit-toolbar .edit-toolgroup').remove(); + if (from !== 'highlighted' && this.getEditUISetting('padding')) { + this._unpad(); + } + } + break; + case 'highlighted': + // As soon as we highlight, make sure we have a toolbar in the DOM (with at least a title). + this.startHighlight(); + break; + case 'activating': + this.setLoadingIndicator(true); + break; + case 'active': + this.startEdit(); + this.setLoadingIndicator(false); + if (this.getEditUISetting('fullWidthToolbar')) { + this.$el.addClass('edit-toolbar-fullwidth'); + } + + if (this.getEditUISetting('padding')) { + this._pad(); + } + if (this.getEditUISetting('unifiedToolbar')) { + this.insertWYSIWYGToolGroups(); + } + break; + case 'changed': + this.$el + .find('button.save') + .addClass('blue-button') + .removeClass('gray-button'); + break; + case 'saving': + this.setLoadingIndicator(true); + break; + case 'saved': + this.setLoadingIndicator(false); + break; + case 'invalid': + this.setLoadingIndicator(false); + break; + } + }, + + /** + * When the user clicks the info label, nothing should happen. + * @note currently redirects the click.edit-event to the editor DOM element. + * + * @param event + */ + onClickInfoLabel: function(event) { + event.stopPropagation(); + event.preventDefault(); + // Redirects the event to the editor DOM element. + this.editor.element.trigger('click.edit'); + }, + + /** + * A mouseleave to the editor doesn't matter; a mouseleave to something else + * counts as a mouseleave on the editor itself. + * + * @param event + */ + onMouseLeave: function(event) { + var el = this.editor.element[0]; + if (event.relatedTarget != el && !$.contains(el, event.relatedTarget)) { + this.editor.element.trigger('mouseleave.edit'); + } + event.stopPropagation(); + }, + + /** + * Upon clicking "Save", trigger a custom event to save this property. + * + * @param event + */ + onClickSave: function(event) { + this.model.set('state', 'saving'); + }, + + /** + * Upon clicking "Close", trigger a custom event to stop editing. + * + * @param event + */ + onClickClose: function(event) { + event.stopPropagation(); + event.preventDefault(); + this.editor.options.widget.setState('candidate', this.predicate, { reason: 'cancel' }); + }, + + /** + * Indicates in the 'info' toolgroup that we're waiting for a server reponse. + * + * Prevents flickering loading indicator by only showing it after 0.6 seconds + * and if it is shown, only hiding it after another 0.6 seconds. + * + * @param bool enabled + * Whether the loading indicator should be displayed or not. + */ + setLoadingIndicator: function(enabled) { + var that = this; + if (enabled) { + this._loader = setTimeout(function() { + that.addClass('info', 'loading'); + that._loaderVisibleStart = new Date().getTime(); + }, 600); + } + else { + var currentTime = new Date().getTime(); + clearTimeout(this._loader); + if (this._loaderVisibleStart) { + setTimeout(function() { + that.removeClass('info', 'loading'); + }, this._loaderVisibleStart + 600 - currentTime); + } + this._loader = null; + this._loaderVisibleStart = 0; + } + }, + + startHighlight: function() { + // Retrieve the lavel to show for this field. + var label = this.model.get('label'); + + this.$el + .addClass('edit-highlighted') + .find('.edit-toolbar') + // Append the "info" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'info edit-animate-only-background-and-padding', + buttons: [ + { label: label, classes: 'blank-button label' } + ] + })); + + // Animations. + var that = this; + setTimeout(function () { + that.show('info'); + }, 0); + }, + + startEdit: function() { + this.$el + .addClass('edit-editing') + .find('.edit-toolbar') + // Append the "ops" toolgroup into the toolbar. + .append(Drupal.theme('editToolgroup', { + classes: 'ops', + buttons: [ + { label: Drupal.t('Save'), type: 'submit', classes: 'field-save save gray-button' }, + { label: '' + Drupal.t('Close') + '', classes: 'field-close close gray-button' } + ] + })); + this.show('ops'); + }, + + /** + * Retrieves a setting of the editor-specific Edit UI integration. + * + * @see Drupal.edit.util.getEditUISetting(). + */ + getEditUISetting: function(setting) { + return Drupal.edit.util.getEditUISetting(this.editor, setting); + }, + + /** + * Adjusts the toolbar to accomodate padding on the PropertyEditor widget. + * + * @see PropertyEditorDecorationView._pad(). + */ + _pad: function() { + // The whole toolbar must move to the top when the property's DOM element + // is displayed inline. + if (this.editor.element.css('display') === 'inline') { + this.$el.css('top', parseInt(this.$el.css('top'), 10) - 5 + 'px'); + } + + // The toolbar must move to the top and the left. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '6px', left: '-5px' }); + + if (this.getEditUISetting('fullWidthToolbar')) { + $hf.css({ width: this.editor.element.width() + 10 }); + } + }, + + /** + * Undoes the changes made by _pad(). + * + * @see PropertyEditorDecorationView._unpad(). + */ + _unpad: function() { + // Move the toolbar back to its original position. + var $hf = this.$el.find('.edit-toolbar-heightfaker'); + $hf.css({ bottom: '1px', left: '' }); + + if (this.getEditUISetting('fullWidthToolbar')) { + $hf.css({ width: '' }); + } + }, + + insertWYSIWYGToolGroups: function() { + this.$el + .find('.edit-toolbar') + .append(Drupal.theme('editToolgroup', { + id: this.getFloatedWysiwygToolgroupId(), + classes: 'wysiwyg-floated', + buttons: [] + })) + .append(Drupal.theme('editToolgroup', { + id: this.getMainWysiwygToolgroupId(), + classes: 'wysiwyg-main', + buttons: [] + })); + + // Animate the toolgroups into visibility. + var that = this; + setTimeout(function () { + that.show('wysiwyg-floated'); + that.show('wysiwyg-main'); + }, 0); + }, + + /** + * Retrieves the ID for this toolbar's container. + * + * Only used to make sane hovering behavior possible. + * + * @return string + * A string that can be used as the ID for this toolbar's container. + */ + getId: function () { + return 'edit-toolbar-for-' + this._id; + }, + + /** + * Retrieves the ID for this toolbar's floating WYSIWYG toolgroup. + * + * Used to provide an abstraction for any WYSIWYG editor to plug in. + * + * @return string + * A string that can be used as the ID. + */ + getFloatedWysiwygToolgroupId: function () { + return 'edit-wysiwyg-floated-toolgroup-for-' + this._id; + }, + + /** + * Retrieves the ID for this toolbar's main WYSIWYG toolgroup. + * + * Used to provide an abstraction for any WYSIWYG editor to plug in. + * + * @return string + * A string that can be used as the ID. + */ + getMainWysiwygToolgroupId: function () { + return 'edit-wysiwyg-main-toolgroup-for-' + this._id; + }, + + /** + * Shows a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + show: function (toolgroup) { + this._find(toolgroup).removeClass('edit-animate-invisible'); + }, + + /** + * Adds classes to a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + addClass: function (toolgroup, classes) { + this._find(toolgroup).addClass(classes); + }, + + /** + * Removes classes from a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + removeClass: function (toolgroup, classes) { + this._find(toolgroup).removeClass(classes); + }, + + /** + * Finds a toolgroup. + * + * @param string toolgroup + * A toolgroup name. + */ + _find: function (toolgroup) { + return this.$el.find('.edit-toolbar .edit-toolgroup.' + toolgroup); + } +}); + +})(jQuery, _, Backbone, Drupal);