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