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