[MERGE] from trunk
[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             var form = document.createElement('form');
18             form.action = this.pathname; // restrict to same origin
19             form.method = 'POST';
20             // TODO: support this.search as form input fields
21             $(this).append(form);
22             form.submit();
23         });
24
25         $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
26             // Prevent dropdown closing when a contenteditable children is focused
27             if (ev.originalEvent
28                     && $(ev.target).has(ev.originalEvent.target).length
29                     && $(ev.originalEvent.target).is('[contenteditable]')) {
30                 ev.preventDefault();
31             }
32         });
33     });
34
35     function link_dialog(editor) {
36         return new website.editor.RTELinkDialog(editor).appendTo(document.body);
37     }
38     function image_dialog(editor) {
39         return new website.editor.RTEImageDialog(editor).appendTo(document.body);
40     }
41
42     // only enable editors manually
43     CKEDITOR.disableAutoInline = true;
44     // EDIT ALL THE THINGS
45     CKEDITOR.dtd.$editable = $.extend(
46         {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
47     // Disable removal of empty elements on CKEDITOR activation. Empty
48     // elements are used for e.g. support of FontAwesome icons
49     CKEDITOR.dtd.$removeEmpty = {};
50
51     website.init_editor = function () {
52         CKEDITOR.plugins.add('customdialogs', {
53 //            requires: 'link,image',
54             init: function (editor) {
55                 editor.on('doubleclick', function (evt) {
56                     var element = evt.data.element;
57                     if (element.is('img')
58                             && !element.data('cke-realelement')
59                             && !element.isReadOnly()
60                             && (element.data('oe-model') !== 'ir.ui.view')) {
61                         image_dialog(editor);
62                         return;
63                     }
64
65                     element = get_selected_link(editor) || evt.data.element;
66                     if (element.isReadOnly()
67                         || !element.is('a')
68                         || element.data('oe-model')) {
69                         return;
70                     }
71
72                     editor.getSelection().selectElement(element);
73                     link_dialog(editor);
74                 }, null, null, 500);
75
76                 var previousSelection;
77                 editor.on('selectionChange', function (evt) {
78                     var selected = evt.data.path.lastElement;
79                     if (previousSelection) {
80                         // cleanup previous selection
81                         $(previousSelection).next().remove();
82                         previousSelection = null;
83                     }
84                     if (!selected.is('img')
85                             || selected.data('cke-realelement')
86                             || selected.isReadOnly()
87                             || selected.data('oe-model') === 'ir.ui.view') {
88                         return;
89                     }
90
91                     // display button
92                     var $el = $(previousSelection = selected.$);
93                     var $btn = $('<button type="button" class="btn btn-primary" contenteditable="false">Edit</button>')
94                         .insertAfter($el)
95                         .click(function (e) {
96                             e.preventDefault();
97                             e.stopPropagation();
98                             image_dialog(editor);
99                         });
100
101                     var position = $el.position();
102                     $btn.css({
103                         position: 'absolute',
104                         top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
105                         left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
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#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                         $(root).find('.' + failure.id)
517                             .removeClass(failure.id)
518                             .popover({
519                                 trigger: 'hover',
520                                 content: failure.error.message,
521                                 placement: 'auto top',
522                             })
523                             // Force-show popovers so users will notice them.
524                             .popover('show');
525                     })
526                 });
527             });
528         },
529         /**
530          * Saves an RTE content, which always corresponds to a view section (?).
531          */
532         saveElement: function ($el) {
533             var markup = $el.prop('outerHTML');
534             return openerp.jsonRpc('/web/dataset/call', 'call', {
535                 model: 'ir.ui.view',
536                 method: 'save',
537                 args: [$el.data('oe-id'), markup,
538                        $el.data('oe-xpath') || null,
539                        website.get_context()],
540             });
541         },
542         cancel: function () {
543             website.reload();
544         },
545         new_page: function (ev) {
546             ev.preventDefault();
547             website.prompt({
548                 window_title: "New Page",
549                 input: "Page Title",
550             }).then(function (val) {
551                 document.location = '/pagenew/' + encodeURI(val);
552             });
553         },
554     });
555
556     var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
557     /* ----- RICH TEXT EDITOR ---- */
558     website.RTE = openerp.Widget.extend({
559         tagName: 'li',
560         id: 'oe_rte_toolbar',
561         className: 'oe_right oe_rte_toolbar',
562         // editor.ui.items -> possible commands &al
563         // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
564
565         init: function (EditorBar) {
566             this.EditorBar = EditorBar;
567             this._super.apply(this, arguments);
568         },
569
570         /**
571          * In Webkit-based browsers, triple-click will select a paragraph up to
572          * the start of the next "paragraph" including any empty space
573          * inbetween. When said paragraph is removed or altered, it nukes
574          * the empty space and brings part of the content of the next
575          * "paragraph" (which may well be e.g. an image) into the current one,
576          * completely fucking up layouts and breaking snippets.
577          *
578          * Try to fuck around with selections on triple-click to attempt to
579          * fix this garbage behavior.
580          *
581          * Note: for consistent behavior we may actually want to take over
582          * triple-clicks, in all browsers in order to ensure consistent cross-
583          * platform behavior instead of being at the mercy of rendering engines
584          * & platform selection quirks?
585          */
586         webkitSelectionFixer: function (root) {
587             root.addEventListener('click', function (e) {
588                 // only webkit seems to have a fucked up behavior, ignore others
589                 // FIXME: $.browser goes away in jquery 1.9...
590                 if (!$.browser.webkit) { return; }
591                 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
592                 // The detail attribute indicates the number of times a mouse button has been pressed
593                 // we just want the triple click
594                 if (e.detail !== 3) { return; }
595                 e.preventDefault();
596
597                 // Get closest block-level element to the triple-clicked
598                 // element (using ckeditor's block list because why not)
599                 var $closest_block = $(e.target).closest(blocks_selector);
600
601                 // manually set selection range to the content of the
602                 // triple-clicked block-level element, to avoid crossing over
603                 // between block-level elements
604                 document.getSelection().selectAllChildren($closest_block[0]);
605             });
606         },
607         tableNavigation: function (root) {
608             var self = this;
609             $(root).on('keydown', function (e) {
610                 // ignore non-TAB
611                 if (e.which !== 9) { return; }
612
613                 if (self.handleTab(e)) {
614                     e.preventDefault();
615                 }
616             });
617         },
618         /**
619          * Performs whatever operation is necessary on a [TAB] hit, returns
620          * ``true`` if the event's default should be cancelled (if the TAB was
621          * handled by the function)
622          */
623         handleTab: function (event) {
624             var forward = !event.shiftKey;
625
626             var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
627             var $cell = $(root).closest('td,th');
628
629             if (!$cell.length) { return false; }
630
631             var cell = $cell[0];
632
633             // find cell in same row
634             var row = cell.parentNode;
635             var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
636             if (sibling) {
637                 document.getSelection().selectAllChildren(sibling);
638                 return true;
639             }
640
641             // find cell in previous/next row
642             var table = row.parentNode;
643             var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
644             if (sibling_row) {
645                 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
646                 document.getSelection().selectAllChildren(new_cell);
647                 return true;
648             }
649
650             // at edge cells, copy word/openoffice behavior: if going backwards
651             // from first cell do nothing, if going forwards from last cell add
652             // a row
653             if (forward) {
654                 var row_size = row.cells.length;
655                 var new_row = document.createElement('tr');
656                 while(row_size--) {
657                     var newcell = document.createElement('td');
658                     // zero-width space
659                     newcell.textContent = '\u200B';
660                     new_row.appendChild(newcell);
661                 }
662                 table.appendChild(new_row);
663                 document.getSelection().selectAllChildren(new_row.cells[0]);
664             }
665
666             return true;
667         },
668         /**
669          * Makes the page editable
670          *
671          * @param {Boolean} [restart=false] in case the edition was already set
672          *                                  up once and is being re-enabled.
673          * @returns {$.Deferred} deferred indicating when the RTE is ready
674          */
675         start_edition: function (restart) {
676             var self = this;
677             // create a single editor for the whole page
678             var root = document.getElementById('wrapwrap');
679             if (!restart) {
680                 $(root).on('dragstart', 'img', function (e) {
681                     e.preventDefault();
682                 });
683                 this.webkitSelectionFixer(root);
684                 this.tableNavigation(root);
685             }
686             var def = $.Deferred();
687             var editor = this.editor = CKEDITOR.inline(root, self._config());
688             editor.on('instanceReady', function () {
689                 editor.setReadOnly(false);
690                 // ckeditor set root to editable, disable it (only inner
691                 // sections are editable)
692                 // FIXME: are there cases where the whole editor is editable?
693                 editor.editable().setReadOnly(true);
694
695                 self.setup_editables(root);
696
697                 // disable firefox's broken table resizing thing
698                 document.execCommand("enableObjectResizing", false, "false");
699                 document.execCommand("enableInlineTableEditing", false, "false");
700
701                 self.trigger('rte:ready');
702                 def.resolve();
703             });
704             return def;
705         },
706
707         setup_editables: function (root) {
708             // selection of editable sub-items was previously in
709             // EditorBar#edit, but for some unknown reason the elements were
710             // apparently removed and recreated (?) at editor initalization,
711             // and observer setup was lost.
712             var self = this;
713             // setup dirty-marking for each editable element
714             this.fetch_editables(root)
715                 .addClass('oe_editable')
716                 .each(function () {
717                     var node = this;
718                     var $node = $(node);
719                     // only explicitly set contenteditable on view sections,
720                     // cke widgets system will do the widgets themselves
721                     if ($node.data('oe-model') === 'ir.ui.view') {
722                         node.contentEditable = true;
723                     }
724
725                     observer.observe(node, OBSERVER_CONFIG);
726                     $node.one('content_changed', function () {
727                         $node.addClass('oe_dirty');
728                         self.trigger('change');
729                     });
730                 });
731         },
732
733         fetch_editables: function (root) {
734             return $(root).find('[data-oe-model]')
735                 .not('link, script')
736                 .not('.oe_snippet_editor')
737                 .filter(function () {
738                     var $this = $(this);
739                     // keep view sections and fields which are *not* in
740                     // view sections for top-level editables
741                     return $this.data('oe-model') === 'ir.ui.view'
742                        || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
743                 });
744         },
745
746         _current_editor: function () {
747             return CKEDITOR.currentInstance;
748         },
749         _config: function () {
750             // base plugins minus
751             // - magicline (captures mousein/mouseout -> breaks draggable)
752             // - contextmenu & tabletools (disable contextual menu)
753             // - bunch of unused plugins
754             var plugins = [
755                 'a11yhelp', 'basicstyles', 'blockquote',
756                 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
757                 'elementspath', 'enterkey', 'entities', 'filebrowser',
758                 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
759                 'indentblock', 'indentlist', 'justify',
760                 'list', 'pastefromword', 'pastetext', 'preview',
761                 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
762                 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
763             ];
764             return {
765                 // FIXME
766                 language: 'en',
767                 // Disable auto-generated titles
768                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
769                 title: false,
770                 plugins: plugins.join(','),
771                 uiColor: '',
772                 // FIXME: currently breaks RTE?
773                 // Ensure no config file is loaded
774                 customConfig: '',
775                 // Disable ACF
776                 allowedContent: true,
777                 // Don't insert paragraphs around content in e.g. <li>
778                 autoParagraph: false,
779                 // Don't automatically add &nbsp; or <br> in empty block-level
780                 // elements when edition starts
781                 fillEmptyBlocks: false,
782                 filebrowserImageUploadUrl: "/website/attach",
783                 // Support for sharedSpaces in 4.x
784                 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
785                 // Place toolbar in controlled location
786                 sharedSpaces: { top: 'oe_rte_toolbar' },
787                 toolbar: [{
788                     name: 'clipboard', items: [
789                         "Undo"
790                     ]},{
791                         name: 'basicstyles', items: [
792                         "Bold", "Italic", "Underline", "Strike", "Subscript",
793                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
794                     ]},{
795                     name: 'span', items: [
796                         "Link", "Blockquote", "BulletedList",
797                         "NumberedList", "Indent", "Outdent"
798                     ]},{
799                     name: 'justify', items: [
800                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
801                     ]},{
802                     name: 'special', items: [
803                         "Image", "TableButton"
804                     ]},{
805                     name: 'styles', items: [
806                         "Styles", "BootstrapLinkCombo"
807                     ]}
808                 ],
809                 // styles dropdown in toolbar
810                 stylesSet: [
811                     {name: "Normal", element: 'p'},
812                     {name: "Heading 1", element: 'h1'},
813                     {name: "Heading 2", element: 'h2'},
814                     {name: "Heading 3", element: 'h3'},
815                     {name: "Heading 4", element: 'h4'},
816                     {name: "Heading 5", element: 'h5'},
817                     {name: "Heading 6", element: 'h6'},
818                     {name: "Formatted", element: 'pre'},
819                     {name: "Address", element: 'address'}
820                 ],
821             };
822         },
823     });
824
825     website.editor = { };
826     website.editor.Dialog = openerp.Widget.extend({
827         events: {
828             'hidden.bs.modal': 'destroy',
829             'click button.save': 'save',
830         },
831         init: function (editor) {
832             this._super();
833             this.editor = editor;
834         },
835         start: function () {
836             var sup = this._super();
837             this.$el.modal({backdrop: 'static'});
838             return sup;
839         },
840         save: function () {
841             this.close();
842         },
843         close: function () {
844             this.$el.modal('hide');
845         },
846     });
847
848     website.editor.LinkDialog = website.editor.Dialog.extend({
849         template: 'website.editor.dialog.link',
850         events: _.extend({}, website.editor.Dialog.prototype.events, {
851             'change :input.url-source': function (e) { this.changed($(e.target)); },
852             'mousedown': function (e) {
853                 var $target = $(e.target).closest('.list-group-item');
854                 if (!$target.length || $target.hasClass('active')) {
855                     // clicked outside groups, or clicked in active groups
856                     return;
857                 }
858
859                 this.changed($target.find('.url-source').filter(':input'));
860             },
861             'click button.remove': 'remove_link',
862             'change input#link-text': function (e) {
863                 this.text = $(e.target).val()
864             },
865         }),
866         init: function (editor) {
867             this._super(editor);
868             this.text = null;
869             // Store last-performed request to be able to cancel/abort it.
870             this.req = null;
871         },
872         start: function () {
873             var self = this;
874             this.$('#link-page').select2({
875                 minimumInputLength: 3,
876                 placeholder: _t("New or existing page"),
877                 query: function (q) {
878                     // FIXME: out-of-order, abort
879                     self.fetch_pages(q.term).then(function (results) {
880                         var rs = _.map(results, function (r) {
881                             return { id: r.url, text: r.name, };
882                         });
883                         rs.push({
884                             create: true,
885                             id: q.term,
886                             text: _.str.sprintf(_t("Create page '%s'"), q.term),
887                         });
888                         q.callback({
889                             more: false,
890                             results: rs
891                         });
892                     });
893                 },
894             });
895             return this._super().then(this.proxy('bind_data'));
896         },
897         save: function () {
898             var self = this, _super = this._super.bind(this);
899             var $e = this.$('.list-group-item.active .url-source').filter(':input');
900             var val = $e.val();
901             if (!val || !$e[0].checkValidity()) {
902                 // FIXME: error message
903                 $e.closest('.form-group').addClass('has-error');
904                 $e.focus();
905                 return;
906             }
907
908             var done = $.when();
909             if ($e.hasClass('email-address')) {
910                 this.make_link('mailto:' + val, false, val);
911             } else if ($e.hasClass('page')) {
912                 var data = $e.select2('data');
913                 if (!data.create) {
914                     self.make_link(data.id, false, data.text);
915                 } else {
916                     // Create the page, get the URL back
917                     done = $.get(_.str.sprintf(
918                             '/pagenew/%s?noredirect', encodeURI(data.id)))
919                         .then(function (response) {
920                             self.make_link(response, false, data.id);
921                         });
922                 }
923             } else {
924                 this.make_link(val, this.$('input.window-new').prop('checked'));
925             }
926             done.then(_super);
927         },
928         make_link: function (url, new_window, label) {
929         },
930         bind_data: function (text, href, new_window) {
931             href = href || this.element && (this.element.data( 'cke-saved-href')
932                                     ||  this.element.getAttribute('href'));
933             if (!href) { return; }
934
935             if (new_window === undefined) {
936                 new_window = this.element.getAttribute('target') === '_blank';
937             }
938             if (text === undefined) {
939                 text = this.element.getText();
940             }
941
942             var match, $control;
943             if ((match = /mailto:(.+)/.exec(href))) {
944                 $control = this.$('input.email-address').val(match[1]);
945             }
946             if (!$control) {
947                 $control = this.$('input.url').val(href);
948             }
949
950             this.changed($control);
951
952             this.$('input#link-text').val(text);
953             this.$('input.window-new').prop('checked', new_window);
954         },
955         changed: function ($e) {
956             this.$('.url-source').filter(':input').not($e).val('')
957                     .filter(function () { return !!$(this).data('select2'); })
958                     .select2('data', null);
959             $e.closest('.list-group-item')
960                 .addClass('active')
961                 .siblings().removeClass('active')
962                 .addBack().removeClass('has-error');
963         },
964         fetch_pages: function (term) {
965             var self = this;
966             if (this.req) { this.req.abort(); }
967             return this.req = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
968                 model: 'website',
969                 method: 'search_pages',
970                 args: [null, term],
971                 kwargs: {
972                     limit: 9,
973                     context: website.get_context()
974                 },
975             }).done(function () {
976                 // request completed successfully -> unstore it
977                 self.req = null;
978             });
979         },
980     });
981     website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
982         start: function () {
983             var element;
984             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
985                 this.editor.getSelection().selectElement(element);
986             }
987             this.element = element;
988             if (element) {
989                 this.add_removal_button();
990             }
991
992             return this._super();
993         },
994         add_removal_button: function () {
995             this.$('.modal-footer').prepend(
996                 openerp.qweb.render(
997                     'website.editor.dialog.link.footer-button'));
998         },
999         remove_link: function () {
1000             var editor = this.editor;
1001             // same issue as in make_link
1002             setTimeout(function () {
1003                 editor.removeStyle(new CKEDITOR.style({
1004                     element: 'a',
1005                     type: CKEDITOR.STYLE_INLINE,
1006                     alwaysRemoveElement: true,
1007                 }));
1008             }, 0);
1009             this.close();
1010         },
1011         /**
1012          * Greatly simplified version of CKEDITOR's
1013          * plugins.link.dialogs.link.onOk.
1014          *
1015          * @param {String} url
1016          * @param {Boolean} [new_window=false]
1017          * @param {String} [label=null]
1018          */
1019         make_link: function (url, new_window, label) {
1020             var attributes = {href: url, 'data-cke-saved-href': url};
1021             var to_remove = [];
1022             if (new_window) {
1023                 attributes['target'] = '_blank';
1024             } else {
1025                 to_remove.push('target');
1026             }
1027
1028             if (this.element) {
1029                 this.element.setAttributes(attributes);
1030                 this.element.removeAttributes(to_remove);
1031                 if (this.text) { this.element.setText(this.text); }
1032             } else {
1033                 var selection = this.editor.getSelection();
1034                 var range = selection.getRanges(true)[0];
1035
1036                 if (range.collapsed) {
1037                     //noinspection JSPotentiallyInvalidConstructorUsage
1038                     var text = new CKEDITOR.dom.text(
1039                         this.text || label || url);
1040                     range.insertNode(text);
1041                     range.selectNodeContents(text);
1042                 }
1043
1044                 //noinspection JSPotentiallyInvalidConstructorUsage
1045                 new CKEDITOR.style({
1046                     type: CKEDITOR.STYLE_INLINE,
1047                     element: 'a',
1048                     attributes: attributes,
1049                 }).applyToRange(range);
1050
1051                 // focus dance between RTE & dialog blow up the stack in Safari
1052                 // and Chrome, so defer select() until dialog has been closed
1053                 setTimeout(function () {
1054                     range.select();
1055                 }, 0);
1056             }
1057         },
1058         /**
1059          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1060          * if the editor is set directly on a link it will thus not work.
1061          */
1062         get_selected_link: function () {
1063             return get_selected_link(this.editor);
1064         },
1065     });
1066
1067     /**
1068      * ImageDialog widget. Lets users change an image, including uploading a
1069      * new image in OpenERP or selecting the image style (if supported by
1070      * the caller).
1071      *
1072      * Initialized as usual, but the caller can hook into two events:
1073      *
1074      * @event start({url, style}) called during dialog initialization and
1075      *                            opening, the handler can *set* the ``url``
1076      *                            and ``style`` properties on its parameter
1077      *                            to provide these as default values to the
1078      *                            dialog
1079      * @event save({url, style}) called during dialog finalization, the handler
1080      *                           is provided with the image url and style
1081      *                           selected by the users (or possibly the ones
1082      *                           originally passed in)
1083      */
1084     website.editor.ImageDialog = website.editor.Dialog.extend({
1085         template: 'website.editor.dialog.image',
1086         events: _.extend({}, website.editor.Dialog.prototype.events, {
1087             'change .url-source': function (e) { this.changed($(e.target)); },
1088             'click button.filepicker': function () {
1089                 this.$('input[type=file]').click();
1090             },
1091             'change input[type=file]': 'file_selection',
1092             'change input.url': 'preview_image',
1093             'click a[href=#existing]': 'browse_existing',
1094             'change select.image-style': 'preview_image',
1095         }),
1096
1097         start: function () {
1098             this.$('.modal-footer [disabled]').text("Uploading…");
1099             var $options = this.$('.image-style').children();
1100             this.image_styles = $options.map(function () { return this.value; }).get();
1101
1102             var o = { url: null, style: null, };
1103             // avoid typos, prevent addition of new properties to the object
1104             Object.preventExtensions(o);
1105             this.trigger('start', o);
1106
1107             if (o.url) {
1108                 if (o.style) {
1109                     this.$('.image-style').val(o.style);
1110                 }
1111                 this.set_image(o.url);
1112             }
1113
1114             return this._super();
1115         },
1116         save: function () {
1117             this.trigger('save', {
1118                 url: this.$('input.url').val(),
1119                 style: this.$('.image-style').val(),
1120             });
1121             return this._super();
1122         },
1123
1124         /**
1125          * Sets the provided image url as the dialog's value-to-save and
1126          * refreshes the preview element to use it.
1127          */
1128         set_image: function (url) {
1129             this.$('input.url').val(url);
1130             this.preview_image();
1131         },
1132
1133         file_selection: function () {
1134             this.$el.addClass('nosave');
1135             this.$('button.filepicker').removeClass('btn-danger btn-success');
1136
1137             var self = this;
1138             var callback = _.uniqueId('func_');
1139             this.$('input[name=func]').val(callback);
1140
1141             window[callback] = function (url, error) {
1142                 delete window[callback];
1143                 self.file_selected(url, error);
1144             };
1145             this.$('form').submit();
1146         },
1147         file_selected: function(url, error) {
1148             var $button = this.$('button.filepicker');
1149             if (error) {
1150                 $button.addClass('btn-danger');
1151                 return;
1152             }
1153             $button.addClass('btn-success');
1154             this.set_image(url);
1155         },
1156         preview_image: function () {
1157             this.$el.removeClass('nosave');
1158             var image = this.$('input.url').val();
1159             if (!image) { return; }
1160
1161             this.$('img.image-preview')
1162                 .attr('src', image)
1163                 .removeClass(this.image_styles.join(' '))
1164                 .addClass(this.$('select.image-style').val());
1165         },
1166         browse_existing: function (e) {
1167             e.preventDefault();
1168             new website.editor.ExistingImageDialog(this).appendTo(document.body);
1169         },
1170     });
1171     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1172         init: function () {
1173             this._super.apply(this, arguments);
1174
1175             this.on('start', this, this.proxy('started'));
1176             this.on('save', this, this.proxy('saved'));
1177         },
1178         started: function (holder) {
1179             var selection = this.editor.getSelection();
1180             var el = selection && selection.getSelectedElement();
1181             this.element = null;
1182
1183             if (el && el.is('img')) {
1184                 this.element = el;
1185                 _(this.image_styles).each(function (style) {
1186                     if (el.hasClass(style)) {
1187                         holder.style = style;
1188                     }
1189                 });
1190                 holder.url = el.getAttribute('src');
1191             }
1192         },
1193         saved: function (data) {
1194             var element, editor = this.editor;
1195             if (!(element = this.element)) {
1196                 element = editor.document.createElement('img');
1197                 element.addClass('img');
1198                 // focus event handler interactions between bootstrap (modal)
1199                 // and ckeditor (RTE) lead to blowing the stack in Safari and
1200                 // Chrome (but not FF) when this is done synchronously =>
1201                 // defer insertion so modal has been hidden & destroyed before
1202                 // it happens
1203                 setTimeout(function () {
1204                     editor.insertElement(element);
1205                 }, 0);
1206             }
1207
1208             var style = data.style;
1209             element.setAttribute('src', data.url);
1210             element.removeAttribute('data-cke-saved-src');
1211             $(element.$).removeClass(this.image_styles.join(' '));
1212             if (style) { element.addClass(style); }
1213         },
1214     });
1215
1216     var IMAGES_PER_ROW = 6;
1217     var IMAGES_ROWS = 4;
1218     website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1219         template: 'website.editor.dialog.image.existing',
1220         events: _.extend({}, website.editor.Dialog.prototype.events, {
1221             'click .existing-attachments img': 'select_existing',
1222             'click .pager > li': function (e) {
1223                 e.preventDefault();
1224                 var $target = $(e.currentTarget);
1225                 if ($target.hasClass('disabled')) {
1226                     return;
1227                 }
1228                 this.page += $target.hasClass('previous') ? -1 : 1;
1229                 this.display_attachments();
1230             },
1231         }),
1232         init: function (parent) {
1233             this.image = null;
1234             this.page = 0;
1235             this.parent = parent;
1236             this._super(parent.editor);
1237         },
1238
1239         start: function () {
1240             return $.when(
1241                 this._super(),
1242                 this.fetch_existing().then(this.proxy('fetched_existing')));
1243         },
1244
1245         fetch_existing: function () {
1246             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1247                 model: 'ir.attachment',
1248                 method: 'search_read',
1249                 args: [],
1250                 kwargs: {
1251                     fields: ['name', 'website_url'],
1252                     domain: [['res_model', '=', 'ir.ui.view']],
1253                     order: 'name',
1254                     context: website.get_context(),
1255                 }
1256             });
1257         },
1258         fetched_existing: function (records) {
1259             this.records = records;
1260             this.display_attachments();
1261         },
1262         display_attachments: function () {
1263             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1264
1265             var from = this.page * per_screen;
1266             var records = this.records;
1267
1268             // Create rows of 3 records
1269             var rows = _(records).chain()
1270                 .slice(from, from + per_screen)
1271                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1272                 .values()
1273                 .value();
1274
1275             this.$('.existing-attachments').replaceWith(
1276                 openerp.qweb.render(
1277                     'website.editor.dialog.image.existing.content', {rows: rows}));
1278             this.$('.pager')
1279                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1280                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1281
1282         },
1283         select_existing: function (e) {
1284             var link = $(e.currentTarget).attr('src');
1285             if (link) {
1286                 this.parent.set_image(link);
1287             }
1288             this.close()
1289         },
1290     });
1291
1292     function get_selected_link(editor) {
1293         var sel = editor.getSelection(),
1294             el = sel.getSelectedElement();
1295         if (el && el.is('a')) { return el; }
1296
1297         var range = sel.getRanges(true)[0];
1298         if (!range) { return null; }
1299
1300         range.shrink(CKEDITOR.SHRINK_TEXT);
1301         var commonAncestor = range.getCommonAncestor();
1302         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1303             return element.data('oe-model') === 'ir.ui.view'
1304         });
1305         if (!viewRoot) { return null; }
1306         // if viewRoot is the first link, don't edit it.
1307         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1308                 .contains('a', true);
1309     }
1310
1311
1312     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1313     var OBSERVER_CONFIG = {
1314         childList: true,
1315         attributes: true,
1316         characterData: true,
1317         subtree: true,
1318         attributeOldValue: true,
1319     };
1320     var observer = new website.Observer(function (mutations) {
1321         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1322         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1323         //       will not mark dirty on attribute changes (@class, img/@src,
1324         //       a/@href, ...)
1325         _(mutations).chain()
1326             .filter(function (m) {
1327                 switch(m.type) {
1328                 case 'attributes': // ignore .cke_focus being added or removed
1329                     // if attribute is not a class, can't be .cke_focus change
1330                     if (m.attributeName !== 'class') { return true; }
1331
1332                     // find out what classes were added or removed
1333                     var oldClasses = (m.oldValue || '').split(/\s+/);
1334                     var newClasses = m.target.className.split(/\s+/);
1335                     var change = _.union(_.difference(oldClasses, newClasses),
1336                                          _.difference(newClasses, oldClasses));
1337                     // ignore mutation if the *only* change is .cke_focus
1338                     return change.length !== 1 || change[0] === 'cke_focus';
1339                 case 'childList':
1340                     // <br type="_moz"> appears when focusing RTE in FF, ignore
1341                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1342                 default:
1343                     return true;
1344                 }
1345             })
1346             .map(function (m) {
1347                 var node = m.target;
1348                 while (node && !$(node).hasClass('oe_editable')) {
1349                     node = node.parentNode;
1350                 }
1351                 $(m.target).trigger('node_changed');
1352                 return node;
1353             })
1354             .compact()
1355             .uniq()
1356             .each(function (node) { $(node).trigger('content_changed'); })
1357     });
1358 })();