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