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