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