diff --git a/core/misc/form.autosave.js b/core/misc/form.autosave.js new file mode 100644 index 0000000..30bdd55 --- /dev/null +++ b/core/misc/form.autosave.js @@ -0,0 +1,252 @@ +(function ($) { + +"use strict"; + +/** + * Include an indexOf function for IE. + */ +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function(obj, start) { + for (var i = (start || 0), j = this.length; i < j; i++) { + if (this[i] === obj) { return i; } + } + return -1; + }; +} + +/** + * Automatically save the contents of a form + * using localStorage when it is modified. + */ +Drupal.behaviors.formAutoSave = { + attach: function (context, settings) { + + // Handle each form on the page. + $('form').each(function() { + + var formId = $(this).attr('id'); + + // Get the last time this form was saved to the server and + // make sure it is a number. In the case where changed is + // not set on the form, this will be missing (NaN). + var changed = parseInt($(this).find("input[name='changed']").val(), 10); + + // @TODO Make this work generically, not just node form. + if (/-node-form/.test(formId)) { + var changedForm = Drupal.form.localUpdate(formId, changed); + + if (changedForm === true) { + $(Drupal.theme('formLocalEditsWarning')).insertBefore('#' + formId).hide().fadeIn('slow'); + } + + // Make an initial backup if the form requires it, + // for example on preview or on form validation error. + if ($(this).hasClass('form-save-to-localstorage-on-load')) { + Drupal.form.localSave(formId, changed); + } + + // On a formUpdated event, save the forms state. + $(this).on('formUpdated', function(e) { + Drupal.form.localSave(formId, changed); + }); + + // Clear the saved form when the user deliberatley + // clicks the overlay close link. + $(document).bind('drupalOverlayCloseByLink', function (event) { + Drupal.form.localClear(formId); + }); + + // Clear form on submission. + $(this).on('submit', function() { + Drupal.form.localClear(formId); + }); + } + }); + } +}; + +/** + * Namespace form functions in the form object. + */ +Drupal.form = {}; + +/** + * Save form values into localStorage. + * + * @param string formId + * The form id on the page whose values to save. + * @param int|NaN changed + * The last time the form was saved on the server + * or NaN if not specified. + */ +Drupal.form.localSave = function (formId, changed) { + + var serializedForm = { + // Store the last time this form was saved on the server. + _changed: changed + }; + + // Serialize all form text elements. + $("#" + formId + " .form-text").each(function() { + serializedForm[this.id] = this.value; + }); + + // Serialize all form text area elements. + $("#" + formId + " .form-textarea").each(function() { + serializedForm[this.id] = this.value; + }); + + // Serialize all form checkbox elements. + $("#" + formId + " .form-checkbox").each(function() { + serializedForm[this.id] = this.checked; + }); + + // Serialize all form checkbox elements. + $("#" + formId + " .form-select").each(function() { + var selectId = this.id; + serializedForm[selectId] = []; + $(this).find(":selected").each(function() { + serializedForm[selectId].push($(this).val()); + }); + }); + + // @TODO: Add support for radio buttons. + var saveString = JSON.stringify(serializedForm); + + // Put all serialized items into localStorage. + localStorage.setItem(Drupal.form.getLocalFormId(formId), saveString); +}; + +/** + * Updates the DOM representation of a form with a serialised + * version. + * + * @param string formId + * The form id to update from local + * @param int|NaN changed + * The form last updated timestamp from the server. + * If the local copy is behind the server then it will + * not be loaded. + * If changed is NaN then the form does not provide this + * so we can only assume local copy is latest. + * @param array[object] formValues + * (optional) A loaded array of form elements + * If not provided, the values will be loaded + * from localStorage. + * + * @return boolean + * TRUE if something was updated otherwise FALSE. + */ +Drupal.form.localUpdate = function(formId, changed, formValues) { + + // If changed is not provided, make it NaN. + changed = typeof changed !== 'undefined' ? changed : Number.NaN; + + // If formValues was not given, try to load from localStorage. + formValues = typeof formValues !== 'undefined' ? formValues : Drupal.form.localLoad(formId); + + if (formValues.length === 0) { + // If there is nothing to update leave here. + return false; + } + + // Check if the local data values for the form are out of date + // compared with the latest from the server. + if ( !isNaN(changed) && (isNaN(formValues._changed) || formValues._changed !== changed) ) { + // The local copy is out of date so do not load it. + return false; + } + + // Remove the _changed data item before loading formValues. + delete formValues._changed; + + // Loop over all form values and apply. + for ( var key in formValues ) { + + // Get the DOM element to set a saved value for. + var el = document.getElementById(key); + + // Get the type of element, its either sum subtype + // of INPUT or a TEXTAREA. + var type = el.tagName === 'INPUT' ? $(el).attr('type') : el.tagName; + + // @TODO Add support for radios. + // @TODO Replace nasty nested ifs with something more elegant. + if ( type === 'TEXTAREA' || type === 'text') { + el.value = formValues[key]; + } + else if ( type === 'checkbox' ) { + if (el.checked != formValues[key]) { + // We want to physically click this as it might + // invoke other JS events such as displaying + // previously hidden page furniture. + el.click(); + } + } + else if ( type === 'SELECT' ) { + for ( var optionsIndex = 0; optionsIndex < el.options.length; optionsIndex++ ) { + el.options[optionsIndex].selected = formValues[key].indexOf(el.options[optionsIndex].value) > -1; + } + } + } + + return true; +}; + +/** + * Loads form values from the localStorage + * + * @param string formId + * The id of the form + * + * @return array[object] + * An array of form value objects + */ +Drupal.form.localLoad = function (formId) { + + var serializedForm = localStorage.getItem(Drupal.form.getLocalFormId(formId)); + + if (typeof(serializedForm) != 'string') { + return []; + } + + return JSON.parse( serializedForm ); +}; + +/** + * Clear a form from localStorage. + * + * @param string formId + * The form id to remove from localStorage. + */ +Drupal.form.localClear = function (formId) { + localStorage.removeItem(Drupal.form.getLocalFormId(formId)); +}; + + +/** + * Get Local Storage ID from Form ID + * + * @param string formId + * The form id to get LocalStorage ID for. + * + * @return string + * A local storage id of the form: + * "Drupal.Form.node.add.article.article-node-form" + */ +Drupal.form.getLocalFormId = function (formId) { + return 'Drupal.Form' + window.location.pathname.replace(/\//g, '.') + formId; +}; + +/** + * Themed warning message to display when a forms + * values from Drupal are overriden with localStorage. + * + * @return string + * The warning message. + */ +Drupal.theme.formLocalEditsWarning = function () { + return '
' + Drupal.t("You have edited this form before so it has local changes which have not been saved yet.") + '
'; +}; + +})(jQuery); diff --git a/core/modules/node/lib/Drupal/node/NodeFormController.php b/core/modules/node/lib/Drupal/node/NodeFormController.php index b9bf7eb..aea7fff 100644 --- a/core/modules/node/lib/Drupal/node/NodeFormController.php +++ b/core/modules/node/lib/Drupal/node/NodeFormController.php @@ -59,6 +59,9 @@ class NodeFormController extends EntityFormController { if (isset($form_state['node_preview'])) { $form['#prefix'] = $form_state['node_preview']; $node->in_preview = TRUE; + + // In preview state, backup the unsaved state of the node on load. + $form['#attributes']['class'][] = 'form-save-to-localstorage-on-load'; } else { unset($node->in_preview); diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js index 678d68a..b53534e 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -132,7 +132,8 @@ Drupal.overlay.create = function () { .bind('drupalOverlayClose' + eventClass, $.proxy(this, 'eventhandlerRefreshPage')) .bind('drupalOverlayBeforeClose' + eventClass + ' drupalOverlayBeforeLoad' + eventClass + - ' drupalOverlayResize' + eventClass, $.proxy(this, 'eventhandlerDispatchEvent')); + ' drupalOverlayResize' + eventClass + + ' drupalOverlayCloseByLink' + eventClass, $.proxy(this, 'eventhandlerDispatchEvent')); if ($('.overlay-displace-top, .overlay-displace-bottom').length) { $(document) @@ -564,6 +565,11 @@ Drupal.overlay.eventhandlerOverrideLink = function (event) { // Close the overlay when the link contains the overlay-close class. if ($target.hasClass('overlay-close')) { + // Trigger event informing scripts the overlay was + // deliberatley closed rather than just navigating away. + var linkCloseOverlayEvent = $.Event('drupalOverlayCloseByLink'); + $(document).trigger(linkCloseOverlayEvent); + // Clearing the overlay URL fragment will close the overlay. $.bbq.removeState('overlay'); return; diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 6baa387..8b03e0f 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -1198,6 +1198,7 @@ function system_library_info() { 'version' => VERSION, 'js' => array( 'core/misc/form.js' => array('group' => JS_LIBRARY, 'weight' => 1), + 'core/misc/form.autosave.js' => array('group' => JS_LIBRARY, 'weight' => 2), ), 'dependencies' => array( array('system', 'jquery'),