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