[MERGE] from website-al
[odoo/odoo.git] / addons / website / static / src / js / website.editor.js
1 (function () {
2     'use strict';
3
4     var website = openerp.website;
5
6     website.templates.push('/website/static/src/xml/website.editor.xml');
7     website.dom_ready.done(function () {
8         // $.fn.data automatically parses value, '0'|'1' -> 0|1
9         website.is_editable = $(document.documentElement).data('editable');
10         var is_smartphone = $(document.body)[0].clientWidth < 767;
11
12         if (website.is_editable && !is_smartphone) {
13             website.ready().then(website.init_editor);
14         }
15     });
16
17     function link_dialog(editor) {
18         return new website.editor.LinkDialog(editor).appendTo(document.body);
19     }
20     function image_dialog(editor) {
21         return new website.editor.ImageDialog(editor).appendTo(document.body);
22     }
23
24     website.init_editor = function () {
25         CKEDITOR.plugins.add('customdialogs', {
26             requires: 'link,image',
27             init: function (editor) {
28                 editor.on('doubleclick', function (evt) {
29                     if (evt.data.dialog === 'link') {
30                         delete evt.data.dialog;
31                         link_dialog(editor);
32                     } else if(evt.data.dialog === 'image') {
33                         delete evt.data.dialog;
34                         image_dialog(editor);
35                     }
36                     // priority should be smaller than dialog (999) but bigger
37                     // than link or image (default=10)
38                 }, null, null, 500);
39
40                 editor.addCommand('link', {
41                     exec: function (editor, data) {
42                         link_dialog(editor);
43                         return true;
44                     },
45                     canUndo: false,
46                     editorFocus: true,
47                 });
48                 editor.addCommand('image', {
49                     exec: function (editor, data) {
50                         image_dialog(editor);
51                         return true;
52                     },
53                     canUndo: false,
54                     editorFocus: true,
55                 });
56             }
57         });
58         CKEDITOR.plugins.add( 'tablebutton', {
59             requires: 'panelbutton,floatpanel',
60             init: function( editor ) {
61                 var label = "Table";
62
63                 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
64                     label: label,
65                     title: label,
66                     // use existing 'table' icon
67                     icon: 'table',
68                     modes: { wysiwyg: true },
69                     editorFocus: true,
70                     // panel opens in iframe, @css is CSS file <link>-ed within
71                     // frame document, @attributes are set on iframe itself.
72                     panel: {
73                         css: '/website/static/src/css/editor.css',
74                         attributes: { 'role': 'listbox', 'aria-label': label, },
75                     },
76
77                     onBlock: function (panel, block) {
78                         block.autoSize = true;
79                         block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
80                             rows: 5,
81                             cols: 5,
82                         }));
83
84                         var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
85                             var $e = $(e.target);
86                             var y = $e.index() + 1;
87                             var x = $e.closest('tr').index() + 1;
88
89                             $table
90                                 .find('td').removeClass('selected').end()
91                                 .find('tr:lt(' + String(x) + ')')
92                                 .children().filter(function () { return $(this).index() < y; })
93                                 .addClass('selected');
94                         }).on('click', 'td', function (e) {
95                             var $e = $(e.target);
96
97                             var table = new CKEDITOR.dom.element(
98                                 $(openerp.qweb.render('website.editor.table', {
99                                     rows: $e.closest('tr').index() + 1,
100                                     cols: $e.index() + 1,
101                                 }))[0]);
102
103                             editor.insertElement(table);
104                             setTimeout(function () {
105                                 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
106                                 var range = editor.createRange();
107                                 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
108                                 range.select();
109                             }, 0);
110                         });
111
112                         block.element.getDocument().getBody().setStyle('overflow', 'hidden');
113                         CKEDITOR.ui.fire('ready', this);
114                     },
115                 });
116             }
117         });
118
119         var editor = new website.EditorBar();
120         var $body = $(document.body);
121         editor.prependTo($body);
122         $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
123     };
124         /* ----- TOP EDITOR BAR FOR ADMIN ---- */
125     website.EditorBar = openerp.Widget.extend({
126         template: 'website.editorbar',
127         events: {
128             'click button[data-action=edit]': 'edit',
129             'click button[data-action=save]': 'save',
130             'click button[data-action=cancel]': 'cancel',
131         },
132         container: 'body',
133         customize_setup: function() {
134             var self = this;
135             var view_name = $(document.documentElement).data('view-xmlid');
136             var menu = $('#customize-menu');
137             this.$('#customize-menu-button').click(function(event) {
138                 menu.empty();
139                 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
140                     function(result) {
141                         _.each(result, function (item) {
142                             if (item.header) {
143                                 menu.append('<li class="dropdown-header">' + item.name + '</li>');
144                             } else {
145                                 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
146                                     item.id, item.active ? '' : '-empty', item.name));
147                             }
148                         });
149                         // Adding Static Menus
150                         menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
151                     }
152                 );
153             });
154             menu.on('click', 'a', function (event) {
155                 var view_id = $(event.currentTarget).data('view-id');
156                 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
157                     'view_id': view_id
158                 }).then( function(result) {
159                     window.location.reload();
160                 });
161             });
162         },
163         start: function() {
164             var self = this;
165
166             this.saving_mutex = new openerp.Mutex();
167
168             this.$('#website-top-edit').hide();
169             this.$('#website-top-view').show();
170
171             $('.dropdown-toggle').dropdown();
172             this.customize_setup();
173
174             this.$buttons = {
175                 edit: this.$('button[data-action=edit]'),
176                 save: this.$('button[data-action=save]'),
177                 cancel: this.$('button[data-action=cancel]'),
178             };
179
180             this.rte = new website.RTE(this);
181             this.rte.on('change', this, this.proxy('rte_changed'));
182
183             return $.when(
184                 this._super.apply(this, arguments),
185                 this.rte.prependTo(this.$('#website-top-edit .nav.pull-right'))
186             );
187         },
188         edit: function () {
189             var self = this;
190             this.$buttons.edit.prop('disabled', true);
191             this.$('#website-top-view').hide();
192             this.$('#website-top-edit').show();
193             $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
194
195             var $editables = $('[data-oe-model]')
196                     .not('link, script')
197                     // FIXME: propagation should make "meta" blocks non-editable in the first place...
198                     .not('.oe_snippets,.oe_snippet, .oe_snippet *')
199                     .prop('contentEditable', true)
200                     .addClass('oe_editable');
201             var $rte_ables = $editables.not('[data-oe-type]');
202             var $raw_editables = $editables.not($rte_ables);
203
204             // temporary: on raw editables, links are still active so an
205             // editable link, containing a link or within a link becomes very
206             // hard to edit. Disable linking for these.
207             $raw_editables.parents('a')
208                 .add($raw_editables.find('a'))
209                 .on('click', function (e) {
210                     e.preventDefault();
211                 });
212
213             this.rte.start_edition($rte_ables);
214             $raw_editables.each(function () {
215                 observer.observe(this, OBSERVER_CONFIG);
216             }).one('content_changed', function () {
217                 $(this).addClass('oe_dirty');
218                 self.rte_changed();
219             });
220         },
221         rte_changed: function () {
222             this.$buttons.save.prop('disabled', false);
223         },
224         save: function () {
225             var self = this;
226             var defs = [];
227             observer.disconnect();
228             $('.oe_dirty').each(function (i, v) {
229                 var $el = $(this);
230                 // TODO: Add a queue with concurrency limit in webclient
231                 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
232                 var def = self.saving_mutex.exec(function () {
233                     return self.saveElement($el).then(function () {
234                         $el.removeClass('oe_dirty');
235                     }).fail(function () {
236                         var data = $el.data();
237                         console.error(_.str.sprintf('Could not save %s#%d#%s', data.oeModel, data.oeId, data.oeField));
238                     });
239                 });
240                 defs.push(def);
241             });
242             return $.when.apply(null, defs).then(function () {
243                 window.location.reload();
244             });
245         },
246         saveElement: function ($el) {
247             var data = $el.data();
248             var html = $el.html();
249             var xpath = data.oeXpath;
250             if (xpath) {
251                 var $w = $el.clone();
252                 $w.removeClass('oe_dirty');
253                 _.each(['model', 'id', 'field', 'xpath'], function(d) {$w.removeAttr('data-oe-' + d);});
254                 $w
255                     .removeClass('oe_editable')
256                     .prop('contentEditable', false);
257                 html = $w.wrap('<div>').parent().html();
258             }
259             return openerp.jsonRpc('/web/dataset/call', 'call', {
260                 model: 'ir.ui.view',
261                 method: 'save',
262                 args: [data.oeModel, data.oeId, data.oeField, html, xpath]
263             });
264         },
265         cancel: function () {
266             window.location.reload();
267         },
268     });
269
270     /* ----- RICH TEXT EDITOR ---- */
271     website.RTE = openerp.Widget.extend({
272         tagName: 'li',
273         id: 'oe_rte_toolbar',
274         className: 'oe_right oe_rte_toolbar',
275         // editor.ui.items -> possible commands &al
276         // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
277
278         init: function (EditorBar) {
279             this.EditorBar = EditorBar;
280             this._super.apply(this, arguments);
281         },
282
283         start_edition: function ($elements) {
284             var self = this;
285             $elements
286                 .not('span, [data-oe-type]')
287                 .each(function () {
288                     var node = this;
289                     var $node = $(node);
290                     var editor = CKEDITOR.inline(this, self._config());
291                     editor.on('instanceReady', function () {
292                         self.trigger('instanceReady');
293                         observer.observe(node, OBSERVER_CONFIG);
294                     });
295                     $node.one('content_changed', function () {
296                         $node.addClass('oe_dirty');
297                         self.trigger('change');
298                     });
299                 });
300         },
301
302         _current_editor: function () {
303             return CKEDITOR.currentInstance;
304         },
305         _config: function () {
306             var removed_plugins = [
307                     // remove custom context menu
308                     'contextmenu,tabletools,liststyle',
309                     // magicline captures mousein/mouseout => draggable does not work
310                     'magicline'
311             ];
312             return {
313                 // Disable auto-generated titles
314                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
315                 title: false,
316                 removePlugins: removed_plugins.join(','),
317                 uiColor: '',
318                 // Ensure no config file is loaded
319                 customConfig: '',
320                 // Disable ACF
321                 allowedContent: true,
322                 // Don't insert paragraphs around content in e.g. <li>
323                 autoParagraph: false,
324                 filebrowserImageUploadUrl: "/website/attach",
325                 // Support for sharedSpaces in 4.x
326                 extraPlugins: 'sharedspace,customdialogs,tablebutton',
327                 // Place toolbar in controlled location
328                 sharedSpaces: { top: 'oe_rte_toolbar' },
329                 toolbar: [
330                     {name: 'basicstyles', items: [
331                         "Bold", "Italic", "Underline", "Strike", "Subscript",
332                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
333                     ]},{
334                     name: 'span', items: [
335                         "Link", "Unlink", "Blockquote", "BulletedList",
336                         "NumberedList", "Indent", "Outdent"
337                     ]},{
338                     name: 'justify', items: [
339                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
340                     ]},{
341                     name: 'special', items: [
342                         "Image", "TableButton"
343                     ]},{
344                     name: 'styles', items: [
345                         "Styles"
346                     ]}
347                 ],
348                 // styles dropdown in toolbar
349                 stylesSet: [
350                     {name: "Normal", element: 'p'},
351                     {name: "Heading 1", element: 'h1'},
352                     {name: "Heading 2", element: 'h2'},
353                     {name: "Heading 3", element: 'h3'},
354                     {name: "Heading 4", element: 'h4'},
355                     {name: "Heading 5", element: 'h5'},
356                     {name: "Heading 6", element: 'h6'},
357                     {name: "Formatted", element: 'pre'},
358                     {name: "Address", element: 'address'},
359                     // emphasis
360                     {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
361                     {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
362                     {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
363                     {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
364                     {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
365                     {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
366                 ],
367             };
368         },
369     });
370
371     website.editor = { };
372     website.editor.Dialog = openerp.Widget.extend({
373         events: {
374             'hidden.bs.modal': 'destroy',
375             'click button.save': 'save',
376         },
377         init: function (editor) {
378             this._super();
379             this.editor = editor;
380         },
381         start: function () {
382             var sup = this._super();
383             this.$el.modal();
384             return sup;
385         },
386         save: function () {
387             this.$el.modal('hide');
388         },
389     });
390
391     website.editor.LinkDialog = website.editor.Dialog.extend({
392         template: 'website.editor.dialog.link',
393         events: _.extend({}, website.editor.Dialog.prototype.events, {
394             'change .url-source': function (e) { this.changed($(e.target)); },
395             'click div.existing a': 'select_page',
396         }),
397         init: function (editor) {
398             this._super(editor);
399             // url -> name mapping for existing pages
400             this.pages = Object.create(null);
401             // name -> url mapping for the same
402             this.pages_by_name = Object.create(null);
403         },
404         start: function () {
405             var element;
406             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
407                 this.editor.getSelection().selectElement(element);
408             }
409             this.element = element;
410
411             return $.when(
412                 this.fetch_pages().done(this.proxy('fill_pages')),
413                 this._super()
414             ).done(this.proxy('bind_data'));
415         },
416         /**
417          * Greatly simplified version of CKEDITOR's
418          * plugins.link.dialogs.link.onOk.
419          *
420          * @param {String} url
421          * @param {Boolean} [new_window=false]
422          * @param {String} [label=null]
423          */
424         make_link: function (url, new_window, label) {
425             var attributes = {href: url, 'data-cke-saved-href': url};
426             var to_remove = [];
427             if (new_window) {
428                 attributes['target'] = '_blank';
429             } else {
430                 to_remove.push('target');
431             }
432
433             if (this.element) {
434                 this.element.setAttributes(attributes);
435                 this.element.removeAttributes(to_remove);
436             } else {
437                 var selection = this.editor.getSelection();
438                 var range = selection.getRanges(true)[0];
439
440                 if (range.collapsed) {
441                     var text = new CKEDITOR.dom.text(label || url);
442                     range.insertNode(text);
443                     range.selectNodeContents(text);
444                 }
445
446                 new CKEDITOR.style({
447                     type: CKEDITOR.STYLE_INLINE,
448                     element: 'a',
449                     attributes: attributes,
450                 }).applyToRange(range);
451
452                 // focus dance between RTE & dialog blow up the stack in Safari
453                 // and Chrome, so defer select() until dialog has been closed
454                 setTimeout(function () {
455                     range.select();
456                 }, 0);
457             }
458         },
459         save: function () {
460             var self = this, _super = this._super.bind(this);
461             var $e = this.$('.url-source').filter(function () { return !!this.value; });
462
463             var val = $e.val(), done = $.when();
464             if ($e.hasClass('email-address')) {
465                 this.make_link('mailto:' + val, false, val);
466             } else if ($e.hasClass('pages')) {
467                 // ``val`` is the *name* of the page
468                 var url = this.pages_by_name[val];
469                 if (!url) {
470                     // Create the page, get the URL back
471                     done = $.get(_.str.sprintf(
472                         '/pagenew/%s?noredirect', encodeURIComponent(val)))
473                         .then(function (response) {
474                             url = response;
475                         });
476                 }
477                 done.then(function () {
478                     self.make_link(url, false, val);
479                 });
480             } else {
481                 this.make_link(val, this.$('input.window-new').prop('checked'));
482             }
483             done.then(_super);
484         },
485         bind_data: function () {
486             var href = this.element && (this.element.data( 'cke-saved-href')
487                                     ||  this.element.getAttribute('href'));
488             if (!href) { return; }
489
490             var match, $control;
491             if (match = /(mailto):(.+)/.exec(href)) {
492                 $control = this.$('input.email-address').val(match[2]);
493             } else if(href in this.pages) {
494                 $control = this.$('input.pages').val(this.pages[href]);
495             }
496             if (!$control) {
497                 $control = this.$('input.url').val(href);
498             }
499
500             this.changed($control);
501
502             this.$('input.window-new').prop(
503                 'checked', this.element.getAttribute('target') === '_blank');
504         },
505         changed: function ($e) {
506             $e.closest('li.list-group-item').addClass('active')
507               .siblings().removeClass('active');
508             this.$('.url-source').not($e).val('');
509         },
510         /**
511          * Selected an existing page in dropdown
512          */
513         select_page: function (e) {
514             e.preventDefault();
515             e.stopPropagation();
516             var $target = $(e.target);
517             this.$('input.pages').val($target.text()).change();
518             // No #dropdown('close'), and using #dropdown('toggle') sur
519             // #closest('.dropdown') makes the dropdown not work correctly
520             $target.closest('.open').removeClass('open');
521         },
522         /**
523          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
524          * if the editor is set directly on a link it will thus not work.
525          */
526         get_selected_link: function () {
527             var sel = this.editor.getSelection(),
528                 el = sel.getSelectedElement();
529             if (el && el.is('a')) { return el; }
530
531             var range = sel.getRanges(true)[0];
532             if (!range) { return null; }
533
534             range.shrink(CKEDITOR.SHRINK_TEXT);
535             return this.editor.elementPath(range.getCommonAncestor())
536                               .contains('a');
537
538         },
539         fetch_pages: function () {
540             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
541                 model: 'website',
542                 method: 'list_pages',
543                 args: [],
544                 kwargs: {}
545             });
546         },
547         fill_pages: function (results) {
548             var self = this;
549             var $pages = this.$('div.existing ul').empty();
550             _(results).each(function (result) {
551                 self.pages[result.url] = result.name;
552                 self.pages_by_name[result.name] = result.url;
553                 var $link = $('<a>').attr('href', result.url).text(result.name);
554                 $('<li>').append($link).appendTo($pages);
555             });
556         },
557     });
558     website.editor.ImageDialog = website.editor.Dialog.extend({
559         template: 'website.editor.dialog.image',
560         events: _.extend({}, website.editor.Dialog.prototype.events, {
561             'change .url-source': function (e) { this.changed($(e.target)); },
562             'click button.filepicker': function () {
563                 this.$('input[type=file]').click();
564             },
565             'change input[type=file]': 'file_selection',
566             'change input.url': 'preview_image',
567             'click .existing-attachments a': 'select_existing',
568         }),
569         start: function () {
570             var selection = this.editor.getSelection();
571             var el = selection && selection.getSelectedElement();
572             this.element = null;
573             if (el && el.is('img')) {
574                 this.element = el;
575                 this.set_image(el.getAttribute('src'));
576             }
577
578             return $.when(
579                 this._super(),
580                 this.fetch_existing().then(this.proxy('fetched_existing')));
581         },
582         save: function () {
583             var url = this.$('input.url').val();
584             var element, editor = this.editor;
585             if (!(element = this.element)) {
586                 element = editor.document.createElement('img');
587                 // focus event handler interactions between bootstrap (modal)
588                 // and ckeditor (RTE) lead to blowing the stack in Safari and
589                 // Chrome (but not FF) when this is done synchronously =>
590                 // defer insertion so modal has been hidden & destroyed before
591                 // it happens
592                 setTimeout(function () {
593                     editor.insertElement(element);
594                 }, 0);
595             }
596             element.setAttribute('src', url);
597             this._super();
598         },
599
600         /**
601          * Sets the provided image url as the dialog's value-to-save and
602          * refreshes the preview element to use it.
603          */
604         set_image: function (url) {
605             this.$('input.url').val(url);
606             this.preview_image();
607         },
608
609         file_selection: function (e) {
610             this.$('button.filepicker').removeClass('btn-danger btn-success');
611
612             var self = this;
613             var callback = _.uniqueId('func_');
614             this.$('input[name=func]').val(callback);
615
616             window[callback] = function (url, error) {
617                 delete window[callback];
618                 self.file_selected(url, error);
619             };
620             this.$('form').submit();
621         },
622         file_selected: function(url, error) {
623             var $button = this.$('button.filepicker');
624             if (error) {
625                 $button.addClass('btn-danger');
626                 return;
627             }
628             $button.addClass('btn-success');
629             this.set_image(url);
630         },
631         preview_image: function () {
632             var image = this.$('input.url').val();
633             if (!image) { return; }
634
635             this.$('img.image-preview').attr('src', image);
636         },
637
638         fetch_existing: function () {
639             // FIXME: lazy load attachments?
640             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
641                 model: 'ir.attachment',
642                 method: 'search_read',
643                 args: [],
644                 kwargs: {
645                     fields: ['name'],
646                     domain: [['res_model', '=', 'ir.ui.view']],
647                     order: 'name',
648                 }
649             });
650         },
651         fetched_existing: function (records) {
652             // Create rows of 3 records
653             var rows = _(records).chain()
654                 .groupBy(function (_, index) { return Math.floor(index / 3); })
655                 .values()
656                 .value();
657             this.$('.existing-attachments').replaceWith(
658                 openerp.qweb.render('website.editor.dialog.image.existing', {rows: rows}));
659         },
660         select_existing: function (e) {
661             e.preventDefault();
662             this.set_image(e.currentTarget.getAttribute('href'));
663         },
664     });
665
666
667     var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
668     var OBSERVER_CONFIG = {
669         childList: true,
670         attributes: true,
671         characterData: true,
672         subtree: true,
673         attributeOldValue: true,
674     };
675     var observer = new Observer(function (mutations) {
676         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
677         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
678         //       will not mark dirty on attribute changes (@class, img/@src,
679         //       a/@href, ...)
680         _(mutations).chain()
681                 .filter(function (m) {
682                 switch(m.type) {
683                 case 'attributes': // ignore .cke_focus being added or removed
684                     // if attribute is not a class, can't be .cke_focus change
685                     if (m.attributeName !== 'class') { return true; }
686
687                     // find out what classes were added or removed
688                     var oldClasses = m.oldValue.split(/\s+/);
689                     var newClasses = m.target.className.split(/\s+/);
690                     var change = _.union(_.difference(oldClasses, newClasses),
691                                          _.difference(newClasses, oldClasses));
692                     // ignore mutation if the *only* change is .cke_focus
693                     return change.length !== 1 || change[0] === 'cke_focus';
694                 case 'childList':
695                     // <br type="_moz"> appears when focusing RTE in FF, ignore
696                     return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
697                 default:
698                     return true;
699                 }
700             })
701             .map(function (m) {
702                 var node = m.target;
703                 while (node && !$(node).hasClass('oe_editable')) {
704                     node = node.parentNode;
705                 }
706                 return node;
707             })
708             .compact()
709             .uniq()
710             .each(function (node) { $(node).trigger('content_changed'); })
711     });
712 })();