drupal/core/modules/edit/js/app.js

392 lines
14 KiB
JavaScript

/**
* @file
* A Backbone View that is the central app controller.
*/
(function ($, _, Backbone, Drupal, VIE) {
"use strict";
Drupal.edit = Drupal.edit || {};
Drupal.edit.EditAppView = Backbone.View.extend({
vie: null,
domService: null,
// Configuration for state handling.
states: [],
activeEditorStates: [],
singleEditorStates: [],
// State.
$entityElements: null,
/**
* Implements Backbone Views' initialize() function.
*/
initialize: function() {
_.bindAll(this, 'appStateChange', 'acceptEditorStateChange', 'editorStateChange');
// VIE instance for Edit.
this.vie = new VIE();
// Use our custom DOM parsing service until RDFa is available.
this.vie.use(new this.vie.EditService());
this.domService = this.vie.service('edit');
// Instantiate configuration for state handling.
this.states = [
null, 'inactive', 'candidate', 'highlighted',
'activating', 'active', 'changed', 'saving', 'saved', 'invalid'
];
this.activeEditorStates = ['activating', 'active'];
this.singleEditorStates = _.union(['highlighted'], this.activeEditorStates);
this.$entityElements = $([]);
// Use Create's Storage widget.
this.$el.createStorage({
vie: this.vie,
editableNs: 'createeditable'
});
// When view/edit mode is toggled in the menu, update the editor widgets.
this.model.on('change:activeEntity', this.appStateChange);
},
/**
* Finds editable properties within a given context.
*
* Finds editable properties, registers them with the app, updates their
* state to match the current app state.
*
* @param $context
* A jQuery-wrapped context DOM element within which will be searched.
*/
findEditableProperties: function($context) {
var that = this;
var activeEntity = this.model.get('activeEntity');
this.domService.findSubjectElements($context).each(function() {
var $element = $(this);
// Ignore editable properties for which we've already set up Create.js.
if (that.$entityElements.index($element) !== -1) {
return;
}
$element
// Instantiate an EditableEntity widget.
.createEditable({
vie: that.vie,
disabled: true,
state: 'inactive',
acceptStateChange: that.acceptEditorStateChange,
statechange: function(event, data) {
that.editorStateChange(data.previous, data.current, data.propertyEditor);
},
decoratePropertyEditor: function(data) {
that.decorateEditor(data.propertyEditor);
}
})
// This event is triggered just before Edit removes an EditableEntity
// widget, so that we can do proper clean-up.
.on('destroyedPropertyEditor.edit', function(event, editor) {
that.undecorateEditor(editor);
that.$entityElements = that.$entityElements.not($(this));
})
// Transition the new PropertyEditor into the default state.
.createEditable('setState', 'inactive');
// 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.)
var entityOfProperty = $element.createEditable('option', 'model');
if (entityOfProperty.getSubjectUri() === activeEntity) {
$element.createEditable('setState', 'candidate');
}
// Add this new EditableEntity widget element to the list.
that.$entityElements = that.$entityElements.add($element);
});
},
/**
* Sets the state of PropertyEditor widgets when edit mode begins or ends.
*
* Should be called whenever EditAppModel's "activeEntity" 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 activeEntity = this.model.get('activeEntity');
var $editableFieldsForEntity = $('[data-edit-id^="' + activeEntity + '/"]');
// First, change the status of all PropertyEditor widgets to 'inactive'.
this.$entityElements.each(function() {
$(this).createEditable('setState', 'inactive', null, {reason: 'stop'});
});
// Then, change the status of PropertyEditor widgets of the currently
// active entity to 'candidate'.
$editableFieldsForEntity.each(function() {
$(this).createEditable('setState', 'candidate');
});
// Manage the page's tab indexes.
},
/**
* Accepts or reject editor (PropertyEditor) state changes.
*
* This is what ensures that the app is in control of what happens.
*
* @param from
* The previous state.
* @param to
* The new state.
* @param predicate
* The predicate of the property for which the state change is happening.
* @param context
* The context that is trying to trigger the state change.
* @param callback
* The callback function that should receive the state acceptance result.
*/
acceptEditorStateChange: function(from, to, predicate, context, callback) {
var accept = true;
// If the app is in view mode, then reject all state changes except for
// those to 'inactive'.
if (context && context.reason === 'stop') {
if (from === 'candidate' && to === 'inactive') {
accept = true;
}
}
// Handling of edit mode state changes is more granular.
else {
// In general, enforce the states sequence. Disallow going back from a
// "later" state to an "earlier" state, except in explicitly allowed
// cases.
if (_.indexOf(this.states, from) > _.indexOf(this.states, to)) {
accept = false;
// Allow: activating/active -> candidate.
// Necessary to stop editing a property.
if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') {
accept = true;
}
// Allow: changed/invalid -> candidate.
// Necessary to stop editing a property when it is changed or invalid.
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
accept = true;
}
// Allow: highlighted -> candidate.
// Necessary to stop highlighting a property.
else if (from === 'highlighted' && to === 'candidate') {
accept = true;
}
// Allow: saved -> candidate.
// Necessary when successfully saved a property.
else if (from === 'saved' && to === 'candidate') {
accept = true;
}
// Allow: invalid -> saving.
// Necessary to be able to save a corrected, invalid property.
else if (from === 'invalid' && to === 'saving') {
accept = true;
}
}
// If it's not against the general principle, then here are more
// disallowed cases to check.
if (accept) {
// Ensure only one editor (field) at a time may be higlighted or active.
if (from === 'candidate' && _.indexOf(this.singleEditorStates, to) !== -1) {
if (this.model.get('highlightedEditor') || this.model.get('activeEditor')) {
accept = false;
}
}
// Reject going from activating/active to candidate because of a
// mouseleave.
else if (_.indexOf(this.activeEditorStates, from) !== -1 && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
}
}
// When attempting to stop editing a changed/invalid property, ask for
// confirmation.
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
}
else {
// Check whether the transition has been confirmed?
if (context && context.confirmed) {
accept = true;
}
// Confirm this transition.
else {
// The callback will be called from the helper function.
this._confirmStopEditing(callback);
return;
}
}
}
}
}
callback(accept);
},
/**
* Asks the user to confirm whether he wants to stop editing via a modal.
*
* @param acceptCallback
* The callback function as passed to acceptEditorStateChange(). This
* callback function will be called with the user's choice.
*
* @see acceptEditorStateChange()
*/
_confirmStopEditing: function(acceptCallback) {
// Only instantiate if there isn't a modal instance visible yet.
if (!this.model.get('activeModal')) {
var that = this;
var modal = new Drupal.edit.views.ModalView({
model: this.model,
message: Drupal.t('You have unsaved changes'),
buttons: [
{ action: 'discard', classes: 'gray-button', label: Drupal.t('Discard changes') },
{ action: 'save', type: 'submit', classes: 'blue-button', label: Drupal.t('Save') }
],
callback: function(action) {
// The active modal has been removed.
that.model.set('activeModal', null);
if (action === 'discard') {
acceptCallback(true);
}
else {
acceptCallback(false);
var editor = that.model.get('activeEditor');
editor.options.widget.setState('saving', editor.options.property);
}
}
});
this.model.set('activeModal', modal);
// The modal will set the activeModal property on the model when rendering
// to prevent multiple modals from being instantiated.
modal.render();
}
else {
// Reject as there is still an open transition waiting for confirmation.
acceptCallback(false);
}
},
/**
* Reacts to editor (PropertyEditor) state changes; tracks global state.
*
* @param from
* The previous state.
* @param to
* The new state.
* @param editor
* The PropertyEditor widget object.
*/
editorStateChange: function(from, to, editor) {
// @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133)
// Get rid of this once that issue is solved.
if (!editor) {
return;
}
else {
editor.stateChange(from, to);
}
// Keep track of the highlighted editor in the global state.
if (_.indexOf(this.singleEditorStates, to) !== -1 && this.model.get('highlightedEditor') !== editor) {
this.model.set('highlightedEditor', editor);
}
else if (this.model.get('highlightedEditor') === editor && to === 'candidate') {
this.model.set('highlightedEditor', null);
}
// Keep track of the active editor in the global state.
if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== editor) {
this.model.set('activeEditor', editor);
}
else if (this.model.get('activeEditor') === editor && to === 'candidate') {
// Discarded if it transitions from a changed state to 'candidate'.
if (from === 'changed' || from === 'invalid') {
// Retrieve the storage widget from DOM.
var createStorageWidget = this.$el.data('createStorage');
// Revert changes in the model, this will trigger the direct editable
// content to be reset and redrawn.
createStorageWidget.revertChanges(editor.options.entity);
}
this.model.set('activeEditor', null);
}
// Propagate the state change to the decoration and toolbar views.
// @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133)
// Uncomment this once that issue is solved.
// editor.decorationView.stateChange(from, to);
// editor.toolbarView.stateChange(from, to);
},
/**
* Decorates an editor (PropertyEditor).
*
* Upon the page load, all appropriate editors are initialized and decorated
* (i.e. even before anything of the editing UI becomes visible; even before
* edit mode is enabled).
*
* @param editor
* The PropertyEditor widget object.
*/
decorateEditor: function(editor) {
// Toolbars are rendered "on-demand" (highlighting or activating).
// They are a sibling element before the editor's DOM element.
editor.toolbarView = new Drupal.edit.views.ToolbarView({
editor: editor,
$storageWidgetEl: this.$el
});
// Decorate the editor's DOM element depending on its state.
editor.decorationView = new Drupal.edit.views.PropertyEditorDecorationView({
el: editor.element,
editor: editor,
toolbarId: editor.toolbarView.getId()
});
// @todo: BLOCKED_ON(Create.js, https://github.com/bergie/create/issues/133)
// Get rid of this once that issue is solved.
editor.options.widget.element.on('createeditablestatechange', function(event, data) {
editor.decorationView.stateChange(data.previous, data.current);
editor.toolbarView.stateChange(data.previous, data.current);
});
},
/**
* Undecorates an editor (PropertyEditor).
*
* Whenever a property has been updated, the old HTML will be replaced by
* the new (re-rendered) HTML. The EditableEntity widget will be destroyed,
* as will be the PropertyEditor widget. This method ensures Edit's editor
* views also are removed properly.
*
* @param editor
* The PropertyEditor widget object.
*/
undecorateEditor: function(editor) {
editor.toolbarView.undelegateEvents();
editor.toolbarView.remove();
delete editor.toolbarView;
editor.decorationView.undelegateEvents();
// Don't call .remove() on the decoration view, because that would remove
// a potentially rerendered field.
delete editor.decorationView;
}
});
})(jQuery, _, Backbone, Drupal, VIE);