Propagate summary to headline
[infodrom.org/service.infodrom.org] / src / jquery.editable.js
1 /*
2 * jQuery plugin that makes elements editable
3 *
4 * @author Victor Jonsson (http://victorjonsson.se/)
5 * @website https://github.com/victorjonsson/jquery-editable/
6 * @license GPLv2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @version 1.3.6.dev
8 * @donations http://victorjonsson.se/donations/
9 */
10 (function($, window) {
11
12     'use strict';
13
14     var $win = $(window), // Reference to window
15
16     // Reference to textarea
17     $textArea = false,
18
19     // Reference to currently edit element
20     $currentlyEdited = false,
21
22     // Some constants
23     EVENT_ATTR = 'data-edit-event',
24     IS_EDITING_ATTR = 'data-is-editing',
25     EMPTY_ATTR = 'data-is-empty',
26     DBL_TAP_EVENT = 'dbltap',
27     SUPPORTS_TOUCH = 'ontouchend' in window,
28     TINYMCE_INSTALLED = 'tinyMCE' in window && typeof window.tinyMCE.init == 'function',
29
30     // reference to old is function
31     oldjQueryIs = $.fn.is,
32
33     /*
34      * Function responsible of triggering double tap event
35      */
36     lastTap = 0,
37     tapper = function() {
38         var now = new Date().getTime();
39         if( (now-lastTap) < 250 ) {
40             $(this).trigger(DBL_TAP_EVENT);
41         }
42         lastTap = now;
43     },
44
45     /**
46      * Event listener that largens font size
47      */
48     keyHandler = function(e) {
49         if( e.keyCode == 13 && e.data.closeOnEnter ) {
50             $currentlyEdited.editable('close');
51         }
52         else if( e.keyCode == 27 ) {
53             $textArea.val($currentlyEdited.attr('orig-text'));
54             $currentlyEdited.editable('close');
55         }
56         else if( e.data.toggleFontSize && (e.metaKey && (e.keyCode == 38 || e.keyCode == 40)) ) {
57             var fontSize = parseInt($textArea.css('font-size'), 10);
58             fontSize += e.keyCode == 40 ? -1 : 1;
59             $textArea.css('font-size', fontSize+'px');
60             return false;
61         }
62     },
63
64     /**
65      * Adjusts the height of the textarea to remove scroll
66      * @todo This way of doing it does not make the textarea smaller when the number of text lines gets smaller
67      */
68     adjustTextAreaHeight = function() {
69         if( $textArea[0].scrollHeight !== parseInt($textArea.attr('data-scroll'), 10) ) {
70             $textArea.css('height', $textArea[0].scrollHeight +'px');
71             $textArea.attr('data-scroll', $textArea[0].scrollHeight);
72         }
73     },
74
75     /**
76      * @param {jQuery} $el
77      * @param {String} newText
78      */
79     resetElement = function($el, newText, emptyMessage) {
80         $el.removeAttr(IS_EDITING_ATTR);
81
82         if (newText.length == 0 && emptyMessage) {
83             $el.html(emptyMessage);
84             $el.attr(EMPTY_ATTR, 'empty');
85         } else {
86             $el.html( newText );
87             $el.removeAttr(EMPTY_ATTR);
88         }
89         $textArea.remove();
90     },
91
92
93     /**
94      * Function creating editor
95      */
96     elementEditor = function($el, opts) {
97
98         if( $el.is(':editing') )
99             return;
100
101         $currentlyEdited = $el;
102         $el.attr(IS_EDITING_ATTR, '1');
103
104         if ($el.is(':empty')) {
105             $el.removeAttr(EMPTY_ATTR);
106             $el.html('');
107         }
108
109         var defaultText = $.trim( $el.html() ),
110             defaultFontSize = $el.css('font-size'),
111             elementHeight = $el.height(),
112             textareaStyle = 'width: 96%; padding:0; margin:0; border:0; background:none;'+
113                             'font-family: '+$el.css('font-family')+'; font-size: '+$el.css('font-size')+';'+
114                             'font-weight: '+$el.css('font-weight')+';';
115
116         $el.attr('orig-text', defaultText);
117         if( opts.lineBreaks ) {
118             defaultText = defaultText.replace(/<br( |)(|\/)>/g, '\n');
119         }
120
121         $textArea = $('<textarea></textarea>');
122         $el.text('');
123
124         if( navigator.userAgent.match(/webkit/i) !== null ) {
125             textareaStyle = document.defaultView.getComputedStyle($el.get(0), "").cssText;
126         }
127
128         // The editor should always be static
129         textareaStyle += 'position: static';
130
131         /*
132           TINYMCE EDITOR
133          */
134         if( opts.tinyMCE !== false ) {
135             var id = 'editable-area-'+(new Date().getTime());
136             $textArea
137                 .val(defaultText)
138                 .appendTo($el)
139                 .attr('id', id);
140
141             if( typeof opts.tinyMCE != 'object' )
142                 opts.tinyMCE = {};
143
144             opts.tinyMCE.mode = 'exact';
145             opts.tinyMCE.elements = id;
146             opts.tinyMCE.width = $el.innerWidth();
147             opts.tinyMCE.height = $el.height() + 200;
148             opts.tinyMCE.theme_advanced_resize_vertical = true;
149
150             opts.tinyMCE.setup = function (ed) {
151                 ed.onInit.add(function(editor, evt) {
152                     var editorWindow = editor.getWin();
153                     var hasPressedKey = false;
154                     var editorBlur = function() {
155
156                         var newText = $(editor.getDoc()).find('body').html();
157                         if( $(newText).get(0).nodeName == $el.get(0).nodeName ) {
158                             newText = $(newText).html();
159                         }
160
161                         // Update element and remove editor
162                         resetElement($el, newText, opts.emptyMessage);
163                         editor.remove();
164                         $textArea = false;
165                         $win.unbind('click', editorBlur);
166                         $currentlyEdited = false;
167
168                         // Run callback
169                         if( typeof opts.callback == 'function' ) {
170                             opts.callback({
171                                 content : newText == defaultText || !hasPressedKey ? false : newText,
172                                 fontSize : false,
173                                 $el : $el
174                             });
175                         }
176                         else if (newText != defaultText) {
177                             var route = $($el).attr('route');
178                             var name = $($el).attr('name');
179                             var item_id = $($el).attr('item_id');
180                             if (typeof(route) == 'string' && typeof(item_id) == 'string')
181                                 $.invoke(route,
182                                          {id: item_id,
183                                           name: name,
184                                           content: newText});
185                         }
186                     };
187
188                     // Blur editor when user clicks outside the editor
189                     setTimeout(function() {
190                         $win.bind('click', editorBlur);
191                     }, 500);
192
193                     // Create a dummy textarea that will called upon when
194                     // programmatically interacting with the editor
195                     $textArea = $('<textarea></textarea>');
196                     $textArea.bind('blur', editorBlur);
197
198                     editorWindow.onkeydown = function() {
199                         hasPressedKey = true;
200                     };
201
202                     editorWindow.focus();
203                 });
204             };
205
206             tinyMCE.init(opts.tinyMCE);
207         }
208
209         /*
210          TEXTAREA EDITOR
211          */
212         else {
213
214             if( opts.toggleFontSize || opts.closeOnEnter ) {
215                 $win.bind('keydown', opts, keyHandler);
216             }
217             $win.bind('keyup', adjustTextAreaHeight);
218
219             $textArea
220                 .val(defaultText)
221                 .blur(function() {
222                     
223                     $currentlyEdited = false;
224
225                     // Get new text and font size
226                     var newText = $.trim( $textArea.val() ),
227                         newFontSize = $textArea.css('font-size');
228                     if( opts.lineBreaks ) {
229                         newText = newText.replace(new RegExp('\n','g'), '<br />');
230                     }
231
232                     // Update element
233                     resetElement($el, newText, opts.emptyMessage);
234                     if( newFontSize != defaultFontSize ) {
235                         $el.css('font-size', newFontSize);
236                     }
237
238                     // remove textarea and size toggles
239                     $win.unbind('keydown', keyHandler);
240                     $win.unbind('keyup', adjustTextAreaHeight);
241
242                     // Run callback
243                     if( typeof opts.callback == 'function' ) {
244                         opts.callback({
245                             content : newText == defaultText ? false : newText,
246                             fontSize : newFontSize == defaultFontSize ? false : newFontSize,
247                             $el : $el
248                         });
249                     }
250                     else if (newText != defaultText) {
251                         var route = $($el).attr('route');
252                         var name = $($el).attr('name');
253                         var item_id = $($el).attr('item_id');
254                         if (typeof(route) == 'string' && typeof(item_id) == 'string')
255                             $.invoke(route,
256                                      {id: item_id,
257                                       name: name,
258                                       content: newText});
259                     }
260                 })
261                 .attr('style', textareaStyle)
262                 .appendTo($el)
263                 .css({
264                     margin: 0,
265                     padding: 0,
266                     height : elementHeight +'px',
267                     overflow : 'hidden'
268                 })
269                 .css(opts.editorStyle)
270                 .focus()
271                 .get(0).select();
272
273             adjustTextAreaHeight();
274
275         }
276
277         $el.trigger('edit', [$textArea]);
278     },
279
280     /**
281      * Event listener
282      */
283     editEvent = function(event) {
284         
285         if( $currentlyEdited !== false && !$currentlyEdited.children("textarea").is(clickedElement)) {
286             // Not closing the currently open editor before opening a new
287             // editor makes things go crazy
288             $currentlyEdited.editable('close');
289             elementEditor($(this), event.data);
290         }
291         else {
292             elementEditor($(this), event.data);
293         }
294         return false;
295     };
296
297     /**
298      * Jquery plugin that makes elments editable
299      * @param {Object|String} [opts] Either callback function or the string 'destroy' if wanting to remove the editor event
300      * @return {jQuery|Boolean}
301      */
302     $.fn.editable = function(opts) {
303
304         if(typeof opts == 'string') {
305
306             if( this.is(':editable') ) {
307
308                 switch (opts) {
309                     case 'open':
310                         if( !this.is(':editing') ) {
311                             this.trigger(this.attr(EVENT_ATTR));
312                         }
313                         break;
314                     case 'close':
315                         if( this.is(':editing') ) {
316                             $textArea.trigger('blur');
317                         }
318                         break;
319                     case 'destroy':
320                         if( this.is(':editing') ) {
321                             $textArea.trigger('blur');
322                         }
323                         this.unbind(this.attr(EVENT_ATTR));
324                         this.removeAttr(EVENT_ATTR);
325                         break;
326                     default:
327                         console.warn('Unknown command "'+opts+'" for jquery.editable');
328                 }
329
330             } else {
331                 console.error('Calling .editable() on an element that is not editable, call .editable() first');
332             }
333         }
334         else {
335
336             if( this.is(':editable') ) {
337                 console.warn('Making an already editable element editable, call .editable("destroy") first');
338                 this.editable('destroy');
339             }
340
341             opts = $.extend({
342                 event : 'dblclick',
343                 touch : true,
344                 lineBreaks : true,
345                 toggleFontSize : true,
346                 closeOnEnter : false,
347                 emptyMessage : false,
348                 tinyMCE : false,
349                 editorStyle : {}
350             }, opts);
351
352             if( opts.tinyMCE !== false && !TINYMCE_INSTALLED ) {
353                 console.warn('Trying to use tinyMCE as editor but id does not seem to be installed');
354                 opts.tinyMCE = false;
355             }
356
357             if( SUPPORTS_TOUCH && opts.touch ) {
358                 opts.event = DBL_TAP_EVENT;
359                 this.unbind('touchend', tapper);
360                 this.bind('touchend', tapper);
361             }
362             else {
363                 opts.event += '.textEditor';
364             }
365
366             this.bind(opts.event, opts, editEvent);
367             this.attr(EVENT_ATTR, opts.event);
368
369             // If it is empty to start with, apply the empty message
370             if (this.html().length == 0 && opts.emptyMessage) {
371                 this.html(opts.emptyMessage);
372                 this.attr(EMPTY_ATTR, 'empty');
373             } else {
374                 this.removeAttr(EMPTY_ATTR);
375             }
376         }
377
378         return this;
379     };
380
381     /**
382      * Add :editable :editing to $.is()
383      * @param {Object} statement
384      * @return {*}
385      */
386     $.fn.is = function(statement) {
387         if( typeof statement == 'string' && statement.indexOf(':') === 0) {
388             if( statement == ':editable' ) {
389                 return this.attr(EVENT_ATTR) !== undefined;
390             } else if( statement == ':editing' ) {
391                 return this.attr(IS_EDITING_ATTR) !== undefined;
392             } else if( statement == ':empty' ) {
393                 return this.attr(EMPTY_ATTR) !== undefined;
394             }
395         }
396         return oldjQueryIs.apply(this, arguments);
397     };
398     
399     // The latest element clicked
400     var clickedElement;
401     $(document).mousedown(function(e) {
402         clickedElement = $(e.target);
403     });
404
405 })(jQuery, window);