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