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