[IMP] Refactor LinkDialog -> RTELinkDialog
[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.RTELinkDialog(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                 .not('link, script')
521                 .not('.oe_snippet_editor')
522                 .filter(function () {
523                     var $this = $(this);
524                     // keep view sections and fields which are *not* in
525                     // view sections for top-level editables
526                     return $this.data('oe-model') === 'ir.ui.view'
527                        || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
528                 });
529         },
530
531         _current_editor: function () {
532             return CKEDITOR.currentInstance;
533         },
534         _config: function () {
535             // base plugins minus
536             // - magicline (captures mousein/mouseout -> breaks draggable)
537             // - contextmenu & tabletools (disable contextual menu)
538             // - bunch of unused plugins
539             var plugins = [
540                 'a11yhelp', 'basicstyles', 'blockquote',
541                 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
542                 'elementspath', 'enterkey', 'entities', 'filebrowser',
543                 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
544                 'indentblock', 'indentlist', 'justify',
545                 'list', 'pastefromword', 'pastetext', 'preview',
546                 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
547                 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
548             ];
549             return {
550                 // FIXME
551                 language: 'en',
552                 // Disable auto-generated titles
553                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
554                 title: false,
555                 plugins: plugins.join(','),
556                 uiColor: '',
557                 // FIXME: currently breaks RTE?
558                 // Ensure no config file is loaded
559                 customConfig: '',
560                 // Disable ACF
561                 allowedContent: true,
562                 // Don't insert paragraphs around content in e.g. <li>
563                 autoParagraph: false,
564                 // Don't automatically add &nbsp; or <br> in empty block-level
565                 // elements when edition starts
566                 fillEmptyBlocks: false,
567                 filebrowserImageUploadUrl: "/website/attach",
568                 // Support for sharedSpaces in 4.x
569                 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
570                 // Place toolbar in controlled location
571                 sharedSpaces: { top: 'oe_rte_toolbar' },
572                 toolbar: [{
573                     name: 'clipboard', items: [
574                         "Undo"
575                     ]},{
576                         name: 'basicstyles', items: [
577                         "Bold", "Italic", "Underline", "Strike", "Subscript",
578                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
579                     ]},{
580                     name: 'span', items: [
581                         "Link", "Blockquote", "BulletedList",
582                         "NumberedList", "Indent", "Outdent"
583                     ]},{
584                     name: 'justify', items: [
585                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
586                     ]},{
587                     name: 'special', items: [
588                         "Image", "TableButton"
589                     ]},{
590                     name: 'styles', items: [
591                         "Styles"
592                     ]}
593                 ],
594                 // styles dropdown in toolbar
595                 stylesSet: [
596                     {name: "Normal", element: 'p'},
597                     {name: "Heading 1", element: 'h1'},
598                     {name: "Heading 2", element: 'h2'},
599                     {name: "Heading 3", element: 'h3'},
600                     {name: "Heading 4", element: 'h4'},
601                     {name: "Heading 5", element: 'h5'},
602                     {name: "Heading 6", element: 'h6'},
603                     {name: "Formatted", element: 'pre'},
604                     {name: "Address", element: 'address'},
605                     // emphasis
606                     {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
607                     {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
608                     {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
609                     {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
610                     {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
611                     {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
612                 ],
613             };
614         },
615     });
616
617     website.editor = { };
618     website.editor.Dialog = openerp.Widget.extend({
619         events: {
620             'hidden.bs.modal': 'destroy',
621             'click button.save': 'save',
622         },
623         init: function (editor) {
624             this._super();
625             this.editor = editor;
626         },
627         start: function () {
628             var sup = this._super();
629             this.$el.modal({backdrop: 'static'});
630             return sup;
631         },
632         save: function () {
633             this.close();
634         },
635         close: function () {
636             this.$el.modal('hide');
637         },
638     });
639
640     website.editor.LinkDialog = website.editor.Dialog.extend({
641         template: 'website.editor.dialog.link',
642         events: _.extend({}, website.editor.Dialog.prototype.events, {
643             'change .url-source': function (e) { this.changed($(e.target)); },
644             'mousedown': function (e) {
645                 var $target = $(e.target).closest('.list-group-item');
646                 if (!$target.length || $target.hasClass('active')) {
647                     // clicked outside groups, or clicked in active groups
648                     return;
649                 }
650
651                 this.changed($target.find('.url-source'));
652             },
653             'click button.remove': 'remove_link',
654             'change input#link-text': function (e) {
655                 this.text = $(e.target).val()
656             },
657         }),
658         init: function (editor) {
659             this._super(editor);
660             // url -> name mapping for existing pages
661             this.pages = Object.create(null);
662             this.text = null;
663         },
664         start: function () {
665             return $.when(
666                 this.fetch_pages().done(this.proxy('fill_pages')),
667                 this.fetch_menus().done(this.proxy('fill_menus')),
668                 this._super()
669             ).done(this.proxy('bind_data'));
670         },
671         save: function () {
672             var self = this, _super = this._super.bind(this);
673             var $e = this.$('.list-group-item.active .url-source');
674             var val = $e.val();
675             if (!val || !$e[0].checkValidity()) {
676                 // FIXME: error message
677                 $e.closest('.form-group').addClass('has-error');
678                 return;
679             }
680
681             var done = $.when();
682             if ($e.hasClass('email-address')) {
683                 this.make_link('mailto:' + val, false, val);
684             } else if ($e.hasClass('existing')) {
685                 self.make_link(val, false, this.pages[val]);
686             } else if ($e.hasClass('pages')) {
687                 // Create the page, get the URL back
688                 done = $.get(_.str.sprintf(
689                         '/pagenew/%s?noredirect', encodeURI(val)))
690                     .then(function (response) {
691                         self.make_link(response, false, val);
692                         var parent_id = self.$('.add-to-menu').val();
693                         if (parent_id) {
694                             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
695                                 model: 'website.menu',
696                                 method: 'create',
697                                 args: [{
698                                     'name': val,
699                                     'url': response,
700                                     'sequence': 0, // TODO: better tree widget
701                                     'website_id': website.id,
702                                     'parent_id': parent_id|0,
703                                 }],
704                                 kwargs: {
705                                     context: website.get_context()
706                                 },
707                             });
708                         }
709                     });
710             } else {
711                 this.make_link(val, this.$('input.window-new').prop('checked'));
712             }
713             done.then(_super);
714         },
715         make_link: function (url, new_window, label) {
716         },
717         bind_data: function () {
718             var href = this.element && (this.element.data( 'cke-saved-href')
719                                     ||  this.element.getAttribute('href'));
720             if (!href) { return; }
721
722             var match, $control;
723             if (match = /mailto:(.+)/.exec(href)) {
724                 $control = this.$('input.email-address').val(match[1]);
725             } else if (href in this.pages) {
726                 $control = this.$('select.existing').val(href);
727             } else if (match = /\/page\/(.+)/.exec(href)) {
728                 var actual_href = '/page/website.' + match[1];
729                 if (actual_href in this.pages) {
730                     $control = this.$('select.existing').val(actual_href);
731                 }
732             }
733             if (!$control) {
734                 $control = this.$('input.url').val(href);
735             }
736
737             this.changed($control);
738
739             this.$('input#link-text').val(this.element.getText());
740             this.$('input.window-new').prop(
741                 'checked', this.element.getAttribute('target') === '_blank');
742         },
743         changed: function ($e) {
744             this.$('.url-source').not($e).val('');
745             $e.closest('.list-group-item')
746                 .addClass('active')
747                 .siblings().removeClass('active')
748                 .addBack().removeClass('has-error');
749         },
750         fetch_pages: function () {
751             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
752                 model: 'website',
753                 method: 'list_pages',
754                 args: [null],
755                 kwargs: {
756                     context: website.get_context()
757                 },
758             });
759         },
760         fill_pages: function (results) {
761             var self = this;
762             var pages = this.$('select.existing')[0];
763             _(results).each(function (result) {
764                 self.pages[result.url] = result.name;
765
766                 pages.options[pages.options.length] =
767                         new Option(result.name, result.url);
768             });
769         },
770         fetch_menus: function () {
771             var context = website.get_context();
772             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
773                 model: 'website.menu',
774                 method: 'get_tree',
775                 args: [[context.website_id]],
776                 kwargs: {
777                     context: context
778                 },
779             });
780         },
781         fill_menus: function (tree) {
782             var self = this;
783             var menus = this.$('select.add-to-menu')[0];
784             var process_tree = function(node) {
785                 var name = (new Array(node.level + 1).join('|-')) + ' ' + node.name;
786                 menus.options[menus.options.length] = new Option(name, node.id);
787                 node.children.forEach(function (child) {
788                     process_tree(child);
789                 });
790             };
791             process_tree(tree);
792         },
793     });
794     website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
795         start: function () {
796             var element;
797             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
798                 this.editor.getSelection().selectElement(element);
799             }
800             this.element = element;
801             if (element) {
802                 this.add_removal_button();
803             }
804
805             return this._super();
806         },
807         add_removal_button: function () {
808             this.$('.modal-footer').prepend(
809                 openerp.qweb.render(
810                     'website.editor.dialog.link.footer-button'));
811         },
812         remove_link: function () {
813             var editor = this.editor;
814             // same issue as in make_link
815             setTimeout(function () {
816                 editor.removeStyle(new CKEDITOR.style({
817                     element: 'a',
818                     type: CKEDITOR.STYLE_INLINE,
819                     alwaysRemoveElement: true,
820                 }));
821             }, 0);
822             this.close();
823         },
824         /**
825          * Greatly simplified version of CKEDITOR's
826          * plugins.link.dialogs.link.onOk.
827          *
828          * @param {String} url
829          * @param {Boolean} [new_window=false]
830          * @param {String} [label=null]
831          */
832         make_link: function (url, new_window, label) {
833             var attributes = {href: url, 'data-cke-saved-href': url};
834             var to_remove = [];
835             if (new_window) {
836                 attributes['target'] = '_blank';
837             } else {
838                 to_remove.push('target');
839             }
840
841             if (this.element) {
842                 this.element.setAttributes(attributes);
843                 this.element.removeAttributes(to_remove);
844                 if (this.text) { this.element.setText(this.text); }
845             } else {
846                 var selection = this.editor.getSelection();
847                 var range = selection.getRanges(true)[0];
848
849                 if (range.collapsed) {
850                     //noinspection JSPotentiallyInvalidConstructorUsage
851                     var text = new CKEDITOR.dom.text(
852                         this.text || label || url);
853                     range.insertNode(text);
854                     range.selectNodeContents(text);
855                 }
856
857                 //noinspection JSPotentiallyInvalidConstructorUsage
858                 new CKEDITOR.style({
859                     type: CKEDITOR.STYLE_INLINE,
860                     element: 'a',
861                     attributes: attributes,
862                 }).applyToRange(range);
863
864                 // focus dance between RTE & dialog blow up the stack in Safari
865                 // and Chrome, so defer select() until dialog has been closed
866                 setTimeout(function () {
867                     range.select();
868                 }, 0);
869             }
870         },
871         /**
872          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
873          * if the editor is set directly on a link it will thus not work.
874          */
875         get_selected_link: function () {
876             return get_selected_link(this.editor);
877         },
878     });
879
880     /**
881      * ImageDialog widget. Lets users change an image, including uploading a
882      * new image in OpenERP or selecting the image style (if supported by
883      * the caller).
884      *
885      * Initialized as usual, but the caller can hook into two events:
886      *
887      * @event start({url, style}) called during dialog initialization and
888      *                            opening, the handler can *set* the ``url``
889      *                            and ``style`` properties on its parameter
890      *                            to provide these as default values to the
891      *                            dialog
892      * @event save({url, style}) called during dialog finalization, the handler
893      *                           is provided with the image url and style
894      *                           selected by the users (or possibly the ones
895      *                           originally passed in)
896      */
897     website.editor.ImageDialog = website.editor.Dialog.extend({
898         template: 'website.editor.dialog.image',
899         events: _.extend({}, website.editor.Dialog.prototype.events, {
900             'change .url-source': function (e) { this.changed($(e.target)); },
901             'click button.filepicker': function () {
902                 this.$('input[type=file]').click();
903             },
904             'change input[type=file]': 'file_selection',
905             'change input.url': 'preview_image',
906             'click a[href=#existing]': 'browse_existing',
907             'change select.image-style': 'preview_image',
908         }),
909
910         start: function () {
911             var $options = this.$('.image-style').children();
912             this.image_styles = $options.map(function () { return this.value; }).get();
913
914             var o = { url: null, style: null, };
915             // avoid typos, prevent addition of new properties to the object
916             Object.preventExtensions(o);
917             this.trigger('start', o);
918
919             if (o.url) {
920                 if (o.style) {
921                     this.$('.image-style').val(o.style);
922                 }
923                 this.set_image(o.url);
924             }
925
926             return this._super();
927         },
928         save: function () {
929             this.trigger('save', {
930                 url: this.$('input.url').val(),
931                 style: this.$('.image-style').val(),
932             });
933             return this._super();
934         },
935
936         /**
937          * Sets the provided image url as the dialog's value-to-save and
938          * refreshes the preview element to use it.
939          */
940         set_image: function (url) {
941             this.$('input.url').val(url);
942             this.preview_image();
943         },
944
945         file_selection: function () {
946             this.$('button.filepicker').removeClass('btn-danger btn-success');
947
948             var self = this;
949             var callback = _.uniqueId('func_');
950             this.$('input[name=func]').val(callback);
951
952             window[callback] = function (url, error) {
953                 delete window[callback];
954                 self.file_selected(url, error);
955             };
956             this.$('form').submit();
957         },
958         file_selected: function(url, error) {
959             var $button = this.$('button.filepicker');
960             if (error) {
961                 $button.addClass('btn-danger');
962                 return;
963             }
964             $button.addClass('btn-success');
965             this.set_image(url);
966         },
967         preview_image: function () {
968             var image = this.$('input.url').val();
969             if (!image) { return; }
970
971             this.$('img.image-preview')
972                 .attr('src', image)
973                 .removeClass(this.image_styles.join(' '))
974                 .addClass(this.$('select.image-style').val());
975         },
976         browse_existing: function (e) {
977             e.preventDefault();
978             new website.editor.ExistingImageDialog(this).appendTo(document.body);
979         },
980     });
981     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
982         init: function () {
983             this._super.apply(this, arguments);
984
985             this.on('start', this, this.proxy('started'));
986             this.on('save', this, this.proxy('saved'));
987         },
988         started: function (holder) {
989             var selection = this.editor.getSelection();
990             var el = selection && selection.getSelectedElement();
991             this.element = null;
992
993             if (el && el.is('img')) {
994                 this.element = el;
995                 _(this.image_styles).each(function (style) {
996                     if (el.hasClass(style)) {
997                         holder.style = style;
998                     }
999                 });
1000                 holder.url = el.getAttribute('src');
1001             }
1002         },
1003         saved: function (data) {
1004             var element, editor = this.editor;
1005             if (!(element = this.element)) {
1006                 element = editor.document.createElement('img');
1007                 element.addClass('img');
1008                 // focus event handler interactions between bootstrap (modal)
1009                 // and ckeditor (RTE) lead to blowing the stack in Safari and
1010                 // Chrome (but not FF) when this is done synchronously =>
1011                 // defer insertion so modal has been hidden & destroyed before
1012                 // it happens
1013                 setTimeout(function () {
1014                     editor.insertElement(element);
1015                 }, 0);
1016             }
1017
1018             var style = data.style;
1019             element.setAttribute('src', data.url);
1020             element.removeAttribute('data-cke-saved-src');
1021             $(element.$).removeClass(this.image_styles.join(' '));
1022             if (style) { element.addClass(style); }
1023         },
1024     });
1025
1026     var IMAGES_PER_ROW = 6;
1027     var IMAGES_ROWS = 4;
1028     website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1029         template: 'website.editor.dialog.image.existing',
1030         events: _.extend({}, website.editor.Dialog.prototype.events, {
1031             'click .existing-attachments img': 'select_existing',
1032             'click .pager > li': function (e) {
1033                 e.preventDefault();
1034                 var $target = $(e.currentTarget);
1035                 if ($target.hasClass('disabled')) {
1036                     return;
1037                 }
1038                 this.page += $target.hasClass('previous') ? -1 : 1;
1039                 this.display_attachments();
1040             },
1041         }),
1042         init: function (parent) {
1043             this.image = null;
1044             this.page = 0;
1045             this.parent = parent;
1046             this._super(parent.editor);
1047         },
1048
1049         start: function () {
1050             return $.when(
1051                 this._super(),
1052                 this.fetch_existing().then(this.proxy('fetched_existing')));
1053         },
1054
1055         fetch_existing: function () {
1056             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1057                 model: 'ir.attachment',
1058                 method: 'search_read',
1059                 args: [],
1060                 kwargs: {
1061                     fields: ['name', 'website_url'],
1062                     domain: [['res_model', '=', 'ir.ui.view']],
1063                     order: 'name',
1064                     context: website.get_context(),
1065                 }
1066             });
1067         },
1068         fetched_existing: function (records) {
1069             this.records = records;
1070             this.display_attachments();
1071         },
1072         display_attachments: function () {
1073             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1074
1075             var from = this.page * per_screen;
1076             var records = this.records;
1077
1078             // Create rows of 3 records
1079             var rows = _(records).chain()
1080                 .slice(from, from + per_screen)
1081                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1082                 .values()
1083                 .value();
1084
1085             this.$('.existing-attachments').replaceWith(
1086                 openerp.qweb.render(
1087                     'website.editor.dialog.image.existing.content', {rows: rows}));
1088             this.$('.pager')
1089                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1090                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1091
1092         },
1093         select_existing: function (e) {
1094             var link = $(e.currentTarget).attr('src');
1095             if (link) {
1096                 this.parent.set_image(link);
1097             }
1098             this.close()
1099         },
1100     });
1101
1102     function get_selected_link(editor) {
1103         var sel = editor.getSelection(),
1104             el = sel.getSelectedElement();
1105         if (el && el.is('a')) { return el; }
1106
1107         var range = sel.getRanges(true)[0];
1108         if (!range) { return null; }
1109
1110         range.shrink(CKEDITOR.SHRINK_TEXT);
1111         var commonAncestor = range.getCommonAncestor();
1112         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1113             return element.data('oe-model') === 'ir.ui.view'
1114         });
1115         if (!viewRoot) { return null; }
1116         // if viewRoot is the first link, don't edit it.
1117         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1118                 .contains('a', true);
1119     }
1120
1121
1122     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1123     var OBSERVER_CONFIG = {
1124         childList: true,
1125         attributes: true,
1126         characterData: true,
1127         subtree: true,
1128         attributeOldValue: true,
1129     };
1130     var observer = new website.Observer(function (mutations) {
1131         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1132         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1133         //       will not mark dirty on attribute changes (@class, img/@src,
1134         //       a/@href, ...)
1135         _(mutations).chain()
1136             .filter(function (m) {
1137                 switch(m.type) {
1138                 case 'attributes': // ignore .cke_focus being added or removed
1139                     // if attribute is not a class, can't be .cke_focus change
1140                     if (m.attributeName !== 'class') { return true; }
1141
1142                     // find out what classes were added or removed
1143                     var oldClasses = (m.oldValue || '').split(/\s+/);
1144                     var newClasses = m.target.className.split(/\s+/);
1145                     var change = _.union(_.difference(oldClasses, newClasses),
1146                                          _.difference(newClasses, oldClasses));
1147                     // ignore mutation if the *only* change is .cke_focus
1148                     return change.length !== 1 || change[0] === 'cke_focus';
1149                 case 'childList':
1150                     // <br type="_moz"> appears when focusing RTE in FF, ignore
1151                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1152                 default:
1153                     return true;
1154                 }
1155             })
1156             .map(function (m) {
1157                 var node = m.target;
1158                 while (node && !$(node).hasClass('oe_editable')) {
1159                     node = node.parentNode;
1160                 }
1161                 $(m.target).trigger('node_changed');
1162                 return node;
1163             })
1164             .compact()
1165             .uniq()
1166             .each(function (node) { $(node).trigger('content_changed'); })
1167     });
1168 })();