[IMP] don't completely wedge the RTE in case save fails. This may well break snippets...
[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._super()
678             ).done(this.proxy('bind_data'));
679         },
680         add_removal_button: function () {
681             this.$('.modal-footer').prepend(
682                 openerp.qweb.render(
683                     'website.editor.dialog.link.footer-button'));
684         },
685         remove_link: function () {
686             var editor = this.editor;
687             // same issue as in make_link
688             setTimeout(function () {
689                 editor.removeStyle(new CKEDITOR.style({
690                     element: 'a',
691                     type: CKEDITOR.STYLE_INLINE,
692                     alwaysRemoveElement: true,
693                 }));
694             }, 0);
695             this.close();
696         },
697         /**
698          * Greatly simplified version of CKEDITOR's
699          * plugins.link.dialogs.link.onOk.
700          *
701          * @param {String} url
702          * @param {Boolean} [new_window=false]
703          * @param {String} [label=null]
704          */
705         make_link: function (url, new_window, label) {
706             var attributes = {href: url, 'data-cke-saved-href': url};
707             var to_remove = [];
708             if (new_window) {
709                 attributes['target'] = '_blank';
710             } else {
711                 to_remove.push('target');
712             }
713
714             if (this.element) {
715                 this.element.setAttributes(attributes);
716                 this.element.removeAttributes(to_remove);
717                 if (this.text) { this.element.setText(this.text); }
718             } else {
719                 var selection = this.editor.getSelection();
720                 var range = selection.getRanges(true)[0];
721
722                 if (range.collapsed) {
723                     //noinspection JSPotentiallyInvalidConstructorUsage
724                     var text = new CKEDITOR.dom.text(
725                         this.text || label || url);
726                     range.insertNode(text);
727                     range.selectNodeContents(text);
728                 }
729
730                 //noinspection JSPotentiallyInvalidConstructorUsage
731                 new CKEDITOR.style({
732                     type: CKEDITOR.STYLE_INLINE,
733                     element: 'a',
734                     attributes: attributes,
735                 }).applyToRange(range);
736
737                 // focus dance between RTE & dialog blow up the stack in Safari
738                 // and Chrome, so defer select() until dialog has been closed
739                 setTimeout(function () {
740                     range.select();
741                 }, 0);
742             }
743         },
744         save: function () {
745             var self = this, _super = this._super.bind(this);
746             var $e = this.$('.list-group-item.active .url-source');
747             var val = $e.val();
748             if (!val || !$e[0].checkValidity()) {
749                 // FIXME: error message
750                 $e.closest('.form-group').addClass('has-error');
751                 return;
752             }
753
754             var done = $.when();
755             if ($e.hasClass('email-address')) {
756                 this.make_link('mailto:' + val, false, val);
757             } else if ($e.hasClass('existing')) {
758                 self.make_link(val, false, this.pages[val]);
759             } else if ($e.hasClass('pages')) {
760                 // Create the page, get the URL back
761                 done = $.get(_.str.sprintf(
762                         '/pagenew/%s?noredirect', encodeURI(val)))
763                     .then(function (response) {
764                         self.make_link(response, false, val);
765                     });
766             } else {
767                 this.make_link(val, this.$('input.window-new').prop('checked'));
768             }
769             done.then(_super);
770         },
771         bind_data: function () {
772             var href = this.element && (this.element.data( 'cke-saved-href')
773                                     ||  this.element.getAttribute('href'));
774             if (!href) { return; }
775
776             var match, $control;
777             if (match = /mailto:(.+)/.exec(href)) {
778                 $control = this.$('input.email-address').val(match[1]);
779             } else if (href in this.pages) {
780                 $control = this.$('select.existing').val(href);
781             } else if (match = /\/page\/(.+)/.exec(href)) {
782                 var actual_href = '/page/website.' + match[1];
783                 if (actual_href in this.pages) {
784                     $control = this.$('select.existing').val(actual_href);
785                 }
786             }
787             if (!$control) {
788                 $control = this.$('input.url').val(href);
789             }
790
791             this.changed($control);
792
793             this.$('input#link-text').val(this.element.getText());
794             this.$('input.window-new').prop(
795                 'checked', this.element.getAttribute('target') === '_blank');
796         },
797         changed: function ($e) {
798             this.$('.url-source').not($e).val('');
799             $e.closest('.list-group-item')
800                 .addClass('active')
801                 .siblings().removeClass('active')
802                 .addBack().removeClass('has-error');
803         },
804         /**
805          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
806          * if the editor is set directly on a link it will thus not work.
807          */
808         get_selected_link: function () {
809             return get_selected_link(this.editor);
810         },
811         fetch_pages: function () {
812             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
813                 model: 'website',
814                 method: 'list_pages',
815                 args: [null],
816                 kwargs: {
817                     context: website.get_context()
818                 },
819             });
820         },
821         fill_pages: function (results) {
822             var self = this;
823             var pages = this.$('select.existing')[0];
824             _(results).each(function (result) {
825                 self.pages[result.url] = result.name;
826
827                 pages.options[pages.options.length] =
828                         new Option(result.name, result.url);
829             });
830         },
831     });
832     /**
833      * ImageDialog widget. Lets users change an image, including uploading a
834      * new image in OpenERP or selecting the image style (if supported by
835      * the caller).
836      *
837      * Initialized as usual, but the caller can hook into two events:
838      *
839      * @event start({url, style}) called during dialog initialization and
840      *                            opening, the handler can *set* the ``url``
841      *                            and ``style`` properties on its parameter
842      *                            to provide these as default values to the
843      *                            dialog
844      * @event save({url, style}) called during dialog finalization, the handler
845      *                           is provided with the image url and style
846      *                           selected by the users (or possibly the ones
847      *                           originally passed in)
848      */
849     website.editor.ImageDialog = website.editor.Dialog.extend({
850         template: 'website.editor.dialog.image',
851         events: _.extend({}, website.editor.Dialog.prototype.events, {
852             'change .url-source': function (e) { this.changed($(e.target)); },
853             'click button.filepicker': function () {
854                 this.$('input[type=file]').click();
855             },
856             'change input[type=file]': 'file_selection',
857             'change input.url': 'preview_image',
858             'click a[href=#existing]': 'browse_existing',
859             'change select.image-style': 'preview_image',
860         }),
861
862         start: function () {
863             var $options = this.$('.image-style').children();
864             this.image_styles = $options.map(function () { return this.value; }).get();
865
866             var o = { url: null, style: null, };
867             // avoid typos, prevent addition of new properties to the object
868             Object.preventExtensions(o);
869             this.trigger('start', o);
870
871             if (o.url) {
872                 if (o.style) {
873                     this.$('.image-style').val(o.style);
874                 }
875                 this.set_image(o.url);
876             }
877
878             return this._super();
879         },
880         save: function () {
881             this.trigger('save', {
882                 url: this.$('input.url').val(),
883                 style: this.$('.image-style').val(),
884             });
885             return this._super();
886         },
887
888         /**
889          * Sets the provided image url as the dialog's value-to-save and
890          * refreshes the preview element to use it.
891          */
892         set_image: function (url) {
893             this.$('input.url').val(url);
894             this.preview_image();
895         },
896
897         file_selection: function () {
898             this.$('button.filepicker').removeClass('btn-danger btn-success');
899
900             var self = this;
901             var callback = _.uniqueId('func_');
902             this.$('input[name=func]').val(callback);
903
904             window[callback] = function (url, error) {
905                 delete window[callback];
906                 self.file_selected(url, error);
907             };
908             this.$('form').submit();
909         },
910         file_selected: function(url, error) {
911             var $button = this.$('button.filepicker');
912             if (error) {
913                 $button.addClass('btn-danger');
914                 return;
915             }
916             $button.addClass('btn-success');
917             this.set_image(url);
918         },
919         preview_image: function () {
920             var image = this.$('input.url').val();
921             if (!image) { return; }
922
923             this.$('img.image-preview')
924                 .attr('src', image)
925                 .removeClass(this.image_styles.join(' '))
926                 .addClass(this.$('select.image-style').val());
927         },
928         browse_existing: function (e) {
929             e.preventDefault();
930             new website.editor.ExistingImageDialog(this).appendTo(document.body);
931         },
932     });
933     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
934         init: function () {
935             this._super.apply(this, arguments);
936
937             this.on('start', this, this.proxy('started'));
938             this.on('save', this, this.proxy('saved'));
939         },
940         started: function (holder) {
941             var selection = this.editor.getSelection();
942             var el = selection && selection.getSelectedElement();
943             this.element = null;
944
945             if (el && el.is('img')) {
946                 this.element = el;
947                 _(this.image_styles).each(function (style) {
948                     if (el.hasClass(style)) {
949                         holder.style = style;
950                     }
951                 });
952                 holder.url = el.getAttribute('src');
953             }
954         },
955         saved: function (data) {
956             var element, editor = this.editor;
957             if (!(element = this.element)) {
958                 element = editor.document.createElement('img');
959                 element.addClass('img');
960                 // focus event handler interactions between bootstrap (modal)
961                 // and ckeditor (RTE) lead to blowing the stack in Safari and
962                 // Chrome (but not FF) when this is done synchronously =>
963                 // defer insertion so modal has been hidden & destroyed before
964                 // it happens
965                 setTimeout(function () {
966                     editor.insertElement(element);
967                 }, 0);
968             }
969
970             var style = data.style;
971             element.setAttribute('src', data.url);
972             element.removeAttribute('data-cke-saved-src');
973             $(element.$).removeClass(this.image_styles.join(' '));
974             if (style) { element.addClass(style); }
975         },
976     });
977
978     var IMAGES_PER_ROW = 6;
979     var IMAGES_ROWS = 4;
980     website.editor.ExistingImageDialog = website.editor.Dialog.extend({
981         template: 'website.editor.dialog.image.existing',
982         events: _.extend({}, website.editor.Dialog.prototype.events, {
983             'click .existing-attachments img': 'select_existing',
984             'click .pager > li': function (e) {
985                 e.preventDefault();
986                 var $target = $(e.currentTarget);
987                 if ($target.hasClass('disabled')) {
988                     return;
989                 }
990                 this.page += $target.hasClass('previous') ? -1 : 1;
991                 this.display_attachments();
992             },
993         }),
994         init: function (parent) {
995             this.image = null;
996             this.page = 0;
997             this.parent = parent;
998             this._super(parent.editor);
999         },
1000
1001         start: function () {
1002             return $.when(
1003                 this._super(),
1004                 this.fetch_existing().then(this.proxy('fetched_existing')));
1005         },
1006
1007         fetch_existing: function () {
1008             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1009                 model: 'ir.attachment',
1010                 method: 'search_read',
1011                 args: [],
1012                 kwargs: {
1013                     fields: ['name', 'website_url'],
1014                     domain: [['res_model', '=', 'ir.ui.view']],
1015                     order: 'name',
1016                     context: website.get_context(),
1017                 }
1018             });
1019         },
1020         fetched_existing: function (records) {
1021             this.records = records;
1022             this.display_attachments();
1023         },
1024         display_attachments: function () {
1025             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1026
1027             var from = this.page * per_screen;
1028             var records = this.records;
1029
1030             // Create rows of 3 records
1031             var rows = _(records).chain()
1032                 .slice(from, from + per_screen)
1033                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1034                 .values()
1035                 .value();
1036
1037             this.$('.existing-attachments').replaceWith(
1038                 openerp.qweb.render(
1039                     'website.editor.dialog.image.existing.content', {rows: rows}));
1040             this.$('.pager')
1041                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1042                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1043
1044         },
1045         select_existing: function (e) {
1046             var link = $(e.currentTarget).attr('src');
1047             if (link) {
1048                 this.parent.set_image(link);
1049             }
1050             this.close()
1051         },
1052     });
1053
1054     function get_selected_link(editor) {
1055         var sel = editor.getSelection(),
1056             el = sel.getSelectedElement();
1057         if (el && el.is('a')) { return el; }
1058
1059         var range = sel.getRanges(true)[0];
1060         if (!range) { return null; }
1061
1062         range.shrink(CKEDITOR.SHRINK_TEXT);
1063         var commonAncestor = range.getCommonAncestor();
1064         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1065             return element.data('oe-model') === 'ir.ui.view'
1066         });
1067         if (!viewRoot) { return null; }
1068         // if viewRoot is the first link, don't edit it.
1069         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1070                 .contains('a', true);
1071     }
1072
1073
1074     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1075     var OBSERVER_CONFIG = {
1076         childList: true,
1077         attributes: true,
1078         characterData: true,
1079         subtree: true,
1080         attributeOldValue: true,
1081     };
1082     var observer = new website.Observer(function (mutations) {
1083         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1084         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1085         //       will not mark dirty on attribute changes (@class, img/@src,
1086         //       a/@href, ...)
1087         _(mutations).chain()
1088             .filter(function (m) {
1089                 switch(m.type) {
1090                 case 'attributes': // ignore .cke_focus being added or removed
1091                     // if attribute is not a class, can't be .cke_focus change
1092                     if (m.attributeName !== 'class') { return true; }
1093
1094                     // find out what classes were added or removed
1095                     var oldClasses = (m.oldValue || '').split(/\s+/);
1096                     var newClasses = m.target.className.split(/\s+/);
1097                     var change = _.union(_.difference(oldClasses, newClasses),
1098                                          _.difference(newClasses, oldClasses));
1099                     // ignore mutation if the *only* change is .cke_focus
1100                     return change.length !== 1 || change[0] === 'cke_focus';
1101                 case 'childList':
1102                     // <br type="_moz"> appears when focusing RTE in FF, ignore
1103                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1104                 default:
1105                     return true;
1106                 }
1107             })
1108             .map(function (m) {
1109                 var node = m.target;
1110                 while (node && !$(node).hasClass('oe_editable')) {
1111                     node = node.parentNode;
1112                 }
1113                 $(m.target).trigger('node_changed');
1114                 return node;
1115             })
1116             .compact()
1117             .uniq()
1118             .each(function (node) { $(node).trigger('content_changed'); })
1119     });
1120 })();