[FIX] Prevent dropdown closing when a contenteditable children is focused
[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.add_template_file('/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         $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
16             // Prevent dropdown closing when a contenteditable children is focused
17             if (ev.originalEvent
18                     && $(ev.target).has(ev.originalEvent.target).length
19                     && $(ev.originalEvent.target).is('[contenteditable]')) {
20                 ev.preventDefault();
21             }
22         });
23     });
24
25     function link_dialog(editor) {
26         return new website.editor.RTELinkDialog(editor).appendTo(document.body);
27     }
28     function image_dialog(editor) {
29         return new website.editor.RTEImageDialog(editor).appendTo(document.body);
30     }
31
32     // only enable editors manually
33     CKEDITOR.disableAutoInline = true;
34     // EDIT ALL THE THINGS
35     CKEDITOR.dtd.$editable = $.extend(
36         {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
37     // Disable removal of empty elements on CKEDITOR activation. Empty
38     // elements are used for e.g. support of FontAwesome icons
39     CKEDITOR.dtd.$removeEmpty = {};
40
41     website.init_editor = function () {
42         CKEDITOR.plugins.add('customdialogs', {
43 //            requires: 'link,image',
44             init: function (editor) {
45                 editor.on('doubleclick', function (evt) {
46                     var element = evt.data.element;
47                     if (element.is('img')
48                             && !element.data('cke-realelement')
49                             && !element.isReadOnly()
50                             && (element.data('oe-model') !== 'ir.ui.view')) {
51                         image_dialog(editor);
52                         return;
53                     }
54
55                     element = get_selected_link(editor) || evt.data.element;
56                     if (element.isReadOnly()
57                         || !element.is('a')
58                         || element.data('oe-model')) {
59                         return;
60                     }
61
62                     editor.getSelection().selectElement(element);
63                     link_dialog(editor);
64                 }, null, null, 500);
65
66                 //noinspection JSValidateTypes
67                 editor.addCommand('link', {
68                     exec: function (editor) {
69                         link_dialog(editor);
70                         return true;
71                     },
72                     canUndo: false,
73                     editorFocus: true,
74                 });
75                 //noinspection JSValidateTypes
76                 editor.addCommand('image', {
77                     exec: function (editor) {
78                         image_dialog(editor);
79                         return true;
80                     },
81                     canUndo: false,
82                     editorFocus: true,
83                 });
84
85                 editor.ui.addButton('Link', {
86                     label: 'Link',
87                     command: 'link',
88                     toolbar: 'links,10',
89                     icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
90                 });
91                 editor.ui.addButton('Image', {
92                     label: 'Image',
93                     command: 'image',
94                     toolbar: 'insert,10',
95                     icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
96                 });
97
98                 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
99             }
100         });
101         CKEDITOR.plugins.add( 'tablebutton', {
102             requires: 'panelbutton,floatpanel',
103             init: function( editor ) {
104                 var label = "Table";
105
106                 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
107                     label: label,
108                     title: label,
109                     // use existing 'table' icon
110                     icon: 'table',
111                     modes: { wysiwyg: true },
112                     editorFocus: true,
113                     // panel opens in iframe, @css is CSS file <link>-ed within
114                     // frame document, @attributes are set on iframe itself.
115                     panel: {
116                         css: '/website/static/src/css/editor.css',
117                         attributes: { 'role': 'listbox', 'aria-label': label, },
118                     },
119
120                     onBlock: function (panel, block) {
121                         block.autoSize = true;
122                         block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
123                             rows: 5,
124                             cols: 5,
125                         }));
126
127                         var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
128                             var $e = $(e.target);
129                             var y = $e.index() + 1;
130                             var x = $e.closest('tr').index() + 1;
131
132                             $table
133                                 .find('td').removeClass('selected').end()
134                                 .find('tr:lt(' + String(x) + ')')
135                                 .children().filter(function () { return $(this).index() < y; })
136                                 .addClass('selected');
137                         }).on('click', 'td', function (e) {
138                             var $e = $(e.target);
139
140                             //noinspection JSPotentiallyInvalidConstructorUsage
141                             var table = new CKEDITOR.dom.element(
142                                 $(openerp.qweb.render('website.editor.table', {
143                                     rows: $e.closest('tr').index() + 1,
144                                     cols: $e.index() + 1,
145                                 }))[0]);
146
147                             editor.insertElement(table);
148                             setTimeout(function () {
149                                 //noinspection JSPotentiallyInvalidConstructorUsage
150                                 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
151                                 var range = editor.createRange();
152                                 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
153                                 range.select();
154                             }, 0);
155                         });
156
157                         block.element.getDocument().getBody().setStyle('overflow', 'hidden');
158                         CKEDITOR.ui.fire('ready', this);
159                     },
160                 });
161             }
162         });
163
164         CKEDITOR.plugins.add('oeref', {
165             requires: 'widget',
166
167             init: function (editor) {
168                 editor.widgets.add('oeref', {
169                     editables: { text: '*' },
170
171                     upcast: function (el) {
172                         return el.attributes['data-oe-type']
173                             && el.attributes['data-oe-type'] !== 'monetary';
174                     },
175                 });
176                 editor.widgets.add('monetary', {
177                     editables: { text: 'span.oe_currency_value' },
178
179                     upcast: function (el) {
180                         return el.attributes['data-oe-type'] === 'monetary';
181                     }
182                 });
183             }
184         });
185
186         CKEDITOR.plugins.add('bootstrapcombo', {
187             requires: 'richcombo',
188
189             init: function (editor) {
190                 var config = editor.config;
191
192                 editor.ui.addRichCombo('BootstrapLinkCombo', {
193                     // default title
194                     label: "Links",
195                     // hover
196                     title: "Link styling",
197                     toolbar: 'styles,10',
198                     allowedContent: ['a'],
199
200                     panel: {
201                                             css: [
202                             '/website/static/lib/bootstrap/css/bootstrap.css',
203                             CKEDITOR.skin.getPath( 'editor' )
204                         ].concat( config.contentsCss ),
205                         multiSelect: true,
206                     },
207
208                     types: {
209                         'basic': 'btn-default',
210                         'primary': 'btn-primary',
211                         'success': 'btn-success',
212                         'info': 'btn-info',
213                         'warning': 'btn-warning',
214                         'danger': 'btn-danger',
215                     },
216
217                     sizes: {
218                         'large': 'btn-lg',
219                         'default': '',
220                         'small': 'btn-sm',
221                         'extra small': 'btn-xs',
222                     },
223
224                     init: function () {
225                         this.add('', 'Reset');
226                         this.startGroup("Types");
227                         for(var type in this.types) {
228                             if (!this.types.hasOwnProperty(type)) { continue; }
229                             var cls = this.types[type];
230                             var el = _.str.sprintf(
231                                 '<span class="btn %s">%s</span>',
232                                 cls, type);
233                             this.add(type, el);
234                         }
235                         this.startGroup("Sizes");
236                         for (var size in this.sizes) {
237                             if (!this.sizes.hasOwnProperty(size)) { continue; }
238                             cls = this.sizes[size];
239
240                             el = _.str.sprintf(
241                                 '<span class="btn btn-default %s">%s</span>',
242                                 cls, size);
243                             this.add(size, el);
244                         }
245                         this.commit();
246                     },
247                     onRender: function () {
248                         var self = this;
249                         editor.on('selectionChange', function (e) {
250                             var path = e.data.path, el;
251
252                             if (!(el = path.contains('a'))) {
253                                 self.element = null;
254                                 self.disable();
255                                 return;
256                             }
257
258                             self.enable();
259                             // This is crap, but getting the currently selected
260                             // element from within onOpen absolutely does not
261                             // work, so store the "current" element in the
262                             // widget instead
263                             self.element = el;
264                         });
265                         setTimeout(function () {
266                             // Because I can't find any normal hook where the
267                             // bloody button's bloody element is available
268                             self.disable();
269                         }, 0);
270                     },
271                     onOpen: function () {
272                         this.showAll();
273                         this.unmarkAll();
274
275                         for(var val in this.types) {
276                             if (!this.types.hasOwnProperty(val)) { continue; }
277                             var cls = this.types[val];
278                             if (!this.element.hasClass(cls)) { continue; }
279
280                             this.mark(val);
281                             break;
282                         }
283
284                         var found;
285                         for(val in this.sizes) {
286                             if (!this.sizes.hasOwnProperty(val)) { continue; }
287                             cls = this.sizes[val];
288                             if (!cls || !this.element.hasClass(cls)) { continue; }
289
290                             found = true;
291                             this.mark(val);
292                             break;
293                         }
294                         if (!found && this.element.hasClass('btn')) {
295                             this.mark('default');
296                         }
297                     },
298                     onClick: function (value) {
299                         editor.focus();
300                         editor.fire('saveShapshot');
301
302                         // basic btn setup
303                         var el = this.element;
304                         if (!el.hasClass('btn')) {
305                             el.addClass('btn');
306                             el.addClass('btn-default');
307                         }
308
309                         if (!value) {
310                             this.setClass(this.types);
311                             this.setClass(this.sizes);
312                             el.removeClass('btn');
313                         } else if (value in this.types) {
314                             this.setClass(this.types, value);
315                         } else if (value in this.sizes) {
316                             this.setClass(this.sizes, value);
317                         }
318
319                         editor.fire('saveShapshot');
320                     },
321                     setClass: function (classMap, value) {
322                         var element = this.element;
323                         _(classMap).each(function (cls) {
324                             if (!cls) { return; }
325                             element.removeClass(cls);
326                         }.bind(this));
327
328                         var cls = classMap[value];
329                         if (cls) {
330                             element.addClass(cls);
331                         }
332                     }
333                 });
334             },
335         });
336
337         var editor = new website.EditorBar();
338         var $body = $(document.body);
339         editor.prependTo($body).then(function () {
340             if (location.search.indexOf("enable_editor") >= 0) {
341                 editor.edit();
342             }
343         });
344         $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
345     };
346         /* ----- TOP EDITOR BAR FOR ADMIN ---- */
347     website.EditorBar = openerp.Widget.extend({
348         template: 'website.editorbar',
349         events: {
350             'click button[data-action=edit]': 'edit',
351             'click button[data-action=save]': 'save',
352             'click button[data-action=cancel]': 'cancel',
353         },
354         container: 'body',
355         customize_setup: function() {
356             var self = this;
357             var view_name = $(document.documentElement).data('view-xmlid');
358             var menu = $('#customize-menu');
359             this.$('#customize-menu-button').click(function(event) {
360                 menu.empty();
361                 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
362                     function(result) {
363                         _.each(result, function (item) {
364                             if (item.header) {
365                                 menu.append('<li class="dropdown-header">' + item.name + '</li>');
366                             } else {
367                                 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
368                                     item.id, item.active ? '' : '-empty', item.name));
369                             }
370                         });
371                         // Adding Static Menus
372                         menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
373                         menu.append('<li class="divider"></li><li><a data-action="ace" href="#">HTML Editor</a></li>');
374                         self.trigger('rte:customize_menu_ready');
375                     }
376                 );
377             });
378             menu.on('click', 'a[data-action!=ace]', function (event) {
379                 var view_id = $(event.currentTarget).data('view-id');
380                 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
381                     'view_id': view_id
382                 }).then( function() {
383                     window.location.reload();
384                 });
385             });
386         },
387         start: function() {
388             var self = this;
389
390             this.saving_mutex = new openerp.Mutex();
391
392             this.$('#website-top-edit').hide();
393             this.$('#website-top-view').show();
394
395             $('.dropdown-toggle').dropdown();
396             this.customize_setup();
397
398             this.$buttons = {
399                 edit: this.$('button[data-action=edit]'),
400                 save: this.$('button[data-action=save]'),
401                 cancel: this.$('button[data-action=cancel]'),
402             };
403
404             this.rte = new website.RTE(this);
405             this.rte.on('change', this, this.proxy('rte_changed'));
406             this.rte.on('rte:ready', this, function () {
407                 self.trigger('rte:ready');
408             });
409
410             return $.when(
411                 this._super.apply(this, arguments),
412                 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
413             );
414         },
415         edit: function () {
416             this.$buttons.edit.prop('disabled', true);
417             this.$('#website-top-view').hide();
418             this.$('#website-top-edit').show();
419             $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
420
421             this.rte.start_edition();
422         },
423         rte_changed: function () {
424             this.$buttons.save.prop('disabled', false);
425         },
426         save: function () {
427             var self = this;
428
429             observer.disconnect();
430             var editor = this.rte.editor;
431             var root = editor.element.$;
432             editor.destroy();
433             // FIXME: select editables then filter by dirty?
434             var defs = this.rte.fetch_editables(root)
435                 .filter('.oe_dirty')
436                 .removeAttr('contentEditable')
437                 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
438                 .map(function () {
439                     var $el = $(this);
440                     // TODO: Add a queue with concurrency limit in webclient
441                     // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
442                     return self.saving_mutex.exec(function () {
443                         return self.saveElement($el)
444                             .then(undefined, function (thing, response) {
445                                 // because ckeditor regenerates all the dom,
446                                 // we can't just setup the popover here as
447                                 // everything will be destroyed by the DOM
448                                 // regeneration. Add markings instead, and
449                                 // returns a new rejection with all relevant
450                                 // info
451                                 var id = _.uniqueId('carlos_danger_');
452                                 $el.addClass('oe_dirty oe_carlos_danger');
453                                 $el.addClass(id);
454                                 return $.Deferred().reject({
455                                     id: id,
456                                     error: response.data,
457                                 });
458                             });
459                     });
460                 }).get();
461             return $.when.apply(null, defs).then(function () {
462                 website.reload();
463             }, function (failed) {
464                 // If there were errors, re-enable edition
465                 self.rte.start_edition(true).then(function () {
466                     // jquery's deferred being a pain in the ass
467                     if (!_.isArray(failed)) { failed = [failed]; }
468
469                     _(failed).each(function (failure) {
470                         $(root).find('.' + failure.id)
471                             .removeClass(failure.id)
472                             .popover({
473                                 trigger: 'hover',
474                                 content: failure.error.message,
475                                 placement: 'auto top',
476                             })
477                             // Force-show popovers so users will notice them.
478                             .popover('show');
479                     })
480                 });
481             });
482         },
483         /**
484          * Saves an RTE content, which always corresponds to a view section (?).
485          */
486         saveElement: function ($el) {
487             var markup = $el.prop('outerHTML');
488             return openerp.jsonRpc('/web/dataset/call', 'call', {
489                 model: 'ir.ui.view',
490                 method: 'save',
491                 args: [$el.data('oe-id'), markup,
492                        $el.data('oe-xpath') || null,
493                        website.get_context()],
494             });
495         },
496         cancel: function () {
497             website.reload();
498         },
499     });
500
501     var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
502     /* ----- RICH TEXT EDITOR ---- */
503     website.RTE = openerp.Widget.extend({
504         tagName: 'li',
505         id: 'oe_rte_toolbar',
506         className: 'oe_right oe_rte_toolbar',
507         // editor.ui.items -> possible commands &al
508         // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
509
510         init: function (EditorBar) {
511             this.EditorBar = EditorBar;
512             this._super.apply(this, arguments);
513         },
514
515         /**
516          * In Webkit-based browsers, triple-click will select a paragraph up to
517          * the start of the next "paragraph" including any empty space
518          * inbetween. When said paragraph is removed or altered, it nukes
519          * the empty space and brings part of the content of the next
520          * "paragraph" (which may well be e.g. an image) into the current one,
521          * completely fucking up layouts and breaking snippets.
522          *
523          * Try to fuck around with selections on triple-click to attempt to
524          * fix this garbage behavior.
525          *
526          * Note: for consistent behavior we may actually want to take over
527          * triple-clicks, in all browsers in order to ensure consistent cross-
528          * platform behavior instead of being at the mercy of rendering engines
529          * & platform selection quirks?
530          */
531         webkitSelectionFixer: function (root) {
532             root.addEventListener('click', function (e) {
533                 // only webkit seems to have a fucked up behavior, ignore others
534                 // FIXME: $.browser goes away in jquery 1.9...
535                 if (!$.browser.webkit) { return; }
536                 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
537                 // The detail attribute indicates the number of times a mouse button has been pressed
538                 // we just want the triple click
539                 if (e.detail !== 3) { return; }
540                 e.preventDefault();
541
542                 // Get closest block-level element to the triple-clicked
543                 // element (using ckeditor's block list because why not)
544                 var $closest_block = $(e.target).closest(blocks_selector);
545
546                 // manually set selection range to the content of the
547                 // triple-clicked block-level element, to avoid crossing over
548                 // between block-level elements
549                 document.getSelection().selectAllChildren($closest_block[0]);
550             });
551         },
552         tableNavigation: function (root) {
553             var self = this;
554             $(root).on('keydown', function (e) {
555                 // ignore non-TAB
556                 if (e.which !== 9) { return; }
557
558                 if (self.handleTab(e)) {
559                     e.preventDefault();
560                 }
561             });
562         },
563         /**
564          * Performs whatever operation is necessary on a [TAB] hit, returns
565          * ``true`` if the event's default should be cancelled (if the TAB was
566          * handled by the function)
567          */
568         handleTab: function (event) {
569             var forward = !event.shiftKey;
570
571             var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
572             var $cell = $(root).closest('td,th');
573
574             if (!$cell.length) { return false; }
575
576             var cell = $cell[0];
577
578             // find cell in same row
579             var row = cell.parentNode;
580             var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
581             if (sibling) {
582                 document.getSelection().selectAllChildren(sibling);
583                 return true;
584             }
585
586             // find cell in previous/next row
587             var table = row.parentNode;
588             var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
589             if (sibling_row) {
590                 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
591                 document.getSelection().selectAllChildren(new_cell);
592                 return true;
593             }
594
595             // at edge cells, copy word/openoffice behavior: if going backwards
596             // from first cell do nothing, if going forwards from last cell add
597             // a row
598             if (forward) {
599                 var row_size = row.cells.length;
600                 var new_row = document.createElement('tr');
601                 while(row_size--) {
602                     var newcell = document.createElement('td');
603                     // zero-width space
604                     newcell.textContent = '\u200B';
605                     new_row.appendChild(newcell);
606                 }
607                 table.appendChild(new_row);
608                 document.getSelection().selectAllChildren(new_row.cells[0]);
609             }
610
611             return true;
612         },
613         /**
614          * Makes the page editable
615          *
616          * @param {Boolean} [restart=false] in case the edition was already set
617          *                                  up once and is being re-enabled.
618          * @returns {$.Deferred} deferred indicating when the RTE is ready
619          */
620         start_edition: function (restart) {
621             var self = this;
622             // create a single editor for the whole page
623             var root = document.getElementById('wrapwrap');
624             if (!restart) {
625                 $(root).on('dragstart', 'img', function (e) {
626                     e.preventDefault();
627                 });
628                 this.webkitSelectionFixer(root);
629                 this.tableNavigation(root);
630             }
631             var def = $.Deferred();
632             var editor = this.editor = CKEDITOR.inline(root, self._config());
633             editor.on('instanceReady', function () {
634                 editor.setReadOnly(false);
635                 // ckeditor set root to editable, disable it (only inner
636                 // sections are editable)
637                 // FIXME: are there cases where the whole editor is editable?
638                 editor.editable().setReadOnly(true);
639
640                 self.setup_editables(root);
641
642                 // disable firefox's broken table resizing thing
643                 document.execCommand("enableObjectResizing", false, "false");
644                 document.execCommand("enableInlineTableEditing", false, "false");
645
646                 self.trigger('rte:ready');
647                 def.resolve();
648             });
649             return def;
650         },
651
652         setup_editables: function (root) {
653             // selection of editable sub-items was previously in
654             // EditorBar#edit, but for some unknown reason the elements were
655             // apparently removed and recreated (?) at editor initalization,
656             // and observer setup was lost.
657             var self = this;
658             // setup dirty-marking for each editable element
659             this.fetch_editables(root)
660                 .addClass('oe_editable')
661                 .each(function () {
662                     var node = this;
663                     var $node = $(node);
664                     // only explicitly set contenteditable on view sections,
665                     // cke widgets system will do the widgets themselves
666                     if ($node.data('oe-model') === 'ir.ui.view') {
667                         node.contentEditable = true;
668                     }
669
670                     observer.observe(node, OBSERVER_CONFIG);
671                     $node.one('content_changed', function () {
672                         $node.addClass('oe_dirty');
673                         self.trigger('change');
674                     });
675                 });
676         },
677
678         fetch_editables: function (root) {
679             return $(root).find('[data-oe-model]')
680                 .not('link, script')
681                 .not('.oe_snippet_editor')
682                 .filter(function () {
683                     var $this = $(this);
684                     // keep view sections and fields which are *not* in
685                     // view sections for top-level editables
686                     return $this.data('oe-model') === 'ir.ui.view'
687                        || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
688                 });
689         },
690
691         _current_editor: function () {
692             return CKEDITOR.currentInstance;
693         },
694         _config: function () {
695             // base plugins minus
696             // - magicline (captures mousein/mouseout -> breaks draggable)
697             // - contextmenu & tabletools (disable contextual menu)
698             // - bunch of unused plugins
699             var plugins = [
700                 'a11yhelp', 'basicstyles', 'blockquote',
701                 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
702                 'elementspath', 'enterkey', 'entities', 'filebrowser',
703                 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
704                 'indentblock', 'indentlist', 'justify',
705                 'list', 'pastefromword', 'pastetext', 'preview',
706                 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
707                 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
708             ];
709             return {
710                 // FIXME
711                 language: 'en',
712                 // Disable auto-generated titles
713                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
714                 title: false,
715                 plugins: plugins.join(','),
716                 uiColor: '',
717                 // FIXME: currently breaks RTE?
718                 // Ensure no config file is loaded
719                 customConfig: '',
720                 // Disable ACF
721                 allowedContent: true,
722                 // Don't insert paragraphs around content in e.g. <li>
723                 autoParagraph: false,
724                 // Don't automatically add &nbsp; or <br> in empty block-level
725                 // elements when edition starts
726                 fillEmptyBlocks: false,
727                 filebrowserImageUploadUrl: "/website/attach",
728                 // Support for sharedSpaces in 4.x
729                 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
730                 // Place toolbar in controlled location
731                 sharedSpaces: { top: 'oe_rte_toolbar' },
732                 toolbar: [{
733                     name: 'clipboard', items: [
734                         "Undo"
735                     ]},{
736                         name: 'basicstyles', items: [
737                         "Bold", "Italic", "Underline", "Strike", "Subscript",
738                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
739                     ]},{
740                     name: 'span', items: [
741                         "Link", "Blockquote", "BulletedList",
742                         "NumberedList", "Indent", "Outdent"
743                     ]},{
744                     name: 'justify', items: [
745                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
746                     ]},{
747                     name: 'special', items: [
748                         "Image", "TableButton"
749                     ]},{
750                     name: 'styles', items: [
751                         "Styles", "BootstrapLinkCombo"
752                     ]}
753                 ],
754                 // styles dropdown in toolbar
755                 stylesSet: [
756                     {name: "Normal", element: 'p'},
757                     {name: "Heading 1", element: 'h1'},
758                     {name: "Heading 2", element: 'h2'},
759                     {name: "Heading 3", element: 'h3'},
760                     {name: "Heading 4", element: 'h4'},
761                     {name: "Heading 5", element: 'h5'},
762                     {name: "Heading 6", element: 'h6'},
763                     {name: "Formatted", element: 'pre'},
764                     {name: "Address", element: 'address'}
765                 ],
766             };
767         },
768     });
769
770     website.editor = { };
771     website.editor.Dialog = openerp.Widget.extend({
772         events: {
773             'hidden.bs.modal': 'destroy',
774             'click button.save': 'save',
775         },
776         init: function (editor) {
777             this._super();
778             this.editor = editor;
779         },
780         start: function () {
781             var sup = this._super();
782             this.$el.modal({backdrop: 'static'});
783             return sup;
784         },
785         save: function () {
786             this.close();
787         },
788         close: function () {
789             this.$el.modal('hide');
790         },
791     });
792
793     website.editor.LinkDialog = website.editor.Dialog.extend({
794         template: 'website.editor.dialog.link',
795         events: _.extend({}, website.editor.Dialog.prototype.events, {
796             'change .url-source': function (e) { this.changed($(e.target)); },
797             'mousedown': function (e) {
798                 var $target = $(e.target).closest('.list-group-item');
799                 if (!$target.length || $target.hasClass('active')) {
800                     // clicked outside groups, or clicked in active groups
801                     return;
802                 }
803
804                 this.changed($target.find('.url-source'));
805             },
806             'click button.remove': 'remove_link',
807             'change input#link-text': function (e) {
808                 this.text = $(e.target).val()
809             },
810         }),
811         init: function (editor) {
812             this._super(editor);
813             // url -> name mapping for existing pages
814             this.pages = Object.create(null);
815             this.text = null;
816         },
817         start: function () {
818             var self = this;
819             return $.when(
820                 this.fetch_pages().done(this.proxy('fill_pages')),
821                 this._super()
822             ).done(function () {
823                 self.bind_data();
824             });
825         },
826         save: function () {
827             var self = this, _super = this._super.bind(this);
828             var $e = this.$('.list-group-item.active .url-source');
829             var val = $e.val();
830             if (!val || !$e[0].checkValidity()) {
831                 // FIXME: error message
832                 $e.closest('.form-group').addClass('has-error');
833                 $e.focus();
834                 return;
835             }
836
837             var done = $.when();
838             if ($e.hasClass('email-address')) {
839                 this.make_link('mailto:' + val, false, val);
840             } else if ($e.hasClass('existing')) {
841                 self.make_link(val, false, this.pages[val]);
842             } else if ($e.hasClass('pages')) {
843                 // Create the page, get the URL back
844                 done = $.get(_.str.sprintf(
845                         '/pagenew/%s?noredirect', encodeURI(val)))
846                     .then(function (response) {
847                         self.make_link(response, false, val);
848                     });
849             } else {
850                 this.make_link(val, this.$('input.window-new').prop('checked'));
851             }
852             done.then(_super);
853         },
854         make_link: function (url, new_window, label) {
855         },
856         bind_data: function (text, href, new_window) {
857             href = href || this.element && (this.element.data( 'cke-saved-href')
858                                     ||  this.element.getAttribute('href'));
859             if (!href) { return; }
860
861             if (new_window === undefined) {
862                 new_window = this.element.getAttribute('target') === '_blank';
863             }
864             if (text === undefined) {
865                 text = this.element.getText();
866             }
867
868             var match, $control;
869             if ((match = /mailto:(.+)/.exec(href))) {
870                 $control = this.$('input.email-address').val(match[1]);
871             } else if (href in this.pages) {
872                 $control = this.$('select.existing').val(href);
873             } else if ((match = /\/page\/(.+)/.exec(href))) {
874                 var actual_href = '/page/website.' + match[1];
875                 if (actual_href in this.pages) {
876                     $control = this.$('select.existing').val(actual_href);
877                 }
878             }
879             if (!$control) {
880                 $control = this.$('input.url').val(href);
881             }
882
883             this.changed($control);
884
885             this.$('input#link-text').val(text);
886             this.$('input.window-new').prop('checked', new_window);
887         },
888         changed: function ($e) {
889             this.$('.url-source').not($e).val('');
890             $e.closest('.list-group-item')
891                 .addClass('active')
892                 .siblings().removeClass('active')
893                 .addBack().removeClass('has-error');
894         },
895         fetch_pages: function () {
896             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
897                 model: 'website',
898                 method: 'list_pages',
899                 args: [null],
900                 kwargs: {
901                     context: website.get_context()
902                 },
903             });
904         },
905         fill_pages: function (results) {
906             var self = this;
907             var pages = this.$('select.existing')[0];
908             _(results).each(function (result) {
909                 self.pages[result.url] = result.name;
910
911                 pages.options[pages.options.length] =
912                         new Option(result.name, result.url);
913             });
914         },
915     });
916     website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
917         start: function () {
918             var element;
919             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
920                 this.editor.getSelection().selectElement(element);
921             }
922             this.element = element;
923             if (element) {
924                 this.add_removal_button();
925             }
926
927             return this._super();
928         },
929         add_removal_button: function () {
930             this.$('.modal-footer').prepend(
931                 openerp.qweb.render(
932                     'website.editor.dialog.link.footer-button'));
933         },
934         remove_link: function () {
935             var editor = this.editor;
936             // same issue as in make_link
937             setTimeout(function () {
938                 editor.removeStyle(new CKEDITOR.style({
939                     element: 'a',
940                     type: CKEDITOR.STYLE_INLINE,
941                     alwaysRemoveElement: true,
942                 }));
943             }, 0);
944             this.close();
945         },
946         /**
947          * Greatly simplified version of CKEDITOR's
948          * plugins.link.dialogs.link.onOk.
949          *
950          * @param {String} url
951          * @param {Boolean} [new_window=false]
952          * @param {String} [label=null]
953          */
954         make_link: function (url, new_window, label) {
955             var attributes = {href: url, 'data-cke-saved-href': url};
956             var to_remove = [];
957             if (new_window) {
958                 attributes['target'] = '_blank';
959             } else {
960                 to_remove.push('target');
961             }
962
963             if (this.element) {
964                 this.element.setAttributes(attributes);
965                 this.element.removeAttributes(to_remove);
966                 if (this.text) { this.element.setText(this.text); }
967             } else {
968                 var selection = this.editor.getSelection();
969                 var range = selection.getRanges(true)[0];
970
971                 if (range.collapsed) {
972                     //noinspection JSPotentiallyInvalidConstructorUsage
973                     var text = new CKEDITOR.dom.text(
974                         this.text || label || url);
975                     range.insertNode(text);
976                     range.selectNodeContents(text);
977                 }
978
979                 //noinspection JSPotentiallyInvalidConstructorUsage
980                 new CKEDITOR.style({
981                     type: CKEDITOR.STYLE_INLINE,
982                     element: 'a',
983                     attributes: attributes,
984                 }).applyToRange(range);
985
986                 // focus dance between RTE & dialog blow up the stack in Safari
987                 // and Chrome, so defer select() until dialog has been closed
988                 setTimeout(function () {
989                     range.select();
990                 }, 0);
991             }
992         },
993         /**
994          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
995          * if the editor is set directly on a link it will thus not work.
996          */
997         get_selected_link: function () {
998             return get_selected_link(this.editor);
999         },
1000     });
1001
1002     /**
1003      * ImageDialog widget. Lets users change an image, including uploading a
1004      * new image in OpenERP or selecting the image style (if supported by
1005      * the caller).
1006      *
1007      * Initialized as usual, but the caller can hook into two events:
1008      *
1009      * @event start({url, style}) called during dialog initialization and
1010      *                            opening, the handler can *set* the ``url``
1011      *                            and ``style`` properties on its parameter
1012      *                            to provide these as default values to the
1013      *                            dialog
1014      * @event save({url, style}) called during dialog finalization, the handler
1015      *                           is provided with the image url and style
1016      *                           selected by the users (or possibly the ones
1017      *                           originally passed in)
1018      */
1019     website.editor.ImageDialog = website.editor.Dialog.extend({
1020         template: 'website.editor.dialog.image',
1021         events: _.extend({}, website.editor.Dialog.prototype.events, {
1022             'change .url-source': function (e) { this.changed($(e.target)); },
1023             'click button.filepicker': function () {
1024                 this.$('input[type=file]').click();
1025             },
1026             'change input[type=file]': 'file_selection',
1027             'change input.url': 'preview_image',
1028             'click a[href=#existing]': 'browse_existing',
1029             'change select.image-style': 'preview_image',
1030         }),
1031
1032         start: function () {
1033             var $options = this.$('.image-style').children();
1034             this.image_styles = $options.map(function () { return this.value; }).get();
1035
1036             var o = { url: null, style: null, };
1037             // avoid typos, prevent addition of new properties to the object
1038             Object.preventExtensions(o);
1039             this.trigger('start', o);
1040
1041             if (o.url) {
1042                 if (o.style) {
1043                     this.$('.image-style').val(o.style);
1044                 }
1045                 this.set_image(o.url);
1046             }
1047
1048             return this._super();
1049         },
1050         save: function () {
1051             this.trigger('save', {
1052                 url: this.$('input.url').val(),
1053                 style: this.$('.image-style').val(),
1054             });
1055             return this._super();
1056         },
1057
1058         /**
1059          * Sets the provided image url as the dialog's value-to-save and
1060          * refreshes the preview element to use it.
1061          */
1062         set_image: function (url) {
1063             this.$('input.url').val(url);
1064             this.preview_image();
1065         },
1066
1067         file_selection: function () {
1068             this.$('button.filepicker').removeClass('btn-danger btn-success');
1069
1070             var self = this;
1071             var callback = _.uniqueId('func_');
1072             this.$('input[name=func]').val(callback);
1073
1074             window[callback] = function (url, error) {
1075                 delete window[callback];
1076                 self.file_selected(url, error);
1077             };
1078             this.$('form').submit();
1079         },
1080         file_selected: function(url, error) {
1081             var $button = this.$('button.filepicker');
1082             if (error) {
1083                 $button.addClass('btn-danger');
1084                 return;
1085             }
1086             $button.addClass('btn-success');
1087             this.set_image(url);
1088         },
1089         preview_image: function () {
1090             var image = this.$('input.url').val();
1091             if (!image) { return; }
1092
1093             this.$('img.image-preview')
1094                 .attr('src', image)
1095                 .removeClass(this.image_styles.join(' '))
1096                 .addClass(this.$('select.image-style').val());
1097         },
1098         browse_existing: function (e) {
1099             e.preventDefault();
1100             new website.editor.ExistingImageDialog(this).appendTo(document.body);
1101         },
1102     });
1103     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1104         init: function () {
1105             this._super.apply(this, arguments);
1106
1107             this.on('start', this, this.proxy('started'));
1108             this.on('save', this, this.proxy('saved'));
1109         },
1110         started: function (holder) {
1111             var selection = this.editor.getSelection();
1112             var el = selection && selection.getSelectedElement();
1113             this.element = null;
1114
1115             if (el && el.is('img')) {
1116                 this.element = el;
1117                 _(this.image_styles).each(function (style) {
1118                     if (el.hasClass(style)) {
1119                         holder.style = style;
1120                     }
1121                 });
1122                 holder.url = el.getAttribute('src');
1123             }
1124         },
1125         saved: function (data) {
1126             var element, editor = this.editor;
1127             if (!(element = this.element)) {
1128                 element = editor.document.createElement('img');
1129                 element.addClass('img');
1130                 // focus event handler interactions between bootstrap (modal)
1131                 // and ckeditor (RTE) lead to blowing the stack in Safari and
1132                 // Chrome (but not FF) when this is done synchronously =>
1133                 // defer insertion so modal has been hidden & destroyed before
1134                 // it happens
1135                 setTimeout(function () {
1136                     editor.insertElement(element);
1137                 }, 0);
1138             }
1139
1140             var style = data.style;
1141             element.setAttribute('src', data.url);
1142             element.removeAttribute('data-cke-saved-src');
1143             $(element.$).removeClass(this.image_styles.join(' '));
1144             if (style) { element.addClass(style); }
1145         },
1146     });
1147
1148     var IMAGES_PER_ROW = 6;
1149     var IMAGES_ROWS = 4;
1150     website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1151         template: 'website.editor.dialog.image.existing',
1152         events: _.extend({}, website.editor.Dialog.prototype.events, {
1153             'click .existing-attachments img': 'select_existing',
1154             'click .pager > li': function (e) {
1155                 e.preventDefault();
1156                 var $target = $(e.currentTarget);
1157                 if ($target.hasClass('disabled')) {
1158                     return;
1159                 }
1160                 this.page += $target.hasClass('previous') ? -1 : 1;
1161                 this.display_attachments();
1162             },
1163         }),
1164         init: function (parent) {
1165             this.image = null;
1166             this.page = 0;
1167             this.parent = parent;
1168             this._super(parent.editor);
1169         },
1170
1171         start: function () {
1172             return $.when(
1173                 this._super(),
1174                 this.fetch_existing().then(this.proxy('fetched_existing')));
1175         },
1176
1177         fetch_existing: function () {
1178             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1179                 model: 'ir.attachment',
1180                 method: 'search_read',
1181                 args: [],
1182                 kwargs: {
1183                     fields: ['name', 'website_url'],
1184                     domain: [['res_model', '=', 'ir.ui.view']],
1185                     order: 'name',
1186                     context: website.get_context(),
1187                 }
1188             });
1189         },
1190         fetched_existing: function (records) {
1191             this.records = records;
1192             this.display_attachments();
1193         },
1194         display_attachments: function () {
1195             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1196
1197             var from = this.page * per_screen;
1198             var records = this.records;
1199
1200             // Create rows of 3 records
1201             var rows = _(records).chain()
1202                 .slice(from, from + per_screen)
1203                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1204                 .values()
1205                 .value();
1206
1207             this.$('.existing-attachments').replaceWith(
1208                 openerp.qweb.render(
1209                     'website.editor.dialog.image.existing.content', {rows: rows}));
1210             this.$('.pager')
1211                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1212                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1213
1214         },
1215         select_existing: function (e) {
1216             var link = $(e.currentTarget).attr('src');
1217             if (link) {
1218                 this.parent.set_image(link);
1219             }
1220             this.close()
1221         },
1222     });
1223
1224     function get_selected_link(editor) {
1225         var sel = editor.getSelection(),
1226             el = sel.getSelectedElement();
1227         if (el && el.is('a')) { return el; }
1228
1229         var range = sel.getRanges(true)[0];
1230         if (!range) { return null; }
1231
1232         range.shrink(CKEDITOR.SHRINK_TEXT);
1233         var commonAncestor = range.getCommonAncestor();
1234         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1235             return element.data('oe-model') === 'ir.ui.view'
1236         });
1237         if (!viewRoot) { return null; }
1238         // if viewRoot is the first link, don't edit it.
1239         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1240                 .contains('a', true);
1241     }
1242
1243
1244     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1245     var OBSERVER_CONFIG = {
1246         childList: true,
1247         attributes: true,
1248         characterData: true,
1249         subtree: true,
1250         attributeOldValue: true,
1251     };
1252     var observer = new website.Observer(function (mutations) {
1253         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1254         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1255         //       will not mark dirty on attribute changes (@class, img/@src,
1256         //       a/@href, ...)
1257         _(mutations).chain()
1258             .filter(function (m) {
1259                 switch(m.type) {
1260                 case 'attributes': // ignore .cke_focus being added or removed
1261                     // if attribute is not a class, can't be .cke_focus change
1262                     if (m.attributeName !== 'class') { return true; }
1263
1264                     // find out what classes were added or removed
1265                     var oldClasses = (m.oldValue || '').split(/\s+/);
1266                     var newClasses = m.target.className.split(/\s+/);
1267                     var change = _.union(_.difference(oldClasses, newClasses),
1268                                          _.difference(newClasses, oldClasses));
1269                     // ignore mutation if the *only* change is .cke_focus
1270                     return change.length !== 1 || change[0] === 'cke_focus';
1271                 case 'childList':
1272                     // <br type="_moz"> appears when focusing RTE in FF, ignore
1273                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1274                 default:
1275                     return true;
1276                 }
1277             })
1278             .map(function (m) {
1279                 var node = m.target;
1280                 while (node && !$(node).hasClass('oe_editable')) {
1281                     node = node.parentNode;
1282                 }
1283                 $(m.target).trigger('node_changed');
1284                 return node;
1285             })
1286             .compact()
1287             .uniq()
1288             .each(function (node) { $(node).trigger('content_changed'); })
1289     });
1290 })();