[FIX] make RTE activate again
[odoo/odoo.git] / addons / website / static / src / js / website.editor.js
1 (function () {
2     'use strict';
3
4     var website = openerp.website;
5
6     website.templates.push('/website/static/src/xml/website.editor.xml');
7     website.dom_ready.done(function () {
8         // $.fn.data automatically parses value, '0'|'1' -> 0|1
9         website.is_editable = $(document.documentElement).data('editable');
10         var is_smartphone = $(document.body)[0].clientWidth < 767;
11
12         if (website.is_editable && !is_smartphone) {
13             website.ready().then(website.init_editor);
14         }
15     });
16
17     function link_dialog(editor) {
18         return new website.editor.LinkDialog(editor).appendTo(document.body);
19     }
20     function image_dialog(editor) {
21         return new website.editor.ImageDialog(editor).appendTo(document.body);
22     }
23
24     website.init_editor = function () {
25         CKEDITOR.plugins.add('customdialogs', {
26             requires: 'link,image',
27             init: function (editor) {
28                 editor.on('doubleclick', function (evt) {
29                     if (evt.data.dialog === 'link') {
30                         delete evt.data.dialog;
31                         link_dialog(editor);
32                     } else if(evt.data.dialog === 'image') {
33                         delete evt.data.dialog;
34                         image_dialog(editor);
35                     }
36                     // priority should be smaller than dialog (999) but bigger
37                     // than link or image (default=10)
38                 }, null, null, 500);
39
40                 //noinspection JSValidateTypes
41                 editor.addCommand('link', {
42                     exec: function (editor, data) {
43                         link_dialog(editor);
44                         return true;
45                     },
46                     canUndo: false,
47                     editorFocus: true,
48                 });
49                 //noinspection JSValidateTypes
50                 editor.addCommand('image', {
51                     exec: function (editor, data) {
52                         image_dialog(editor);
53                         return true;
54                     },
55                     canUndo: false,
56                     editorFocus: true,
57                 });
58             }
59         });
60         CKEDITOR.plugins.add( 'tablebutton', {
61             requires: 'panelbutton,floatpanel',
62             init: function( editor ) {
63                 var label = "Table";
64
65                 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
66                     label: label,
67                     title: label,
68                     // use existing 'table' icon
69                     icon: 'table',
70                     modes: { wysiwyg: true },
71                     editorFocus: true,
72                     // panel opens in iframe, @css is CSS file <link>-ed within
73                     // frame document, @attributes are set on iframe itself.
74                     panel: {
75                         css: '/website/static/src/css/editor.css',
76                         attributes: { 'role': 'listbox', 'aria-label': label, },
77                     },
78
79                     onBlock: function (panel, block) {
80                         block.autoSize = true;
81                         block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
82                             rows: 5,
83                             cols: 5,
84                         }));
85
86                         var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
87                             var $e = $(e.target);
88                             var y = $e.index() + 1;
89                             var x = $e.closest('tr').index() + 1;
90
91                             $table
92                                 .find('td').removeClass('selected').end()
93                                 .find('tr:lt(' + String(x) + ')')
94                                 .children().filter(function () { return $(this).index() < y; })
95                                 .addClass('selected');
96                         }).on('click', 'td', function (e) {
97                             var $e = $(e.target);
98
99                             //noinspection JSPotentiallyInvalidConstructorUsage
100                             var table = new CKEDITOR.dom.element(
101                                 $(openerp.qweb.render('website.editor.table', {
102                                     rows: $e.closest('tr').index() + 1,
103                                     cols: $e.index() + 1,
104                                 }))[0]);
105
106                             editor.insertElement(table);
107                             setTimeout(function () {
108                                 //noinspection JSPotentiallyInvalidConstructorUsage
109                                 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
110                                 var range = editor.createRange();
111                                 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
112                                 range.select();
113                             }, 0);
114                         });
115
116                         block.element.getDocument().getBody().setStyle('overflow', 'hidden');
117                         CKEDITOR.ui.fire('ready', this);
118                     },
119                 });
120             }
121         });
122
123         CKEDITOR.plugins.add('oeref', {
124             requires: 'widget',
125
126             init: function (editor) {
127                 editor.widgets.add('oeref', {
128                     inline: true,
129                     // dialog: 'oeref',
130                     allowedContent: '[data-oe-type]',
131                     editables: { text: '*' },
132
133                     init: function () {
134                         var element = this.element;
135                         this.setData({
136                             model: element.data('oe-model'),
137                             id: parseInt(element.data('oe-id'), 10),
138                             field: element.data('oe-field'),
139                         });
140                     },
141                     data: function () {
142                         this.element.data('oe-model', this.data.model);
143                         this.element.data('oe-id', this.data.id);
144                         this.element.data('oe-field', this.data.field);
145                     },
146                     upcast: function (el) {
147                         return el.attributes['data-oe-type'];
148                     },
149                 });
150             }
151         });
152
153         var editor = new website.EditorBar();
154         var $body = $(document.body);
155         editor.prependTo($body);
156         $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
157     };
158         /* ----- TOP EDITOR BAR FOR ADMIN ---- */
159     website.EditorBar = openerp.Widget.extend({
160         template: 'website.editorbar',
161         events: {
162             'click button[data-action=edit]': 'edit',
163             'click button[data-action=save]': 'save',
164             'click button[data-action=cancel]': 'cancel',
165         },
166         container: 'body',
167         customize_setup: function() {
168             var self = this;
169             var view_name = $(document.documentElement).data('view-xmlid');
170             var menu = $('#customize-menu');
171             this.$('#customize-menu-button').click(function(event) {
172                 menu.empty();
173                 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
174                     function(result) {
175                         _.each(result, function (item) {
176                             if (item.header) {
177                                 menu.append('<li class="dropdown-header">' + item.name + '</li>');
178                             } else {
179                                 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
180                                     item.id, item.active ? '' : '-empty', item.name));
181                             }
182                         });
183                         // Adding Static Menus
184                         menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
185                     }
186                 );
187             });
188             menu.on('click', 'a', function (event) {
189                 var view_id = $(event.currentTarget).data('view-id');
190                 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
191                     'view_id': view_id
192                 }).then( function(result) {
193                     window.location.reload();
194                 });
195             });
196         },
197         start: function() {
198             var self = this;
199
200             this.saving_mutex = new openerp.Mutex();
201
202             this.$('#website-top-edit').hide();
203             this.$('#website-top-view').show();
204
205             $('.dropdown-toggle').dropdown();
206             this.customize_setup();
207
208             this.$buttons = {
209                 edit: this.$('button[data-action=edit]'),
210                 save: this.$('button[data-action=save]'),
211                 cancel: this.$('button[data-action=cancel]'),
212             };
213
214             this.rte = new website.RTE(this);
215             this.rte.on('change', this, this.proxy('rte_changed'));
216
217             return $.when(
218                 this._super.apply(this, arguments),
219                 this.rte.prependTo(this.$('#website-top-edit .nav.pull-right'))
220             );
221         },
222         edit: function () {
223             var self = this;
224             this.$buttons.edit.prop('disabled', true);
225             this.$('#website-top-view').hide();
226             this.$('#website-top-edit').show();
227             $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
228
229             var $editables = $('[data-oe-model][data-oe-xpath]')
230                     // FIXME: propagation should make "meta" blocks non-editable in the first place...
231                     .not('link, script')
232                     .not('[data-oe-type]')
233                     .not('.oe_snippet_editor')
234                     .prop('contentEditable', true)
235                     .addClass('oe_editable');
236             this.rte.start_edition($editables);
237         },
238         rte_changed: function () {
239             this.$buttons.save.prop('disabled', false);
240         },
241         save: function () {
242             var self = this;
243
244             observer.disconnect();
245             var defs = _(CKEDITOR.instances).chain()
246                 .filter(function (editor) { return editor.element.hasClass('oe_dirty'); })
247                 .map(function (editor) {
248                     var $el = $(editor.element.$);
249                     // TODO: Add a queue with concurrency limit in webclient
250                     // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
251                     return self.saving_mutex.exec(function () {
252                         return self.saveEditor(editor)
253                             .fail(function () {
254                                 var data = $el.data();
255                                 console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
256                             });
257                     });
258                 }).value();
259             return $.when.apply(null, defs).then(function () {
260                 window.location.reload();
261             });
262         },
263         /**
264          * Saves an RTE content, which always corresponds to a view section (?).
265          */
266         saveEditor: function (editor) {
267             var element = editor.element;
268             editor.destroy();
269             element.removeClass('cke_focus')
270                    .removeClass('oe_editable')
271                    .removeAttribute('contentEditable');
272             var data = element.getOuterHtml();
273             return openerp.jsonRpc('/web/dataset/call', 'call', {
274                 model: 'ir.ui.view',
275                 method: 'save',
276                 args: [element.data('oe-id'), element.data('oe-xpath'), data],
277             });
278         },
279         cancel: function () {
280             window.location.reload();
281         },
282     });
283
284     /* ----- RICH TEXT EDITOR ---- */
285     website.RTE = openerp.Widget.extend({
286         tagName: 'li',
287         id: 'oe_rte_toolbar',
288         className: 'oe_right oe_rte_toolbar',
289         // editor.ui.items -> possible commands &al
290         // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
291
292         start_edition: function ($elements) {
293             var self = this;
294             $elements
295                 .each(function () {
296                     var node = this;
297                     var $node = $(node);
298                     var editor = CKEDITOR.inline(this, self._config());
299                     editor.on('instanceReady', function () {
300                         observer.observe(node, OBSERVER_CONFIG);
301                     });
302                     $node.one('content_changed', function () {
303                         $node.addClass('oe_dirty');
304                         self.trigger('change');
305                     });
306                 });
307         },
308
309         _current_editor: function () {
310             return CKEDITOR.currentInstance;
311         },
312         _config: function () {
313             var removed_plugins = [
314                     // remove custom context menu
315                     'contextmenu,tabletools,liststyle',
316                     // magicline captures mousein/mouseout => draggable does not work
317                     'magicline'
318             ];
319             return {
320                 // Disable auto-generated titles
321                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
322                 title: false,
323                 removePlugins: removed_plugins.join(','),
324                 uiColor: '',
325                 // FIXME: currently breaks RTE?
326 //                // Ensure no config file is loaded
327 //                customConfig: '',
328                 // Disable ACF
329                 allowedContent: true,
330                 // Don't insert paragraphs around content in e.g. <li>
331                 autoParagraph: false,
332                 filebrowserImageUploadUrl: "/website/attach",
333                 // Support for sharedSpaces in 4.x
334                 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
335                 // Place toolbar in controlled location
336                 sharedSpaces: { top: 'oe_rte_toolbar' },
337                 toolbar: [
338                     {name: 'basicstyles', items: [
339                         "Bold", "Italic", "Underline", "Strike", "Subscript",
340                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
341                     ]},{
342                     name: 'span', items: [
343                         "Link", "Unlink", "Blockquote", "BulletedList",
344                         "NumberedList", "Indent", "Outdent"
345                     ]},{
346                     name: 'justify', items: [
347                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
348                     ]},{
349                     name: 'special', items: [
350                         "Image", "TableButton"
351                     ]},{
352                     name: 'styles', items: [
353                         "Styles"
354                     ]}
355                 ],
356                 // styles dropdown in toolbar
357                 stylesSet: [
358                     {name: "Normal", element: 'p'},
359                     {name: "Heading 1", element: 'h1'},
360                     {name: "Heading 2", element: 'h2'},
361                     {name: "Heading 3", element: 'h3'},
362                     {name: "Heading 4", element: 'h4'},
363                     {name: "Heading 5", element: 'h5'},
364                     {name: "Heading 6", element: 'h6'},
365                     {name: "Formatted", element: 'pre'},
366                     {name: "Address", element: 'address'},
367                     // emphasis
368                     {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
369                     {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
370                     {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
371                     {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
372                     {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
373                     {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
374                 ],
375             };
376         },
377     });
378
379     website.editor = { };
380     website.editor.Dialog = openerp.Widget.extend({
381         events: {
382             'hidden.bs.modal': 'destroy',
383             'click button.save': 'save',
384         },
385         init: function (editor) {
386             this._super();
387             this.editor = editor;
388         },
389         start: function () {
390             var sup = this._super();
391             this.$el.modal();
392             return sup;
393         },
394         save: function () {
395             this.$el.modal('hide');
396         },
397     });
398
399     website.editor.LinkDialog = website.editor.Dialog.extend({
400         template: 'website.editor.dialog.link',
401         events: _.extend({}, website.editor.Dialog.prototype.events, {
402             'change .url-source': function (e) { this.changed($(e.target)); },
403             'click div.existing a': 'select_page',
404         }),
405         init: function (editor) {
406             this._super(editor);
407             // url -> name mapping for existing pages
408             this.pages = Object.create(null);
409             // name -> url mapping for the same
410             this.pages_by_name = Object.create(null);
411         },
412         start: function () {
413             var element;
414             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
415                 this.editor.getSelection().selectElement(element);
416             }
417             this.element = element;
418
419             return $.when(
420                 this.fetch_pages().done(this.proxy('fill_pages')),
421                 this._super()
422             ).done(this.proxy('bind_data'));
423         },
424         /**
425          * Greatly simplified version of CKEDITOR's
426          * plugins.link.dialogs.link.onOk.
427          *
428          * @param {String} url
429          * @param {Boolean} [new_window=false]
430          * @param {String} [label=null]
431          */
432         make_link: function (url, new_window, label) {
433             var attributes = {href: url, 'data-cke-saved-href': url};
434             var to_remove = [];
435             if (new_window) {
436                 attributes['target'] = '_blank';
437             } else {
438                 to_remove.push('target');
439             }
440
441             if (this.element) {
442                 this.element.setAttributes(attributes);
443                 this.element.removeAttributes(to_remove);
444             } else {
445                 var selection = this.editor.getSelection();
446                 var range = selection.getRanges(true)[0];
447
448                 if (range.collapsed) {
449                     //noinspection JSPotentiallyInvalidConstructorUsage
450                     var text = new CKEDITOR.dom.text(label || url);
451                     range.insertNode(text);
452                     range.selectNodeContents(text);
453                 }
454
455                 //noinspection JSPotentiallyInvalidConstructorUsage
456                 new CKEDITOR.style({
457                     type: CKEDITOR.STYLE_INLINE,
458                     element: 'a',
459                     attributes: attributes,
460                 }).applyToRange(range);
461
462                 // focus dance between RTE & dialog blow up the stack in Safari
463                 // and Chrome, so defer select() until dialog has been closed
464                 setTimeout(function () {
465                     range.select();
466                 }, 0);
467             }
468         },
469         save: function () {
470             var self = this, _super = this._super.bind(this);
471             var $e = this.$('.url-source').filter(function () { return !!this.value; });
472
473             var val = $e.val(), done = $.when();
474             if ($e.hasClass('email-address')) {
475                 this.make_link('mailto:' + val, false, val);
476             } else if ($e.hasClass('pages')) {
477                 // ``val`` is the *name* of the page
478                 var url = this.pages_by_name[val];
479                 if (!url) {
480                     // Create the page, get the URL back
481                     done = $.get(_.str.sprintf(
482                         '/pagenew/%s?noredirect', encodeURIComponent(val)))
483                         .then(function (response) {
484                             url = response;
485                         });
486                 }
487                 done.then(function () {
488                     self.make_link(url, false, val);
489                 });
490             } else {
491                 this.make_link(val, this.$('input.window-new').prop('checked'));
492             }
493             done.then(_super);
494         },
495         bind_data: function () {
496             var href = this.element && (this.element.data( 'cke-saved-href')
497                                     ||  this.element.getAttribute('href'));
498             if (!href) { return; }
499
500             var match, $control;
501             if (match = /(mailto):(.+)/.exec(href)) {
502                 $control = this.$('input.email-address').val(match[2]);
503             } else if(href in this.pages) {
504                 $control = this.$('input.pages').val(this.pages[href]);
505             }
506             if (!$control) {
507                 $control = this.$('input.url').val(href);
508             }
509
510             this.changed($control);
511
512             this.$('input.window-new').prop(
513                 'checked', this.element.getAttribute('target') === '_blank');
514         },
515         changed: function ($e) {
516             $e.closest('li.list-group-item').addClass('active')
517               .siblings().removeClass('active');
518             this.$('.url-source').not($e).val('');
519         },
520         /**
521          * Selected an existing page in dropdown
522          */
523         select_page: function (e) {
524             e.preventDefault();
525             e.stopPropagation();
526             var $target = $(e.target);
527             this.$('input.pages').val($target.text()).change();
528             // No #dropdown('close'), and using #dropdown('toggle') sur
529             // #closest('.dropdown') makes the dropdown not work correctly
530             $target.closest('.open').removeClass('open')
531         },
532         /**
533          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
534          * if the editor is set directly on a link it will thus not work.
535          */
536         get_selected_link: function () {
537             var sel = this.editor.getSelection(),
538                 el = sel.getSelectedElement();
539             if (el && el.is('a')) { return el; }
540
541             var range = sel.getRanges(true)[0];
542             if (!range) { return null; }
543
544             range.shrink(CKEDITOR.SHRINK_TEXT);
545             return this.editor.elementPath(range.getCommonAncestor())
546                               .contains('a');
547
548         },
549         fetch_pages: function () {
550             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
551                 model: 'website',
552                 method: 'list_pages',
553                 args: [],
554                 kwargs: {}
555             });
556         },
557         fill_pages: function (results) {
558             var self = this;
559             var $pages = this.$('div.existing ul').empty();
560             _(results).each(function (result) {
561                 self.pages[result.url] = result.name;
562                 self.pages_by_name[result.name] = result.url;
563                 var $link = $('<a>').attr('href', result.url).text(result.name);
564                 $('<li>').append($link).appendTo($pages);
565             });
566         },
567     });
568     website.editor.ImageDialog = website.editor.Dialog.extend({
569         template: 'website.editor.dialog.image',
570         events: _.extend({}, website.editor.Dialog.prototype.events, {
571             'change .url-source': function (e) { this.changed($(e.target)); },
572             'click button.filepicker': function () {
573                 this.$('input[type=file]').click();
574             },
575             'change input[type=file]': 'file_selection',
576             'change input.url': 'preview_image',
577             'click .existing-attachments a': 'select_existing',
578         }),
579         start: function () {
580             var selection = this.editor.getSelection();
581             var el = selection && selection.getSelectedElement();
582             this.element = null;
583             if (el && el.is('img')) {
584                 this.element = el;
585                 this.set_image(el.getAttribute('src'));
586             }
587
588             return $.when(
589                 this._super(),
590                 this.fetch_existing().then(this.proxy('fetched_existing')));
591         },
592         save: function () {
593             var url = this.$('input.url').val();
594             var element, editor = this.editor;
595             if (!(element = this.element)) {
596                 element = editor.document.createElement('img');
597                 // focus event handler interactions between bootstrap (modal)
598                 // and ckeditor (RTE) lead to blowing the stack in Safari and
599                 // Chrome (but not FF) when this is done synchronously =>
600                 // defer insertion so modal has been hidden & destroyed before
601                 // it happens
602                 setTimeout(function () {
603                     editor.insertElement(element);
604                 }, 0);
605             }
606             element.setAttribute('src', url);
607             this._super();
608         },
609
610         /**
611          * Sets the provided image url as the dialog's value-to-save and
612          * refreshes the preview element to use it.
613          */
614         set_image: function (url) {
615             this.$('input.url').val(url);
616             this.preview_image();
617         },
618
619         file_selection: function (e) {
620             this.$('button.filepicker').removeClass('btn-danger btn-success');
621
622             var self = this;
623             var callback = _.uniqueId('func_');
624             this.$('input[name=func]').val(callback);
625
626             window[callback] = function (url, error) {
627                 delete window[callback];
628                 self.file_selected(url, error);
629             };
630             this.$('form').submit();
631         },
632         file_selected: function(url, error) {
633             var $button = this.$('button.filepicker');
634             if (error) {
635                 $button.addClass('btn-danger');
636                 return;
637             }
638             $button.addClass('btn-success');
639             this.set_image(url);
640         },
641         preview_image: function () {
642             var image = this.$('input.url').val();
643             if (!image) { return; }
644
645             this.$('img.image-preview').attr('src', image);
646         },
647
648         fetch_existing: function () {
649             // FIXME: lazy load attachments?
650             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
651                 model: 'ir.attachment',
652                 method: 'search_read',
653                 args: [],
654                 kwargs: {
655                     fields: ['name'],
656                     domain: [['res_model', '=', 'ir.ui.view']],
657                     order: 'name',
658                 }
659             });
660         },
661         fetched_existing: function (records) {
662             // Create rows of 3 records
663             var rows = _(records).chain()
664                 .groupBy(function (_, index) { return Math.floor(index / 3); })
665                 .values()
666                 .value();
667             this.$('.existing-attachments').replaceWith(
668                 openerp.qweb.render('website.editor.dialog.image.existing', {rows: rows}));
669         },
670         select_existing: function (e) {
671             e.preventDefault();
672             this.set_image(e.currentTarget.getAttribute('href'));
673         },
674     });
675
676
677     var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
678     var OBSERVER_CONFIG = {
679         childList: true,
680         attributes: true,
681         characterData: true,
682         subtree: true,
683         attributeOldValue: true,
684     };
685     var observer = new Observer(function (mutations) {
686         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
687         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
688         //       will not mark dirty on attribute changes (@class, img/@src,
689         //       a/@href, ...)
690         _(mutations).chain()
691             .filter(function (m) {
692                 switch(m.type) {
693                 case 'attributes': // ignore .cke_focus being added or removed
694                     // if attribute is not a class, can't be .cke_focus change
695                     if (m.attributeName !== 'class') { return true; }
696
697                     // find out what classes were added or removed
698                     var oldClasses = m.oldValue.split(/\s+/);
699                     var newClasses = m.target.className.split(/\s+/);
700                     var change = _.union(_.difference(oldClasses, newClasses),
701                                          _.difference(newClasses, oldClasses));
702                     // ignore mutation if the *only* change is .cke_focus
703                     return change.length !== 1 || change[0] === 'cke_focus';
704                 case 'childList':
705                     // <br type="_moz"> appears when focusing RTE in FF, ignore
706                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
707                 default:
708                     return true;
709                 }
710             })
711             .map(function (m) {
712                 var node = m.target;
713                 while (node && !$(node).hasClass('oe_editable')) {
714                     node = node.parentNode;
715                 }
716                 return node;
717             })
718             .compact()
719             .uniq()
720             .each(function (node) { $(node).trigger('content_changed'); })
721     });
722 })();