core/includes/form.inc | 11 + core/misc/form.autosave.js | 25 ++ core/misc/garlic/garlic.js | 421 ++++++++++++++++++++ .../node/lib/Drupal/node/NodeFormController.php | 6 + core/modules/overlay/overlay-parent.js | 8 +- core/modules/system/system.module | 15 + 7 files changed, 485 insertions(+), 1 deletions(-) diff --git a/core/includes/form.inc b/core/includes/form.inc index 375f178..675aefd 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -951,6 +951,17 @@ function drupal_process_form($form_id, &$form, &$form_state) { } } + if (!empty($form['#autosave'])) { + $form['#attached']['js'][] = array( + 'data' => array( + 'formAutoSave' => array( + $form['#id'] => !empty($form['#autosave']['#changed']) ? $form['#autosave']['#changed'] : 0 + ), + ), + 'type' => 'setting', + ); + } + // After processing the form, the form builder or a #process callback may // have set $form_state['cache'] to indicate that the form and form state // shall be cached. But the form may only be cached if the 'no_cache' property diff --git a/core/misc/form.autosave.js b/core/misc/form.autosave.js new file mode 100644 index 0000000..ffb4db5 --- /dev/null +++ b/core/misc/form.autosave.js @@ -0,0 +1,25 @@ +(function ($, Drupal) { + +"use strict"; + +/** + * Automatically saves the contents of a form using localStorage when modified. + */ +Drupal.behaviors.formAutoSave = { + attach: function (context, settings) { + for (var formID in settings.formAutoSave) { + if (settings.formAutoSave.hasOwnProperty(formID)) { + $(context).find('#' + formID).garlic({ + expires: 86400 * 30, + conflictManager: { enabled: false }, + // Not yet supported by Garlic, but we need something like this to + // ensure Garlic doesn't overwrite values when the form contains + // different values by default. + lastModifiedIdentifier: settings.formAutoSave[formID] + }); + } + } + } +}; + +})(jQuery, Drupal); diff --git a/core/misc/garlic/garlic.js b/core/misc/garlic/garlic.js new file mode 100644 index 0000000..9a3db32 --- /dev/null +++ b/core/misc/garlic/garlic.js @@ -0,0 +1,421 @@ +/* + Garlic.js allows you to automatically persist your forms' text field values locally, + until the form is submitted. This way, your users don't lose any precious data if they + accidentally close their tab or browser. + + author: Guillaume Potier - @guillaumepotier +*/ + +!function ($) { + + "use strict"; + /*global localStorage */ + /*global document */ + + /* STORAGE PUBLIC CLASS DEFINITION + * =============================== */ + var Storage = function ( options ) { + this.defined = 'undefined' !== typeof localStorage; + } + + Storage.prototype = { + + constructor: Storage + + , get: function ( key, placeholder ) { + return localStorage.getItem( key ) ? localStorage.getItem( key ) : 'undefined' !== typeof placeholder ? placeholder : null; + } + + , has: function ( key ) { + return localStorage.getItem( key ) ? true : false; + } + + , set: function ( key, value, fn ) { + if ( 'string' === typeof value ) { + + // if value is null, remove storage if exists + if ( '' === value ) { + this.destroy( key ); + } else { + localStorage.setItem( key , value ); + } + } + + return 'function' === typeof fn ? fn() : true; + } + + , destroy: function ( key, fn ) { + localStorage.removeItem( key ); + return 'function' === typeof fn ? fn() : true; + } + + , clean: function ( fn ) { + for ( var i = localStorage.length - 1; i >= 0; i-- ) { + if ( 'undefined' === typeof Array.indexOf && -1 !== localStorage.key(i).indexOf( 'garlic:' ) ) { + localStorage.removeItem( localStorage.key(i) ); + } + } + + return 'function' === typeof fn ? fn() : true; + } + + , clear: function ( fn ) { + localStorage.clear(); + return 'function' === typeof fn ? fn() : true; + } + } + + /* GARLIC PUBLIC CLASS DEFINITION + * =============================== */ + + var Garlic = function ( element, storage, options ) { + this.init( 'garlic', element, storage, options ); + } + + Garlic.prototype = { + + constructor: Garlic + + /* init data, bind jQuery on() actions */ + , init: function ( type, element, storage, options ) { + this.type = type; + this.$element = $( element ); + this.options = this.getOptions( options ); + this.storage = storage; + this.path = this.getPath(); + this.parentForm = this.$element.closest( 'form' ); + this.$element.addClass('garlic-auto-save'); + this.expiresFlag = !this.options.expires ? false : ( this.$element.data( 'expires' ) ? this.path : this.getPath( this.parentForm ) ) + '_flag' ; + + // bind garlic events + this.$element.on( this.options.events.join( '.' + this.type + ' ') , false, $.proxy( this.persist, this ) ); + + if ( this.options.destroy ) { + $( this.parentForm ).on( 'submit reset' , false, $.proxy( this.destroy, this ) ); + } + + // retrieve garlic persisted data + this.retrieve(); + } + + , getOptions: function ( options ) { + return $.extend( {}, $.fn[this.type].defaults, options, this.$element.data() ); + } + + /* temporary store data / state in localStorage */ + , persist: function () { + + // some binded events are redundant (change & paste for example), persist only once by field val + if ( this.val === this.$element.val() ) { + return; + } + + this.val = this.$element.val(); + + // if auto-expires is enabled, set the expiration date for future auto-deletion + if ( this.options.expires ) { + this.storage.set( this.expiresFlag , ( new Date().getTime() + this.options.expires * 1000 ).toString() ); + } + + // for checkboxes, we need to implement an unchecked / checked behavior + if ( this.$element.is( 'input[type=checkbox]' ) ) { + return this.storage.set( this.path , this.$element.attr( 'checked' ) ? 'checked' : 'unchecked' ); + } + + this.storage.set( this.path , this.$element.val() ); + } + + /* retrieve localStorage data / state and update elem accordingly */ + , retrieve: function () { + if ( this.storage.has( this.path ) ) { + + // if data expired, destroy it! + if ( this.options.expires ) { + var date = new Date().getTime(); + if ( this.storage.get( this.expiresFlag ) < date.toString() ) { + this.storage.destroy( this.path ); + return; + } else { + this.$element.attr( 'expires-in', Math.floor( ( parseInt( this.storage.get( this.expiresFlag ) ) - date ) / 1000 ) ); + } + } + + var storedValue = this.storage.get( this.path ); + + // if conflictManager enabled, manage fields with already provided data, different from the one stored + if ( this.options.conflictManager.enabled && this.detectConflict() ) { + return this.conflictManager(); + } + + // input[type=checkbox] and input[type=radio] have a special checked / unchecked behavior + if ( this.$element.is( 'input[type=radio], input[type=checkbox]' ) ) { + + // for checkboxes and radios + if ( 'checked' === storedValue || this.$element.val() === storedValue ) { + return this.$element.attr( 'checked', true ); + + // only needed for checkboxes + } else if ( 'unchecked' === storedValue ) { + this.$element.attr( 'checked', false ); + } + + return; + } + + // for input[type=text], select and textarea, just set val() + this.$element.val( storedValue ); + + // trigger custom user function when data is retrieved + this.options.onRetrieve( this.$element, storedValue ); + + return; + } + } + + /* there is a conflict when initial data / state differs from persisted data / state */ + , detectConflict: function() { + var self = this; + + // radio buttons and checkboxes are yet not supported + if ( this.$element.is( 'input[type=checkbox], input[type=radio]' ) ) { + return false; + } + + // there is a default not null value and we have a different one stored + if ( this.$element.val() && this.storage.get( this.path ) !== this.$element.val() ) { + + // for select elements, we need to check if there is a default checked value + if ( this.$element.is( 'select' ) ) { + var selectConflictDetected = false; + + // foreach each options except first one, always considered as selected, seeking for a default selected one + this.$element.find( 'option' ).each( function () { + if ( $( this ).index() !== 0 && $( this ).attr( 'selected' ) && $( this ).val() !== self.storage.get( this.path ) ) { + selectConflictDetected = true; + return; + } + }); + + return selectConflictDetected; + } + + return true; + } + + return false; + } + + /* manage here the conflict, show default value depending on options.garlicPriority value */ + , conflictManager: function () { + + // user can define here a custom function that could stop Garlic default behavior, if returns false + if ( 'function' === typeof this.options.conflictManager.onConflictDetected + && !this.options.conflictManager.onConflictDetected( this.$element, this.storage.get( this.path ) ) ) { + return false; + } + + if ( this.options.conflictManager.garlicPriority ) { + this.$element.data( 'swap-data', this.$element.val() ); + this.$element.data( 'swap-state', 'garlic' ); + this.$element.val( this.storage.get( this.path ) ); + } else { + this.$element.data( 'swap-data', this.storage.get( this.path ) ); + this.$element.data( 'swap-state', 'default' ); + } + + this.swapHandler(); + this.$element.addClass( 'garlic-conflict-detected' ); + this.$element.closest( 'input[type=submit]' ).attr( 'disabled', true ); + } + + /* manage swap user interface */ + , swapHandler: function () { + var swapChoiceElem = $( this.options.conflictManager.template ); + this.$element.after( swapChoiceElem.text( this.options.conflictManager.message ) ); + swapChoiceElem.on( 'click', false, $.proxy( this.swap, this ) ); + } + + /* swap data / states for conflicted elements */ + , swap: function () { + var val = this.$element.data( 'swap-data' ); + this.$element.data( 'swap-state', 'garlic' === this.$element.data( 'swap-state' ) ? 'default' : 'garlic' ); + this.$element.data( 'swap-data', this.$element.val()); + $( this.$element ).val( val ); + } + + /* delete localStorage persistance only */ + , destroy: function () { + this.storage.destroy( this.path ); + } + + /* remove data / reset state AND delete localStorage */ + , remove: function () { + this.remove(); + + if ( this.$element.is( 'input[type=radio], input[type=checkbox]' ) ) { + $( this.$element ).attr( 'checked', false ); + return; + } + + this.$element.val( '' ); + } + + /* retuns an unique identifier for form elements, depending on their behaviors: + * radio buttons: domain > pathname > form.[:eq(x)] > input. + no eq(); must be all stored under the same field name inside the same form + + * checkbokes: domain > pathname > form.[:eq(x)] > [fieldset, div, span..] > input.[:eq(y)] + cuz' they have the same name, must detect their exact position in the form. detect the exact hierarchy in DOM elements + + * other inputs: domain > pathname > form.[:eq(x)] > input.[:eq(y)] + we just need the element name / eq() inside a given form + */ + , getPath: function ( elem ) { + + if ( 'undefined' === typeof elem ) { + elem = this.$element; + } + + // Requires one element. + if ( elem.length != 1 ) { + return false; + } + + var path = '' + , fullPath = elem.is( 'input[type=checkbox]' ) + , node = elem; + + while ( node.length ) { + var realNode = node[0] + , name = realNode.nodeName; + + if ( !name ) { + break; + } + + name = name.toLowerCase(); + + var parent = node.parent() + , siblings = parent.children( name ); + + // don't need to pollute path with select, fieldsets, divs and other noisy elements, + // exept for checkboxes that need exact path, cuz have same name and sometimes same eq()! + if ( !$( realNode ).is( 'form, input, select, textarea' ) && !fullPath ) { + node = parent; + continue; + } + + // set input type as name + name attr if exists + name += $( realNode ).attr( 'name' ) ? '.' + $( realNode ).attr( 'name' ) : ''; + + // if has sibilings, get eq(), exept for radio buttons + if ( siblings.length > 1 && !$( realNode ).is( 'input[type=radio]' ) ) { + name += ':eq(' + siblings.index( realNode ) + ')'; + } + + path = name + ( path ? '>' + path : '' ); + + // break once we came up to form:eq(x), no need to go further + if ( 'form' == realNode.nodeName.toLowerCase() ) { + break; + } + + node = parent; + } + + return 'garlic:' + document.domain + ( this.options.domain ? '*' : window.location.pathname ) + '>' + path; + } + + , getStorage: function () { + return this.storage; + } + } + + /* GARLIC PLUGIN DEFINITION + * ========================= */ + + $.fn.garlic = function ( option, fn ) { + var options = $.extend(true, {}, $.fn.garlic.defaults, option, this.data() ) + , storage = new Storage() + , returnValue = false; + + // this plugin heavily rely on local Storage. If there is no localStorage or data-storage=false, no need to go further + if ( !storage.defined ) { + return false; + } + + function bind ( self ) { + var $this = $( self ) + , data = $this.data( 'garlic' ) + , fieldOptions = $.extend( {}, options, $this.data() ); + + // don't bind an elem with data-storage=false + if ( 'undefined' !== typeof fieldOptions.storage && !fieldOptions.storage ) { + return; + } + + // don't bind a password type field + if ( 'password' === $( self ).attr( 'type' ) ) { + return; + } + + // if data never binded, bind it right now! + if ( !data ) { + $this.data( 'garlic', ( data = new Garlic( self, storage, fieldOptions ) ) ); + } + + // here is our garlic public function accessor, currently does not support args + if ( 'string' === typeof option && 'function' === typeof data[option] ) { + return data[option](); + } + } + + // loop through every elemt we want to garlic + this.each(function () { + + // if a form elem is given, bind all its input children + if ( $( this ).is( 'form' ) ) { + $( this ).find( options.inputs ).each( function () { + returnValue = bind( $( this ) ); + }); + + // if it is a Garlic supported single element, bind it too + // add here a return instance, cuz' we could call public methods on single elems with data[option]() above + } else if ( $( this ).is( options.inputs ) ) { + returnValue = bind( $( this ) ); + } + }); + + return 'function' === typeof fn ? fn() : returnValue; + } + + /* GARLIC CONFIGS & OPTIONS + * ========================= */ + $.fn.garlic.Constructor = Garlic; + + $.fn.garlic.defaults = { + destroy: true // Remove or not localstorage on submit & clear + , inputs: 'input, textarea, select' // Default supported inputs. + , events: [ 'DOMAttrModified', 'textInput', 'input', 'change', 'keypress', 'paste', 'focus' ] // Events list that trigger a localStorage + , domain: false // Store et retrieve forms data accross all domain, not just on + , expires: false // false for no expiration, otherwise (int) in seconds for auto-expiration + , conflictManager: { + enabled: true // Manage default data and persisted data. If false, persisted data will always replace default ones + , garlicPriority: true // If form have default data, garlic persisted data will be shown first + , template: '' // Template used to swap between values if conflict detected + , message: 'This is your saved data. Click here to see default one' // Default message for swapping data / state + , onConflictDetected: function ( item, storedVal ) { return true; } // This function will be triggered if a conflict is detected on an item. Return true if you want Garlic behavior, return false if you want to override it + } + , onRetrieve: function ( item, storedVal ) {} // This function will be triggered each time Garlic find an retrieve a local stored data for a field + } + + /* GARLIC DATA-API + * =============== */ + $( window ).on( 'load', function () { + $( '[data-persist="garlic"]' ).each( function () { + $(this).garlic(); + }) + }); + +// This plugin works with jQuery or Zepto (with data extension builded for Zepto. See changelog 0.0.6) +}(window.jQuery || window.Zepto); diff --git a/core/modules/node/lib/Drupal/node/NodeFormController.php b/core/modules/node/lib/Drupal/node/NodeFormController.php index 08ecf08..c12bd5c 100644 --- a/core/modules/node/lib/Drupal/node/NodeFormController.php +++ b/core/modules/node/lib/Drupal/node/NodeFormController.php @@ -57,6 +57,12 @@ protected function prepareEntity(EntityInterface $node) { */ public function form(array $form, array &$form_state, EntityInterface $node) { $user_config = config('user.settings'); + + // Let the node form use localStorage to prevent data loss. + $form['#autosave'] = array( + '#changed' => isset($node->changed) ? $node->changed : NULL, + ); + // Some special stuff when previewing a node. if (isset($form_state['node_preview'])) { $form['#prefix'] = $form_state['node_preview']; diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js index caf9336..af868b4 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -137,7 +137,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) @@ -569,6 +570,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 cdf7b90..4ef5f22 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -1282,12 +1282,14 @@ 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'), array('system', 'drupal'), array('system', 'jquery.cookie'), array('system', 'jquery.once'), + array('system', 'garlic'), ), ); @@ -2014,6 +2016,19 @@ function system_library_info() { ), ); + // Garlic. + $libraries['garlic'] = array( + 'title' => 'garlic.js', + 'website' => 'http://garlicjs.org/', + 'version' => '1.1.2', + 'js' => array( + 'core/misc/garlic/garlic.js' => array('group' => JS_LIBRARY, 'weight' => 0), + ), + 'dependencies' => array( + array('system', 'jquery'), + ), + ); + // VIE. $libraries['vie.core'] = array( 'title' => 'VIE.js core (excluding services, views and xdr)',