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