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