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