[IMP] Menu tree from client side
[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                 .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             var element;
666             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
667                 this.editor.getSelection().selectElement(element);
668             }
669             this.element = element;
670             if (element) {
671                 this.add_removal_button();
672             }
673
674             return $.when(
675                 this.fetch_pages().done(this.proxy('fill_pages')),
676                 this.fetch_menus().done(this.proxy('fill_menus')),
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                         var parent_id = self.$('.add-to-menu').val();
766                         if (parent_id) {
767                             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
768                                 model: 'website.menu',
769                                 method: 'create',
770                                 args: [{
771                                     'name': val,
772                                     'url': response,
773                                     'sequence': 0, // TODO: better tree widget
774                                     'website_id': website.id,
775                                     'parent_id': parent_id|0,
776                                 }],
777                                 kwargs: {
778                                     context: website.get_context()
779                                 },
780                             });
781                         }
782                     });
783             } else {
784                 this.make_link(val, this.$('input.window-new').prop('checked'));
785             }
786             done.then(_super);
787         },
788         bind_data: function () {
789             var href = this.element && (this.element.data( 'cke-saved-href')
790                                     ||  this.element.getAttribute('href'));
791             if (!href) { return; }
792
793             var match, $control;
794             if (match = /mailto:(.+)/.exec(href)) {
795                 $control = this.$('input.email-address').val(match[1]);
796             } else if (href in this.pages) {
797                 $control = this.$('select.existing').val(href);
798             } else if (match = /\/page\/(.+)/.exec(href)) {
799                 var actual_href = '/page/website.' + match[1];
800                 if (actual_href in this.pages) {
801                     $control = this.$('select.existing').val(actual_href);
802                 }
803             }
804             if (!$control) {
805                 $control = this.$('input.url').val(href);
806             }
807
808             this.changed($control);
809
810             this.$('input#link-text').val(this.element.getText());
811             this.$('input.window-new').prop(
812                 'checked', this.element.getAttribute('target') === '_blank');
813         },
814         changed: function ($e) {
815             this.$('.url-source').not($e).val('');
816             $e.closest('.list-group-item')
817                 .addClass('active')
818                 .siblings().removeClass('active')
819                 .addBack().removeClass('has-error');
820         },
821         /**
822          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
823          * if the editor is set directly on a link it will thus not work.
824          */
825         get_selected_link: function () {
826             return get_selected_link(this.editor);
827         },
828         fetch_pages: function () {
829             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
830                 model: 'website',
831                 method: 'list_pages',
832                 args: [null],
833                 kwargs: {
834                     context: website.get_context()
835                 },
836             });
837         },
838         fill_pages: function (results) {
839             var self = this;
840             var pages = this.$('select.existing')[0];
841             _(results).each(function (result) {
842                 self.pages[result.url] = result.name;
843
844                 pages.options[pages.options.length] =
845                         new Option(result.name, result.url);
846             });
847         },
848         fetch_menus: function () {
849             var context = website.get_context();
850             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
851                 model: 'website.menu',
852                 method: 'get_tree',
853                 args: [[context.website_id]],
854                 kwargs: {
855                     context: context
856                 },
857             });
858         },
859         fill_menus: function (tree) {
860             var self = this;
861             var menus = this.$('select.add-to-menu')[0];
862             var process_tree = function(node) {
863                 var name = (new Array(node.level + 1).join('|-')) + ' ' + node.name;
864                 menus.options[menus.options.length] = new Option(name, node.id);
865                 node.children.forEach(function (child) {
866                     process_tree(child);
867                 });
868             };
869             process_tree(tree);
870         },
871     });
872     /**
873      * ImageDialog widget. Lets users change an image, including uploading a
874      * new image in OpenERP or selecting the image style (if supported by
875      * the caller).
876      *
877      * Initialized as usual, but the caller can hook into two events:
878      *
879      * @event start({url, style}) called during dialog initialization and
880      *                            opening, the handler can *set* the ``url``
881      *                            and ``style`` properties on its parameter
882      *                            to provide these as default values to the
883      *                            dialog
884      * @event save({url, style}) called during dialog finalization, the handler
885      *                           is provided with the image url and style
886      *                           selected by the users (or possibly the ones
887      *                           originally passed in)
888      */
889     website.editor.ImageDialog = website.editor.Dialog.extend({
890         template: 'website.editor.dialog.image',
891         events: _.extend({}, website.editor.Dialog.prototype.events, {
892             'change .url-source': function (e) { this.changed($(e.target)); },
893             'click button.filepicker': function () {
894                 this.$('input[type=file]').click();
895             },
896             'change input[type=file]': 'file_selection',
897             'change input.url': 'preview_image',
898             'click a[href=#existing]': 'browse_existing',
899             'change select.image-style': 'preview_image',
900         }),
901
902         start: function () {
903             var $options = this.$('.image-style').children();
904             this.image_styles = $options.map(function () { return this.value; }).get();
905
906             var o = { url: null, style: null, };
907             // avoid typos, prevent addition of new properties to the object
908             Object.preventExtensions(o);
909             this.trigger('start', o);
910
911             if (o.url) {
912                 if (o.style) {
913                     this.$('.image-style').val(o.style);
914                 }
915                 this.set_image(o.url);
916             }
917
918             return this._super();
919         },
920         save: function () {
921             this.trigger('save', {
922                 url: this.$('input.url').val(),
923                 style: this.$('.image-style').val(),
924             });
925             return this._super();
926         },
927
928         /**
929          * Sets the provided image url as the dialog's value-to-save and
930          * refreshes the preview element to use it.
931          */
932         set_image: function (url) {
933             this.$('input.url').val(url);
934             this.preview_image();
935         },
936
937         file_selection: function () {
938             this.$('button.filepicker').removeClass('btn-danger btn-success');
939
940             var self = this;
941             var callback = _.uniqueId('func_');
942             this.$('input[name=func]').val(callback);
943
944             window[callback] = function (url, error) {
945                 delete window[callback];
946                 self.file_selected(url, error);
947             };
948             this.$('form').submit();
949         },
950         file_selected: function(url, error) {
951             var $button = this.$('button.filepicker');
952             if (error) {
953                 $button.addClass('btn-danger');
954                 return;
955             }
956             $button.addClass('btn-success');
957             this.set_image(url);
958         },
959         preview_image: function () {
960             var image = this.$('input.url').val();
961             if (!image) { return; }
962
963             this.$('img.image-preview')
964                 .attr('src', image)
965                 .removeClass(this.image_styles.join(' '))
966                 .addClass(this.$('select.image-style').val());
967         },
968         browse_existing: function (e) {
969             e.preventDefault();
970             new website.editor.ExistingImageDialog(this).appendTo(document.body);
971         },
972     });
973     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
974         init: function () {
975             this._super.apply(this, arguments);
976
977             this.on('start', this, this.proxy('started'));
978             this.on('save', this, this.proxy('saved'));
979         },
980         started: function (holder) {
981             var selection = this.editor.getSelection();
982             var el = selection && selection.getSelectedElement();
983             this.element = null;
984
985             if (el && el.is('img')) {
986                 this.element = el;
987                 _(this.image_styles).each(function (style) {
988                     if (el.hasClass(style)) {
989                         holder.style = style;
990                     }
991                 });
992                 holder.url = el.getAttribute('src');
993             }
994         },
995         saved: function (data) {
996             var element, editor = this.editor;
997             if (!(element = this.element)) {
998                 element = editor.document.createElement('img');
999                 element.addClass('img');
1000                 // focus event handler interactions between bootstrap (modal)
1001                 // and ckeditor (RTE) lead to blowing the stack in Safari and
1002                 // Chrome (but not FF) when this is done synchronously =>
1003                 // defer insertion so modal has been hidden & destroyed before
1004                 // it happens
1005                 setTimeout(function () {
1006                     editor.insertElement(element);
1007                 }, 0);
1008             }
1009
1010             var style = data.style;
1011             element.setAttribute('src', data.url);
1012             element.removeAttribute('data-cke-saved-src');
1013             $(element.$).removeClass(this.image_styles.join(' '));
1014             if (style) { element.addClass(style); }
1015         },
1016     });
1017
1018     var IMAGES_PER_ROW = 6;
1019     var IMAGES_ROWS = 4;
1020     website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1021         template: 'website.editor.dialog.image.existing',
1022         events: _.extend({}, website.editor.Dialog.prototype.events, {
1023             'click .existing-attachments img': 'select_existing',
1024             'click .pager > li': function (e) {
1025                 e.preventDefault();
1026                 var $target = $(e.currentTarget);
1027                 if ($target.hasClass('disabled')) {
1028                     return;
1029                 }
1030                 this.page += $target.hasClass('previous') ? -1 : 1;
1031                 this.display_attachments();
1032             },
1033         }),
1034         init: function (parent) {
1035             this.image = null;
1036             this.page = 0;
1037             this.parent = parent;
1038             this._super(parent.editor);
1039         },
1040
1041         start: function () {
1042             return $.when(
1043                 this._super(),
1044                 this.fetch_existing().then(this.proxy('fetched_existing')));
1045         },
1046
1047         fetch_existing: function () {
1048             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1049                 model: 'ir.attachment',
1050                 method: 'search_read',
1051                 args: [],
1052                 kwargs: {
1053                     fields: ['name', 'website_url'],
1054                     domain: [['res_model', '=', 'ir.ui.view']],
1055                     order: 'name',
1056                     context: website.get_context(),
1057                 }
1058             });
1059         },
1060         fetched_existing: function (records) {
1061             this.records = records;
1062             this.display_attachments();
1063         },
1064         display_attachments: function () {
1065             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1066
1067             var from = this.page * per_screen;
1068             var records = this.records;
1069
1070             // Create rows of 3 records
1071             var rows = _(records).chain()
1072                 .slice(from, from + per_screen)
1073                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1074                 .values()
1075                 .value();
1076
1077             this.$('.existing-attachments').replaceWith(
1078                 openerp.qweb.render(
1079                     'website.editor.dialog.image.existing.content', {rows: rows}));
1080             this.$('.pager')
1081                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1082                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1083
1084         },
1085         select_existing: function (e) {
1086             var link = $(e.currentTarget).attr('src');
1087             if (link) {
1088                 this.parent.set_image(link);
1089             }
1090             this.close()
1091         },
1092     });
1093
1094     function get_selected_link(editor) {
1095         var sel = editor.getSelection(),
1096             el = sel.getSelectedElement();
1097         if (el && el.is('a')) { return el; }
1098
1099         var range = sel.getRanges(true)[0];
1100         if (!range) { return null; }
1101
1102         range.shrink(CKEDITOR.SHRINK_TEXT);
1103         var commonAncestor = range.getCommonAncestor();
1104         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1105             return element.data('oe-model') === 'ir.ui.view'
1106         });
1107         if (!viewRoot) { return null; }
1108         // if viewRoot is the first link, don't edit it.
1109         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1110                 .contains('a', true);
1111     }
1112
1113
1114     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1115     var OBSERVER_CONFIG = {
1116         childList: true,
1117         attributes: true,
1118         characterData: true,
1119         subtree: true,
1120         attributeOldValue: true,
1121     };
1122     var observer = new website.Observer(function (mutations) {
1123         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1124         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1125         //       will not mark dirty on attribute changes (@class, img/@src,
1126         //       a/@href, ...)
1127         _(mutations).chain()
1128             .filter(function (m) {
1129                 switch(m.type) {
1130                 case 'attributes': // ignore .cke_focus being added or removed
1131                     // if attribute is not a class, can't be .cke_focus change
1132                     if (m.attributeName !== 'class') { return true; }
1133
1134                     // find out what classes were added or removed
1135                     var oldClasses = (m.oldValue || '').split(/\s+/);
1136                     var newClasses = m.target.className.split(/\s+/);
1137                     var change = _.union(_.difference(oldClasses, newClasses),
1138                                          _.difference(newClasses, oldClasses));
1139                     // ignore mutation if the *only* change is .cke_focus
1140                     return change.length !== 1 || change[0] === 'cke_focus';
1141                 case 'childList':
1142                     // <br type="_moz"> appears when focusing RTE in FF, ignore
1143                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1144                 default:
1145                     return true;
1146                 }
1147             })
1148             .map(function (m) {
1149                 var node = m.target;
1150                 while (node && !$(node).hasClass('oe_editable')) {
1151                     node = node.parentNode;
1152                 }
1153                 $(m.target).trigger('node_changed');
1154                 return node;
1155             })
1156             .compact()
1157             .uniq()
1158             .each(function (node) { $(node).trigger('content_changed'); })
1159     });
1160 })();