[MERGE] forward port of branch 8.0 up to 591e329
[odoo/odoo.git] / addons / website / static / src / js / website.editor.js
1 (function () {
2     'use strict';
3
4     var website = openerp.website;
5     var _t = openerp._t;
6     website.no_editor = !!$(document.documentElement).data('editable-no-editor');
7
8     website.add_template_file('/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 (!is_smartphone) {
13             website.ready().then(website.init_editor);
14         } else {
15             var resize_smartphone = function () {
16                 is_smartphone = $(document.body)[0].clientWidth < 767;
17                 if (!is_smartphone) {
18                     $(window).off("resize", resize_smartphone);
19                     website.init_editor();
20                 }
21             };
22             $(window).on("resize", resize_smartphone);
23         }
24
25         $(document).on('click', 'a.js_link2post', function (ev) {
26             ev.preventDefault();
27             website.form(this.pathname, 'POST');
28         });
29
30         $(document).on('click', '.cke_editable label', function (ev) {
31             ev.preventDefault();
32         });
33
34         $(document).on('submit', '.cke_editable form', function (ev) {
35             // Disable form submition in editable mode
36             ev.preventDefault();
37         });
38
39         $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
40             // Prevent dropdown closing when a contenteditable children is focused
41             if (ev.originalEvent
42                     && $(ev.target).has(ev.originalEvent.target).length
43                     && $(ev.originalEvent.target).is('[contenteditable]')) {
44                 ev.preventDefault();
45             }
46         });
47     });
48
49     /**
50      * An editing host is an HTML element with @contenteditable=true, or the
51      * child of a document in designMode=on (but that one's not supported)
52      *
53      * https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#editing-host
54      */
55     function is_editing_host(element) {
56         return element.getAttribute('contentEditable') === 'true';
57     }
58     /**
59      * Checks that both the element's content *and the element itself* are
60      * editable: an editing host is considered non-editable because its content
61      * is editable but its attributes should not be considered editable
62      */
63     function is_editable_node(element) {
64         return !(element.data('oe-model') === 'ir.ui.view'
65               || element.data('cke-realelement')
66               || (is_editing_host(element) && element.getAttribute('attributeEditable') !== 'true')
67               || element.isReadOnly());
68     }
69
70     function link_dialog(editor) {
71         return new website.editor.RTELinkDialog(editor).appendTo(document.body);
72     }
73     function image_dialog(editor, image) {
74         return new website.editor.MediaDialog(editor, image).appendTo(document.body);
75     }
76
77     // only enable editors manually
78     CKEDITOR.disableAutoInline = true;
79     // EDIT ALL THE THINGS
80     CKEDITOR.dtd.$editable = _.omit(
81         $.extend({}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline),
82         // well maybe not *all* the things
83         'ul', 'ol', 'li', 'table', 'tr', 'th', 'td');
84     // Disable removal of empty elements on CKEDITOR activation. Empty
85     // elements are used for e.g. support of FontAwesome icons
86     CKEDITOR.dtd.$removeEmpty = {};
87
88
89     website.init_editor = function () {
90         CKEDITOR.plugins.add('customdialogs', {
91             // requires: 'link,image',
92             init: function (editor) {
93                 editor.on('doubleclick', function (evt) {
94                     var element = evt.data.element;
95                     if ((element.is('img') || element.$.className.indexOf(' fa-') != -1) && is_editable_node(element)) {
96                         image_dialog(editor, element);
97                         return;
98                     }
99                     var parent = new CKEDITOR.dom.element(element.$.parentNode);
100                     if (parent.$.className.indexOf('media_iframe_video') != -1 && is_editable_node(parent)) {
101                         image_dialog(editor, parent);
102                         return;
103                     }
104
105                     element = get_selected_link(editor) || evt.data.element;
106                     if (!(element.is('a') && is_editable_node(element))) {
107                         return;
108                     }
109
110                     editor.getSelection().selectElement(element);
111                     link_dialog(editor);
112                 }, null, null, 500);
113
114                 //noinspection JSValidateTypes
115                 editor.addCommand('link', {
116                     exec: function (editor) {
117                         link_dialog(editor);
118                         return true;
119                     },
120                     canUndo: false,
121                     editorFocus: true,
122                     context: 'a',
123                 });
124                 //noinspection JSValidateTypes
125                 editor.addCommand('cimage', {
126                     exec: function (editor) {
127                         image_dialog(editor);
128                         return true;
129                     },
130                     canUndo: false,
131                     editorFocus: true,
132                     context: 'img',
133                 });
134
135                 editor.ui.addButton('Link', {
136                     label: 'Link',
137                     command: 'link',
138                     toolbar: 'links,10',
139                 });
140                 editor.ui.addButton('Image', {
141                     label: 'Image',
142                     command: 'cimage',
143                     toolbar: 'insert,10',
144                 });
145
146                 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
147             }
148         });
149         CKEDITOR.plugins.add( 'tablebutton', {
150             requires: 'panelbutton,floatpanel',
151             init: function( editor ) {
152                 var label = "Table";
153
154                 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
155                     label: label,
156                     title: label,
157                     // use existing 'table' icon
158                     icon: 'table',
159                     modes: { wysiwyg: true },
160                     editorFocus: true,
161                     // panel opens in iframe, @css is CSS file <link>-ed within
162                     // frame document, @attributes are set on iframe itself.
163                     panel: {
164                         css: '/website/static/src/css/editor.css',
165                         attributes: { 'role': 'listbox', 'aria-label': label, },
166                     },
167
168                     onBlock: function (panel, block) {
169                         block.autoSize = true;
170                         block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
171                             rows: 5,
172                             cols: 5,
173                         }));
174
175                         var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
176                             var $e = $(e.target);
177                             var y = $e.index() + 1;
178                             var x = $e.closest('tr').index() + 1;
179
180                             $table
181                                 .find('td').removeClass('selected').end()
182                                 .find('tr:lt(' + String(x) + ')')
183                                 .children().filter(function () { return $(this).index() < y; })
184                                 .addClass('selected');
185                         }).on('click', 'td', function (e) {
186                             var $e = $(e.target);
187
188                             //noinspection JSPotentiallyInvalidConstructorUsage
189                             var table = new CKEDITOR.dom.element(
190                                 $(openerp.qweb.render('website.editor.table', {
191                                     rows: $e.closest('tr').index() + 1,
192                                     cols: $e.index() + 1,
193                                 }))[0]);
194
195                             editor.insertElement(table);
196                             setTimeout(function () {
197                                 //noinspection JSPotentiallyInvalidConstructorUsage
198                                 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
199                                 var range = editor.createRange();
200                                 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
201                                 range.select();
202                             }, 0);
203                         });
204
205                         block.element.getDocument().getBody().setStyle('overflow', 'hidden');
206                         CKEDITOR.ui.fire('ready', this);
207                     },
208                 });
209             }
210         });
211
212         CKEDITOR.plugins.add('customColor', {
213             requires: 'panelbutton,floatpanel',
214             init: function (editor) {
215                 function create_button (buttonID, label) {
216                     var btnID = buttonID;
217                     editor.ui.add(buttonID, CKEDITOR.UI_PANELBUTTON, {
218                         label: label,
219                         title: label,
220                         modes: { wysiwyg: true },
221                         editorFocus: true,
222                         context: 'font',
223                         panel: {
224                             css: [  '/web/css/web.assets_common/' + (new Date().getTime()),
225                                     '/web/css/website.assets_frontend/' + (new Date().getTime()),
226                                     '/web/css/website.assets_editor/' + (new Date().getTime())],
227                             attributes: { 'role': 'listbox', 'aria-label': label },
228                         },
229                         enable: function () {
230                             this.setState(CKEDITOR.TRISTATE_OFF);
231                         },
232                         disable: function () {
233                             this.setState(CKEDITOR.TRISTATE_DISABLED);
234                         },
235                         onBlock: function (panel, block) {
236                             var self = this;
237                             var html = openerp.qweb.render('website.colorpicker');
238                             block.autoSize = true;
239                             block.element.setHtml( html );
240                             $(block.element.$).on('click', 'button', function () {
241                                 self.clicked(this);
242                             });
243                             if (btnID === "TextColor") {
244                                 $(".only-text", block.element.$).css("display", "block");
245                                 $(".only-bg", block.element.$).css("display", "none");
246                             }
247                             var $body = $(block.element.$).parents("body");
248                             setTimeout(function () {
249                                 $body.css('background-color', '#fff');
250                             }, 0);
251                         },
252                         getClasses: function () {
253                             var self = this;
254                             var classes = [];
255                             var id = this._.id;
256                             var block = this._.panel._.panel._.blocks[id];
257                             var $root = $(block.element.$);
258                             $root.find("button").map(function () {
259                                 var color = self.getClass(this);
260                                 if(color) classes.push( color );
261                             });
262                             return classes;
263                         },
264                         getClass: function (button) {
265                             var color = btnID === "BGColor" ? $(button).attr("class") : $(button).attr("class").replace(/^bg-/i, 'text-');
266                             return color.length && color;
267                         },
268                         clicked: function (button) {
269                             var className = this.getClass(button);
270                             var ancestor = editor.getSelection().getCommonAncestor();
271
272                             editor.focus();
273                             this._.panel.hide();
274                             editor.fire('saveSnapshot');
275
276                             // remove style
277                             var classes = [];
278                             var $ancestor = $(ancestor.$);
279                             var $fonts = $(ancestor.$).find('font');
280                             if (!ancestor.$.tagName) {
281                                 $ancestor = $ancestor.parent();
282                             }
283                             if ($ancestor.is('font')) {
284                                 $fonts = $fonts.add($ancestor[0]);
285                             }
286
287                             $fonts.filter("."+this.getClasses().join(",.")).map(function () {
288                                 var className = $(this).attr("class");
289                                 if (classes.indexOf(className) === -1) {
290                                     classes.push(className);
291                                 }
292                             });
293                             for (var k in classes) {
294                                 editor.removeStyle( new CKEDITOR.style({
295                                     element: 'font',
296                                     attributes: { 'class': classes[k] },
297                                 }) );
298                             }
299
300                             // add new style
301                             if (className) {
302                                 editor.applyStyle( new CKEDITOR.style({
303                                     element: 'font',
304                                     attributes: { 'class': className },
305                                 }) );
306                             }
307                             editor.fire('saveSnapshot');
308                         }
309
310                     });
311                 }
312                 create_button("BGColor", "Background Color");
313                 create_button("TextColor", "Text Color");
314             }
315         });
316
317         CKEDITOR.plugins.add('oeref', {
318             requires: 'widget',
319
320             init: function (editor) {
321                 var specials = {
322                     // Can't find the correct ACL rule to only allow img tags
323                     image: { content: '*' },
324                     html: { text: '*' },
325                     monetary: {
326                         text: {
327                             selector: 'span.oe_currency_value',
328                             allowedContent: { }
329                         }
330                     }
331                 };
332                 _(specials).each(function (editable, type) {
333                     editor.widgets.add(type, {
334                         draggable: false,
335                         editables: editable,
336                         upcast: function (el) {
337                             return  el.attributes['data-oe-type'] === type;
338
339                         }
340                     });
341                 });
342                 editor.widgets.add('oeref', {
343                     draggable: false,
344                     editables: {
345                         text: {
346                             selector: '*',
347                             allowedContent: { }
348                         },
349                     },
350                     upcast: function (el) {
351                         var type = el.attributes['data-oe-type'];
352                         if (!type || (type in specials)) {
353                             return false;
354                         }
355                         if (el.attributes['data-oe-original']) {
356                             while (el.children.length) {
357                                 el.children[0].remove();
358                             }
359                             el.add(new CKEDITOR.htmlParser.text(
360                                 el.attributes['data-oe-original']
361                             ));
362                         }
363                         return true;
364                     }
365                 });
366
367                 editor.widgets.add('icons', {
368                     draggable: false,
369
370                     init: function () {
371                         this.on('edit', function () {
372                             new website.editor.MediaDialog(editor, this.element)
373                                 .appendTo(document.body);
374                         });
375                     },
376                     upcast: function (el) {
377                         return el.hasClass('fa')
378                             // ignore ir.ui.view (other data-oe-model should
379                             // already have been matched by oeref and
380                             // monetary?
381                             && !el.attributes['data-oe-model'];
382                     }
383                 });
384             }
385         });
386
387         var editor = new website.EditorBar();
388         var $body = $(document.body);
389         editor.prependTo($body).then(function () {
390             if (location.search.indexOf("enable_editor") >= 0) {
391                 editor.edit();
392             }
393         });
394         website.editor_bar = editor;
395     };
396
397     /* ----- TOP EDITOR BAR FOR ADMIN ---- */
398     website.EditorBar = openerp.Widget.extend({
399         template: 'website.editorbar',
400         events: {
401             'click button[data-action=save]': 'save',
402             'click a[data-action=cancel]': 'cancel',
403         },
404         start: function() {
405             var self = this;
406             this.saving_mutex = new openerp.Mutex();
407
408             this.$buttons = {
409                 edit: this.$el.parents().find('button[data-action=edit]'),
410                 save: this.$('button[data-action=save]'),
411                 cancel: this.$('button[data-action=cancel]'),
412             };
413
414             this.$('#website-top-edit').hide();
415             this.$('#website-top-view').show();
416
417             var $edit_button = this.$buttons.edit
418                     .prop('disabled', website.no_editor);
419             if (website.no_editor) {
420                 var help_text = $(document.documentElement).data('editable-no-editor');
421                 $edit_button.parent()
422                     // help must be set on form above button because it does
423                     // not appear on disabled button
424                     .attr('title', help_text);
425             }
426
427             $('.dropdown-toggle').dropdown();
428
429             this.$buttons.edit.click(function(ev) {
430                 self.edit();
431             });
432
433             this.rte = new website.RTE(this);
434             this.rte.on('change', this, this.proxy('rte_changed'));
435             this.rte.on('rte:ready', this, function () {
436                 self.setup_hover_buttons();
437                 self.trigger('rte:ready');
438             });
439
440             this.rte.appendTo(this.$('#website-top-edit .nav.js_editor_placeholder'));
441             return this._super.apply(this, arguments);
442             
443         },
444         edit: function () {
445             this.$buttons.edit.prop('disabled', true);
446             this.$('#website-top-view').hide();
447             this.$el.show();
448             this.$('#website-top-edit').show();
449             $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
450
451             this.rte.start_edition();
452             this.trigger('rte:called');
453         },
454         rte_changed: function () {
455             this.$buttons.save.prop('disabled', false);
456         },
457         save: function () {
458             var self = this;
459
460             observer.disconnect();
461             var editor = this.rte.editor;
462             var root = editor.element && editor.element.$;
463             try {
464                 editor.destroy();
465             }
466             catch(err) {
467                 // Hack to avoid the lost of all changes because ckeditor fails in destroy
468                 console.log("Error in editor.destroy() : " + err.toString() + "\n  " + err.stack);
469             }
470             // FIXME: select editables then filter by dirty?
471             var defs = this.rte.fetch_editables(root)
472                 .filter('.oe_dirty')
473                 .removeAttr('contentEditable')
474                 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
475                 .map(function () {
476                     var $el = $(this);
477                     // TODO: Add a queue with concurrency limit in webclient
478                     // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
479                     return self.saving_mutex.exec(function () {
480                         return self.saveElement($el)
481                             .then(undefined, function (thing, response) {
482                                 // because ckeditor regenerates all the dom,
483                                 // we can't just setup the popover here as
484                                 // everything will be destroyed by the DOM
485                                 // regeneration. Add markings instead, and
486                                 // returns a new rejection with all relevant
487                                 // info
488                                 var id = _.uniqueId('carlos_danger_');
489                                 $el.addClass('oe_dirty oe_carlos_danger');
490                                 $el.addClass(id);
491                                 return $.Deferred().reject({
492                                     id: id,
493                                     error: response.data,
494                                 });
495                             });
496                     });
497                 }).get();
498             return $.when.apply(null, defs).then(function () {
499                 website.reload();
500             }, function (failed) {
501                 // If there were errors, re-enable edition
502                 self.rte.start_edition(true).then(function () {
503                     // jquery's deferred being a pain in the ass
504                     if (!_.isArray(failed)) { failed = [failed]; }
505
506                     _(failed).each(function (failure) {
507                         var html = failure.error.exception_type === "except_osv";
508                         if (html) {
509                             var msg = $("<div/>").text(failure.error.message).html();
510                             var data = msg.substring(3,msg.length-2).split(/', u'/);
511                             failure.error.message = '<b>' + data[0] + '</b><br/>' + data[1];
512                         }
513                         $(root).find('.' + failure.id)
514                             .removeClass(failure.id)
515                             .popover({
516                                 html: html,
517                                 trigger: 'hover',
518                                 content: failure.error.message,
519                                 placement: 'auto top',
520                             })
521                             // Force-show popovers so users will notice them.
522                             .popover('show');
523                     });
524                 });
525             });
526         },
527         /**
528          * Saves an RTE content, which always corresponds to a view section (?).
529          */
530         saveElement: function ($el) {
531             var markup = $el.prop('outerHTML');
532             return openerp.jsonRpc('/web/dataset/call', 'call', {
533                 model: 'ir.ui.view',
534                 method: 'save',
535                 args: [$el.data('oe-id'), markup,
536                        $el.data('oe-xpath') || null,
537                        website.get_context()],
538             });
539         },
540         cancel: function () {
541             new $.Deferred(function (d) {
542                 var $dialog = $(openerp.qweb.render('website.editor.discard')).appendTo(document.body);
543                 $dialog.on('click', '.btn-danger', function () {
544                     d.resolve();
545                 }).on('hidden.bs.modal', function () {
546                     d.reject();
547                 });
548                 d.always(function () {
549                     $dialog.remove();
550                 });
551                 $dialog.modal('show');
552             }).then(function () {
553                 website.reload();
554             })
555         },
556
557         /**
558          * Creates a "hover" button for link edition
559          *
560          * @param {Function} editfn edition function, called when clicking the button
561          * @returns {jQuery}
562          */
563         make_hover_button_link: function (editfn) {
564             return $(openerp.qweb.render('website.editor.hoverbutton.link', {}))
565                 .hide()
566                 .click(function (e) {
567                     e.preventDefault();
568                     e.stopPropagation();
569                     editfn.call(this, e);
570                 })
571                 .appendTo(document.body);
572         },
573
574         /**
575          * Creates a "hover" button for image
576          *
577          * @param {Function} editfn edition function, called when clicking the button
578          * @param {Function} stylefn edition style function, called when clicking the button
579          * @returns {jQuery}
580          */
581         make_hover_button_image: function (editfn, stylefn) {
582             var $div = $(openerp.qweb.render('website.editor.hoverbutton.media', {}))
583                 .hide()
584                 .appendTo(document.body);
585
586             $div.find('[data-toggle="dropdown"]').dropdown();
587             $div.find(".hover-edition-button").click(function (e) {
588                 e.preventDefault();
589                 e.stopPropagation();
590                 editfn.call(this, e);
591             });
592             if (stylefn) {
593                 $div.find(".hover-style-button").click(function (e) {
594                     e.preventDefault();
595                     e.stopPropagation();
596                     stylefn.call(this, e);
597                 });
598             }
599             return $div;
600         },
601         /**
602          * For UI clarity, during RTE edition when the user hovers links and
603          * images a small button should appear to make the capability clear,
604          * as not all users think of double-clicking the image or link.
605          */
606         setup_hover_buttons: function () {
607             var editor = this.rte.editor;
608             var $link_button = this.make_hover_button_link(function () {
609                 var sel = new CKEDITOR.dom.element(previous);
610                 editor.getSelection().selectElement(sel);
611                 if(sel.hasClass('fa')) {
612                     new website.editor.MediaDialog(editor, previous)
613                         .appendTo(document.body);
614                 } else if (previous.tagName.toUpperCase() === 'A') {
615                     link_dialog(editor);
616                 }
617                 $link_button.hide();
618                 previous = null;
619             });
620
621             function is_icons_widget(element) {
622                 var w = editor.widgets.getByElement(element);
623                 return w && w.name === 'icons';
624             }
625
626             // previous is the state of the button-trigger: it's the
627             // currently-ish hovered element which can trigger a button showing.
628             // -ish, because when moving to the button itself ``previous`` is
629             // still set to the element having triggered showing the button.
630             var previous;
631             $(editor.element.$).on('mouseover', 'a', function () {
632                 // Back from edit button -> ignore
633                 if (previous && previous === this) { return; }
634
635                 // hover button should appear for "editable" links and images
636                 // (img and a nodes whose *attributes* are editable, they
637                 // can not be "editing hosts") *or* for non-editing-host
638                 // elements bearing an ``fa`` class. These should have been
639                 // made into CKE widgets which are editing hosts by
640                 // definition, so instead check if the element has been
641                 // converted/upcasted to an fa widget
642                 var selected = new CKEDITOR.dom.element(this);
643                 if (!(is_editable_node(selected) || is_icons_widget(selected))) {
644                     return;
645                 }
646
647                 previous = this;
648                 var $selected = $(this);
649                 var position = $selected.offset();
650                 $link_button.show().offset({
651                     top: $selected.outerHeight()
652                             + position.top,
653                     left: $selected.outerWidth() / 2
654                             + position.left
655                             - $link_button.outerWidth() / 2
656                 })
657             }).on('mouseleave', 'a, img, .fa', function (e) {
658                 var current = document.elementFromPoint(e.clientX, e.clientY);
659                 if (current === $link_button[0] || $(current).parent()[0] === $link_button[0]) {
660                     return;
661                 }
662                 $link_button.hide();
663                 previous = null;
664             });
665         }
666     });
667     
668     website.EditorBarCustomize = openerp.Widget.extend({
669         events: {
670             'mousedown a.dropdown-toggle': 'load_menu',
671             'click ul a[data-view-id]': 'do_customize',
672         },
673         start: function() {
674             var self = this;
675             this.$menu = self.$el.find('ul');
676             this.view_name = $(document.documentElement).data('view-xmlid');
677             if (!this.view_name) {
678                 this.$el.hide();
679             }
680             this.loaded = false;
681         },
682         load_menu: function () {
683             var self = this;
684             if(this.loaded) {
685                 return;
686             }
687             openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': this.view_name }).then(
688                 function(result) {
689                     _.each(result, function (item) {
690                         if (item.xml_id === "website.debugger" && !window.location.search.match(/[&?]debug(&|$)/)) return;
691                         if (item.header) {
692                             self.$menu.append('<li class="dropdown-header">' + item.name + '</li>');
693                         } else {
694                             self.$menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="fa fa%s-square-o"></strong> %s</a></li>',
695                                 item.id, item.active ? '-check' : '', item.name));
696                         }
697                     });
698                     self.loaded = true;
699                 }
700             );
701         },
702         do_customize: function (event) {
703             var view_id = $(event.currentTarget).data('view-id');
704             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
705                 model: 'ir.ui.view',
706                 method: 'toggle',
707                 args: [],
708                 kwargs: {
709                     ids: [parseInt(view_id, 10)],
710                     context: website.get_context()
711                 }
712             }).then( function() {
713                 window.location.reload();
714             });
715         },
716     });
717
718     $(document).ready(function() {
719         var editorBarCustomize = new website.EditorBarCustomize();
720         editorBarCustomize.setElement($('li[id=customize-menu]'));
721         editorBarCustomize.start();
722     });
723
724     var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
725     /* ----- RICH TEXT EDITOR ---- */
726     website.RTE = openerp.Widget.extend({
727         tagName: 'li',
728         id: 'oe_rte_toolbar',
729         className: 'oe_right oe_rte_toolbar',
730         // editor.ui.items -> possible commands &al
731         // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
732
733         init: function (EditorBar) {
734             this.EditorBar = EditorBar;
735             this._super.apply(this, arguments);
736         },
737
738         /**
739          * In Webkit-based browsers, triple-click will select a paragraph up to
740          * the start of the next "paragraph" including any empty space
741          * inbetween. When said paragraph is removed or altered, it nukes
742          * the empty space and brings part of the content of the next
743          * "paragraph" (which may well be e.g. an image) into the current one,
744          * completely fucking up layouts and breaking snippets.
745          *
746          * Try to fuck around with selections on triple-click to attempt to
747          * fix this garbage behavior.
748          *
749          * Note: for consistent behavior we may actually want to take over
750          * triple-clicks, in all browsers in order to ensure consistent cross-
751          * platform behavior instead of being at the mercy of rendering engines
752          * & platform selection quirks?
753          */
754         webkitSelectionFixer: function (root) {
755             root.addEventListener('click', function (e) {
756                 // only webkit seems to have a fucked up behavior, ignore others
757                 // FIXME: $.browser goes away in jquery 1.9...
758                 if (!$.browser.webkit) { return; }
759                 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
760                 // The detail attribute indicates the number of times a mouse button has been pressed
761                 // we just want the triple click
762                 if (e.detail !== 3) { return; }
763                 e.preventDefault();
764
765                 // Get closest block-level element to the triple-clicked
766                 // element (using ckeditor's block list because why not)
767                 var $closest_block = $(e.target).closest(blocks_selector);
768
769                 // manually set selection range to the content of the
770                 // triple-clicked block-level element, to avoid crossing over
771                 // between block-level elements
772                 document.getSelection().selectAllChildren($closest_block[0]);
773             });
774         },
775         tableNavigation: function (root) {
776             var self = this;
777             $(root).on('keydown', function (e) {
778                 // ignore non-TAB
779                 if (e.which !== 9) { return; }
780
781                 if (self.handleTab(e)) {
782                     e.preventDefault();
783                 }
784             });
785         },
786         /**
787          * Performs whatever operation is necessary on a [TAB] hit, returns
788          * ``true`` if the event's default should be cancelled (if the TAB was
789          * handled by the function)
790          */
791         handleTab: function (event) {
792             var forward = !event.shiftKey;
793
794             var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
795             var $cell = $(root).closest('td,th');
796
797             if (!$cell.length) { return false; }
798
799             var cell = $cell[0];
800
801             // find cell in same row
802             var row = cell.parentNode;
803             var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
804             if (sibling) {
805                 document.getSelection().selectAllChildren(sibling);
806                 return true;
807             }
808
809             // find cell in previous/next row
810             var table = row.parentNode;
811             var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
812             if (sibling_row) {
813                 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
814                 document.getSelection().selectAllChildren(new_cell);
815                 return true;
816             }
817
818             // at edge cells, copy word/openoffice behavior: if going backwards
819             // from first cell do nothing, if going forwards from last cell add
820             // a row
821             if (forward) {
822                 var row_size = row.cells.length;
823                 var new_row = document.createElement('tr');
824                 while(row_size--) {
825                     var newcell = document.createElement('td');
826                     // zero-width space
827                     newcell.textContent = '\u200B';
828                     new_row.appendChild(newcell);
829                 }
830                 table.appendChild(new_row);
831                 document.getSelection().selectAllChildren(new_row.cells[0]);
832             }
833
834             return true;
835         },
836         /**
837          * Makes the page editable
838          *
839          * @param {Boolean} [restart=false] in case the edition was already set
840          *                                  up once and is being re-enabled.
841          * @returns {$.Deferred} deferred indicating when the RTE is ready
842          */
843         start_edition: function (restart) {
844             var self = this;
845             // create a single editor for the whole page
846             var root = document.getElementById('wrapwrap');
847             if (!restart) {
848                 $(root).on('dragstart', 'img', function (e) {
849                     e.preventDefault();
850                 });
851                 this.webkitSelectionFixer(root);
852                 this.tableNavigation(root);
853             }
854             var def = $.Deferred();
855             var editor = this.editor = CKEDITOR.inline(root, self._config());
856             editor.on('instanceReady', function () {
857                 $("[data-oe-type=selection]").attr("contenteditable",false);
858                 editor.setReadOnly(false);
859                 // ckeditor set root to editable, disable it (only inner
860                 // sections are editable)
861                 // FIXME: are there cases where the whole editor is editable?
862                 editor.editable().setReadOnly(true);
863
864                 self.setup_editables(root);
865
866                 try {
867                     // disable firefox's broken table resizing thing
868                     document.execCommand("enableObjectResizing", false, "false");
869                     document.execCommand("enableInlineTableEditing", false, "false");
870                 } catch (e) {}
871
872                 // detect & setup any CKEDITOR widget within a newly dropped
873                 // snippet. There does not seem to be a simple way to do it for
874                 // HTML not inserted via ckeditor APIs:
875                 // https://dev.ckeditor.com/ticket/11472
876                 $(document.body)
877                     .off('snippet-dropped')
878                     .on('snippet-dropped', function (e, el) {
879                         // CKEDITOR data processor extended by widgets plugin
880                         // to add wrappers around upcasting elements
881                         el.innerHTML = editor.dataProcessor.toHtml(el.innerHTML, {
882                             fixForBody: false,
883                             dontFilter: true,
884                         });
885                         // then repository.initOnAll() handles the conversion
886                         // from wrapper to actual widget instance (or something
887                         // like that).
888                         setTimeout(function () {
889                             editor.widgets.initOnAll();
890                         }, 0);
891                     });
892
893                 self.trigger('rte:ready');
894                 def.resolve();
895             });
896             return def;
897         },
898
899         setup_editables: function (root) {
900             // selection of editable sub-items was previously in
901             // EditorBar#edit, but for some unknown reason the elements were
902             // apparently removed and recreated (?) at editor initalization,
903             // and observer setup was lost.
904             var self = this;
905             // setup dirty-marking for each editable element
906             this.fetch_editables(root)
907                 .addClass('oe_editable')
908                 .each(function () {
909                     var node = this;
910                     var $node = $(node);
911                     // only explicitly set contenteditable on view sections,
912                     // cke widgets system will do the widgets themselves
913                     if ($node.data('oe-model') === 'ir.ui.view') {
914                         node.contentEditable = true;
915                     }
916
917                     observer.observe(node, OBSERVER_CONFIG);
918                     $node.one('content_changed', function () {
919                         $node.addClass('oe_dirty');
920                         self.trigger('change');
921                     });
922                 });
923         },
924
925         fetch_editables: function (root) {
926             return $(root).find('[data-oe-model]')
927                 .not('[data-oe-type = "selection"]')
928                 .not('link, script')
929                 .not('.oe_snippet_editor');
930         },
931
932         _current_editor: function () {
933             return CKEDITOR.currentInstance;
934         },
935         _config: function () {
936             // base plugins minus
937             // - magicline (captures mousein/mouseout -> breaks draggable)
938             // - contextmenu & tabletools (disable contextual menu)
939             // - bunch of unused plugins
940             var plugins = [
941                 'a11yhelp', 'basicstyles', 'blockquote',
942                 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
943                 'elementspath', /*'enterkey',*/ 'entities', 'filebrowser',
944                 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
945                 'indentblock', 'indentlist', 'justify',
946                 'list', 'pastefromword', 'pastetext', 'preview',
947                 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
948                 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
949             ];
950             return {
951                 // FIXME
952                 language: 'en',
953                 // Disable auto-generated titles
954                 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
955                 title: false,
956                 plugins: plugins.join(','),
957                 uiColor: '',
958                 // FIXME: currently breaks RTE?
959                 // Ensure no config file is loaded
960                 customConfig: '',
961                 // Disable ACF
962                 allowedContent: true,
963                 // Don't insert paragraphs around content in e.g. <li>
964                 autoParagraph: false,
965                 // Don't automatically add &nbsp; or <br> in empty block-level
966                 // elements when edition starts
967                 fillEmptyBlocks: false,
968                 filebrowserImageUploadUrl: "/website/attach",
969                 // Support for sharedSpaces in 4.x
970                 extraPlugins: 'customColor,sharedspace,customdialogs,tablebutton,oeref',
971                 // Place toolbar in controlled location
972                 sharedSpaces: { top: 'oe_rte_toolbar' },
973                 toolbar: [{
974                         name: 'basicstyles', items: [
975                         "Bold", "Italic", "Underline", "Strike", "Subscript",
976                         "Superscript", "TextColor", "BGColor", "RemoveFormat"
977                     ]},{
978                     name: 'span', items: [
979                         "Link", "Blockquote", "BulletedList",
980                         "NumberedList", "Indent", "Outdent"
981                     ]},{
982                     name: 'justify', items: [
983                         "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
984                     ]},{
985                     name: 'special', items: [
986                         "Image", "TableButton"
987                     ]},{
988                     name: 'styles', items: [
989                         "Styles"
990                     ]}
991                 ],
992                 // styles dropdown in toolbar
993                 stylesSet: [
994                     {name: "Normal", element: 'p'},
995                     {name: "Heading 1", element: 'h1'},
996                     {name: "Heading 2", element: 'h2'},
997                     {name: "Heading 3", element: 'h3'},
998                     {name: "Heading 4", element: 'h4'},
999                     {name: "Heading 5", element: 'h5'},
1000                     {name: "Heading 6", element: 'h6'},
1001                     {name: "Formatted", element: 'pre'},
1002                     {name: "Address", element: 'address'},
1003                 ],
1004             };
1005         },
1006     });
1007
1008     website.editor = { };
1009     website.editor.Dialog = openerp.Widget.extend({
1010         events: {
1011             'hidden.bs.modal': 'destroy',
1012             'click button.save': 'save',
1013             'click button[data-dismiss="modal"]': 'cancel',
1014         },
1015         init: function (editor) {
1016             this._super();
1017             this.editor = editor;
1018         },
1019         start: function () {
1020             var sup = this._super();
1021             this.$el.modal({backdrop: 'static'});
1022             this.$('input:first').focus();
1023             return sup;
1024         },
1025         save: function () {
1026             this.close();
1027             this.trigger("saved");
1028         },
1029         cancel: function () {
1030             this.trigger("cancel");
1031         },
1032         close: function () {
1033             this.$el.modal('hide');
1034         },
1035     });
1036
1037     website.editor.LinkDialog = website.editor.Dialog.extend({
1038         template: 'website.editor.dialog.link',
1039         events: _.extend({}, website.editor.Dialog.prototype.events, {
1040             'change :input.url-source': 'changed',
1041             'keyup :input.url': 'onkeyup',
1042             'keyup :input': 'preview',
1043             'mousedown': function (e) {
1044                 var $target = $(e.target).closest('.list-group-item:has(.url-source)');
1045                 if (!$target.length || $target.hasClass('active')) {
1046                     // clicked outside groups, or clicked in active groups
1047                     return;
1048                 }
1049                 $target.find("input.url-source").change();
1050             },
1051             'click button.remove': 'remove_link',
1052             'change input#link-text': function (e) {
1053                 this.text = $(e.target).val();
1054             },
1055             'change .link-style': function (e) {
1056                 this.preview();
1057             },
1058         }),
1059         init: function (editor) {
1060             this._super(editor);
1061             this.text = null;
1062             // Store last-performed request to be able to cancel/abort it.
1063             this.page_exists_req = null;
1064             this.search_pages_req = null;
1065         },
1066         start: function () {
1067             var self = this;
1068             var last;
1069             this.$('#link-page').select2({
1070                 minimumInputLength: 1,
1071                 placeholder: _t("New or existing page"),
1072                 query: function (q) {
1073                     if (q.term == last) return;
1074                     last = q.term;
1075                     $.when(
1076                         self.page_exists(q.term),
1077                         self.fetch_pages(q.term)
1078                     ).then(function (exists, results) {
1079                         var rs = _.map(results, function (r) {
1080                             return { id: r.loc, text: r.loc, };
1081                         });
1082                         if (!exists) {
1083                             rs.push({
1084                                 create: true,
1085                                 id: q.term,
1086                                 text: _.str.sprintf(_t("Create page '%s'"), q.term),
1087                             });
1088                         }
1089                         q.callback({
1090                             more: false,
1091                             results: rs
1092                         });
1093                     }, function () {
1094                         q.callback({more: false, results: []});
1095                     });
1096                 },
1097             });
1098             return this._super().then(this.proxy('bind_data'));
1099         },
1100         get_data: function (test) {
1101             var self = this,
1102                 def = new $.Deferred(),
1103                 $e = this.$('.active input.url-source').filter(':input'),
1104                 val = $e.val(),
1105                 label = this.$('#link-text').val() || val;
1106
1107             if (test !== false && (!val || !$e[0].checkValidity())) {
1108                 // FIXME: error message
1109                 $e.closest('.form-group').addClass('has-error');
1110                 $e.focus();
1111                 def.reject();
1112             }
1113
1114             var style = this.$("input[name='link-style-type']:checked").val();
1115             var size = this.$("input[name='link-style-size']:checked").val();
1116             var classes = (style && style.length ? "btn " : "") + style + " " + size;
1117
1118             if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) {
1119                 def.resolve('mailto:' + val, false, label, classes);
1120             } else if ($e.val() && $e.val().length && $e.hasClass('page')) {
1121                 var data = $e.select2('data');
1122                 if (!data.create) {
1123                     def.resolve(data.id, false, label || data.text, classes);
1124                 } else {
1125                     // Create the page, get the URL back
1126                     $.get(_.str.sprintf(
1127                             '/website/add/%s?noredirect=1', encodeURI(data.id)))
1128                         .then(function (response) {
1129                             def.resolve(response, false, data.id, classes);
1130                         });
1131                 }
1132             } else {
1133                 def.resolve(val, this.$('input.window-new').prop('checked'), label, classes);
1134             }
1135             return def;
1136         },
1137         save: function () {
1138             var self = this;
1139             var _super = this._super.bind(this);
1140             return this.get_data()
1141                 .then(function (url, new_window, label, classes) {
1142                     self.make_link(url, new_window, label, classes);
1143                 }).then(_super);
1144         },
1145         make_link: function (url, new_window, label, classes) {
1146         },
1147         bind_data: function () {
1148             var self = this;
1149             var href = this.element && (this.element.data( 'cke-saved-href')
1150                                     ||  this.element.getAttribute('href'));
1151             var new_window = this.element
1152                         ? this.element.getAttribute('target') === '_blank'
1153                         : false;
1154             var text = this.element ? this.element.getText() : '';
1155             if (!text.length) {
1156                 if (this.editor) {
1157                     text = this.editor.getSelection().getSelectedText();
1158                 } else {
1159                     text = this.data.name;
1160                     href = this.data.url;
1161                     new_window = this.data.new_window;
1162                 }
1163             }
1164
1165             this.$('input#link-text').val(text);
1166             this.$('input.window-new').prop('checked', new_window);
1167
1168             var classes = this.element && this.element.$.className;
1169             if (classes) {
1170                 this.$('input[value!=""]').each(function () {
1171                     var $option = $(this);
1172                     if (classes.indexOf($option.val()) !== -1) {
1173                         $option.attr("checked", "checked");
1174                     }
1175                 });
1176             }
1177
1178             var match, $control;
1179             if (href && (match = /mailto:(.+)/.exec(href))) {
1180                 this.$('input.email-address').val(match[1]).change();
1181             }
1182             if (href && !$control) {
1183                 this.page_exists(href).then(function (exist) {
1184                     if (exist) {
1185                         self.$('#link-page').select2('data', {'id': href, 'text': href});
1186                     } else {
1187                         self.$('input.url').val(href).change();
1188                         self.$('input.window-new').closest("div").show();
1189                     }
1190                 });
1191             }
1192             this.preview();
1193         },
1194         changed: function (e) {
1195             var $e = $(e.target);
1196             this.$('.url-source').filter(':input').not($e).val('')
1197                     .filter(function () { return !!$(this).data('select2'); })
1198                     .select2('data', null);
1199             $e.closest('.list-group-item')
1200                 .addClass('active')
1201                 .siblings().removeClass('active')
1202                 .addBack().removeClass('has-error');
1203             this.preview();
1204         },
1205         call: function (method, args, kwargs) {
1206             var self = this;
1207             var req = method + '_req';
1208
1209             if (this[req]) { this[req].abort(); }
1210
1211             return this[req] = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1212                 model: 'website',
1213                 method: method,
1214                 args: args,
1215                 kwargs: kwargs,
1216             }).always(function () {
1217                 self[req] = null;
1218             });
1219         },
1220         page_exists: function (term) {
1221             return this.call('page_exists', [null, term], {
1222                 context: website.get_context(),
1223             });
1224         },
1225         fetch_pages: function (term) {
1226             return this.call('search_pages', [null, term], {
1227                 limit: 9,
1228                 context: website.get_context(),
1229             });
1230         },
1231         onkeyup: function (e) {
1232             var $e = $(e.target);
1233             var is_link = ($e.val()||'').length && $e.val().indexOf("@") === -1;
1234             this.$('input.window-new').closest("div").toggle(is_link);
1235             this.preview();
1236         },
1237         preview: function () {
1238             var $preview = this.$("#link-preview");
1239             this.get_data(false).then(function (url, new_window, label, classes) {
1240                 $preview.attr("target", new_window ? '_blank' : "")
1241                     .text((label && label.length ? label : url))
1242                     .attr("class", classes);
1243             });
1244         }
1245     });
1246     website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
1247         start: function () {
1248             var element;
1249             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
1250                 this.editor.getSelection().selectElement(element);
1251             }
1252             this.element = element;
1253             if (element) {
1254                 this.add_removal_button();
1255             }
1256
1257             return this._super();
1258         },
1259         add_removal_button: function () {
1260             this.$('.modal-footer').prepend(
1261                 openerp.qweb.render(
1262                     'website.editor.dialog.link.footer-button'));
1263         },
1264         remove_link: function () {
1265             var editor = this.editor;
1266             // same issue as in make_link
1267             setTimeout(function () {
1268                 editor.removeStyle(new CKEDITOR.style({
1269                     element: 'a',
1270                     type: CKEDITOR.STYLE_INLINE,
1271                     alwaysRemoveElement: true,
1272                 }));
1273             }, 0);
1274             this.close();
1275         },
1276         /**
1277          * Greatly simplified version of CKEDITOR's
1278          * plugins.link.dialogs.link.onOk.
1279          *
1280          * @param {String} url
1281          * @param {Boolean} [new_window=false]
1282          * @param {String} [label=null]
1283          */
1284         make_link: function (url, new_window, label, classes) {
1285             var attributes = {href: url, 'data-cke-saved-href': url};
1286             var to_remove = [];
1287             if (new_window) {
1288                 attributes['target'] = '_blank';
1289             } else {
1290                 to_remove.push('target');
1291             }
1292             if (classes && classes.length) {
1293                 attributes['class'] = classes;
1294             }
1295
1296             if (this.element) {
1297                 this.element.setAttributes(attributes);
1298                 this.element.removeAttributes(to_remove);
1299                 if (this.text) { this.element.setText(this.text); }
1300             } else {
1301                 var selection = this.editor.getSelection();
1302                 var range = selection.getRanges(true)[0];
1303
1304                 if (range.collapsed) {
1305                     //noinspection JSPotentiallyInvalidConstructorUsage
1306                     var text = new CKEDITOR.dom.text(
1307                         this.text || label || url);
1308                     range.insertNode(text);
1309                     range.selectNodeContents(text);
1310                 }
1311
1312                 //noinspection JSPotentiallyInvalidConstructorUsage
1313                 new CKEDITOR.style({
1314                     type: CKEDITOR.STYLE_INLINE,
1315                     element: 'a',
1316                     attributes: attributes,
1317                 }).applyToRange(range);
1318
1319                 // focus dance between RTE & dialog blow up the stack in Safari
1320                 // and Chrome, so defer select() until dialog has been closed
1321                 setTimeout(function () {
1322                     range.select();
1323                 }, 0);
1324             }
1325         },
1326         /**
1327          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1328          * if the editor is set directly on a link it will thus not work.
1329          */
1330         get_selected_link: function () {
1331             return get_selected_link(this.editor);
1332         },
1333     });
1334
1335     website.editor.Media = openerp.Widget.extend({
1336         init: function (parent, editor, media) {
1337             this._super();
1338             this.parent = parent;
1339             this.editor = editor;
1340             this.media = media;
1341         },
1342         start: function () {
1343             this.$preview = this.$('.preview-container').detach();
1344             return this._super();
1345         },
1346         search: function (needle) {
1347         },
1348         save: function () {
1349         },
1350         clear: function () {
1351         },
1352         cancel: function () {
1353         },
1354         close: function () {
1355         },
1356     });
1357     website.editor.MediaDialog = website.editor.Dialog.extend({
1358         template: 'website.editor.dialog.media',
1359         events : _.extend({}, website.editor.Dialog.prototype.events, {
1360             'input input#icon-search': 'search',
1361         }),
1362
1363         init: function (editor, media) {
1364             this._super(editor);
1365             this.editor = editor;
1366             this.page = 0;
1367             this.media = media;
1368         },
1369         start: function () {
1370             var self = this;
1371
1372             if (this.editor.getSelection) {
1373                 var selection = this.editor.getSelection();
1374                 this.range = selection.getRanges(true)[0];
1375             }
1376
1377             this.imageDialog = new website.editor.RTEImageDialog(this, this.editor, this.media);
1378             this.imageDialog.appendTo(this.$("#editor-media-image"));
1379             this.iconDialog = new website.editor.FontIconsDialog(this, this.editor, this.media);
1380             this.iconDialog.appendTo(this.$("#editor-media-icon"));
1381             this.videoDialog = new website.editor.VideoDialog(this, this.editor, this.media);
1382             this.videoDialog.appendTo(this.$("#editor-media-video"));
1383
1384             this.active = this.imageDialog;
1385
1386             $('a[data-toggle="tab"]').on('shown.bs.tab', function (event) {
1387                 if ($(event.target).is('[href="#editor-media-image"]')) {
1388                     self.active = self.imageDialog;
1389                     self.$('li.search, li.previous, li.next').removeClass("hidden");
1390                 } else if ($(event.target).is('[href="#editor-media-icon"]')) {
1391                     self.active = self.iconDialog;
1392                     self.$('li.search, li.previous, li.next').removeClass("hidden");
1393                     self.$('.nav-tabs li.previous, .nav-tabs li.next').addClass("hidden");
1394                 } else if ($(event.target).is('[href="#editor-media-video"]')) {
1395                     self.active = self.videoDialog;
1396                     self.$('.nav-tabs li.search').addClass("hidden");
1397                 }
1398             });
1399
1400             if (this.media) {
1401                 if (this.media.$.nodeName === "IMG") {
1402                     this.$('[href="#editor-media-image"]').tab('show');
1403                 } else if (this.media.$.className.match(/(^|\s)media_iframe_video($|\s)/)) {
1404                     this.$('[href="#editor-media-video"]').tab('show');
1405                 } else if (this.media.$.className.match(/(^|\s)fa($|\s)/)) {
1406                     this.$('[href="#editor-media-icon"]').tab('show');
1407                 }
1408
1409                 if ($(this.media.$).parent().data("oe-field") === "image") {
1410                     this.$('[href="#editor-media-video"], [href="#editor-media-icon"]').addClass('hidden');
1411                 }
1412             }
1413
1414             return this._super();
1415         },
1416         save: function () {
1417             var self = this;
1418             if (self.media) {
1419                 this.media.$.innerHTML = "";
1420                 if (this.active !== this.imageDialog) {
1421                     this.imageDialog.clear();
1422                 }
1423                 if (this.active !== this.iconDialog) {
1424                     this.iconDialog.clear();
1425                 }
1426                 if (this.active !== this.videoDialog) {
1427                     this.videoDialog.clear();
1428                 }
1429             } else {
1430                 this.media = new CKEDITOR.dom.element("img");
1431                 self.range.insertNode(this.media);
1432                 self.range.selectNodeContents(this.media);
1433                 this.active.media = this.media;
1434             }
1435
1436             var $el = $(self.active.media.$);
1437
1438             this.active.save();
1439
1440             this.media.$.className = this.media.$.className.replace(/\s+/g, ' ');
1441
1442             setTimeout(function () {
1443                 if(self.range) self.range.select();
1444                 $el.trigger("saved", self.active.media.$);
1445                 $(document.body).trigger("media-saved", [$el[0], self.active.media.$]);
1446             },0);
1447
1448             this._super();
1449         },
1450         searchTimer: null,
1451         search: function () {
1452             var self = this;
1453             var needle = this.$("input#icon-search").val();
1454             clearTimeout(this.searchTimer);
1455             this.searchTimer = setTimeout(function () {
1456                 self.active.search(needle || "");
1457             },250);
1458         }
1459     });
1460
1461     /**
1462      * ImageDialog widget. Lets users change an image, including uploading a
1463      * new image in OpenERP or selecting the image style (if supported by
1464      * the caller).
1465      *
1466      * Initialized as usual, but the caller can hook into two events:
1467      *
1468      * @event start({url, style}) called during dialog initialization and
1469      *                            opening, the handler can *set* the ``url``
1470      *                            and ``style`` properties on its parameter
1471      *                            to provide these as default values to the
1472      *                            dialog
1473      * @event save({url, style}) called during dialog finalization, the handler
1474      *                           is provided with the image url and style
1475      *                           selected by the users (or possibly the ones
1476      *                           originally passed in)
1477      */
1478     var IMAGES_PER_ROW = 6;
1479     var IMAGES_ROWS = 2;
1480     website.editor.ImageDialog = website.editor.Media.extend({
1481         template: 'website.editor.dialog.image',
1482         events: _.extend({}, website.editor.Dialog.prototype.events, {
1483             'change .url-source': function (e) {
1484                 this.changed($(e.target));
1485             },
1486             'click button.filepicker': function () {
1487                 var filepicker = this.$('input[type=file]');
1488                 if (!_.isEmpty(filepicker)){
1489                     filepicker[0].click();
1490                 }
1491             },
1492             'click .js_disable_optimization': function () {
1493                 this.$('input[name="disable_optimization"]').val('1');
1494                 var filepicker = this.$('button.filepicker');
1495                 if (!_.isEmpty(filepicker)){
1496                     filepicker[0].click();
1497                 }
1498             },
1499             'change input[type=file]': 'file_selection',
1500             'submit form': 'form_submit',
1501             'change input.url': "change_input",
1502             'keyup input.url': "change_input",
1503             //'change select.image-style': 'preview_image',
1504             'click .existing-attachments img': 'select_existing',
1505             'click .existing-attachment-remove': 'try_remove',
1506         }),
1507
1508         init: function (parent, editor, media) {
1509             this.page = 0;
1510             this._super(parent, editor, media);
1511         },
1512         start: function () {
1513             var self = this;
1514             var res = this._super();
1515
1516             var o = { url: null };
1517             // avoid typos, prevent addition of new properties to the object
1518             Object.preventExtensions(o);
1519             this.trigger('start', o);
1520
1521             this.parent.$(".pager > li").click(function (e) {
1522                 e.preventDefault();
1523                 var $target = $(e.currentTarget);
1524                 if ($target.hasClass('disabled')) {
1525                     return;
1526                 }
1527                 self.page += $target.hasClass('previous') ? -1 : 1;
1528                 self.display_attachments();
1529             });
1530
1531             this.set_image(o.url);
1532
1533             return res;
1534         },
1535         save: function () {
1536             if (!this.link) {
1537                 this.link = this.$(".existing-attachments img:first").attr('src');
1538             }
1539             this.trigger('save', {
1540                 url: this.link
1541             });
1542             this.media.renameNode("img");
1543             $(this.media).attr('src', this.link);
1544             return this._super();
1545         },
1546         clear: function () {
1547             this.media.$.className = this.media.$.className.replace(/(^|\s)(img(\s|$)|img-[^\s]*)/g, ' ');
1548         },
1549         cancel: function () {
1550             this.trigger('cancel');
1551         },
1552
1553         change_input: function (e) {
1554             var $input = $(e.target);
1555             var $button = $input.parent().find("button");
1556             if ($input.val() === "") {
1557                 $button.addClass("btn-default").removeClass("btn-primary");
1558             } else {
1559                 $button.removeClass("btn-default").addClass("btn-primary");
1560             }
1561         },
1562
1563         search: function (needle) {
1564             var self = this;
1565             this.fetch_existing(needle).then(function () {
1566                 self.selected_existing(self.$('input.url').val());
1567             });
1568         },
1569
1570         set_image: function (url, error) {
1571             var self = this;
1572             if (url) this.link = url;
1573             this.$('input.url').val('');
1574             this.fetch_existing().then(function () {
1575                 self.selected_existing(url);
1576             });
1577         },
1578
1579         form_submit: function (event) {
1580             var self = this;
1581             var $form = this.$('form[action="/website/attach"]');
1582             if (!$form.find('input[name="upload"]').val().length) {
1583                 var url = $form.find('input[name="url"]').val();
1584                 if (this.selected_existing(url).size()) {
1585                     event.preventDefault();
1586                     return false;
1587                 }
1588             }
1589             var callback = _.uniqueId('func_');
1590             this.$('input[name=func]').val(callback);
1591             window[callback] = function (attachments, error) {
1592                 delete window[callback];
1593                 self.file_selected(attachments[0]['website_url'], error);
1594             };
1595         },
1596         file_selection: function () {
1597             this.$el.addClass('nosave');
1598             this.$('form').removeClass('has-error').find('.help-block').empty();
1599             this.$('button.filepicker').removeClass('btn-danger btn-success');
1600             this.$('form').submit();
1601         },
1602         file_selected: function(url, error) {
1603             var $button = this.$('button.filepicker');
1604             if (!error) {
1605                 $button.addClass('btn-success');
1606             } else {
1607                 url = null;
1608                 this.$('form').addClass('has-error')
1609                     .find('.help-block').text(error);
1610                 $button.addClass('btn-danger');
1611             }
1612             this.set_image(url, error);
1613             // auto save and close popup
1614             this.parent.save();
1615         },
1616
1617         fetch_existing: function (needle) {
1618             var domain = [['res_model', '=', 'ir.ui.view'], '|',
1619                         ['mimetype', '=', false], ['mimetype', '=like', 'image/%']];
1620             if (needle && needle.length) {
1621                 domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]);
1622             }
1623             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1624                 model: 'ir.attachment',
1625                 method: 'search_read',
1626                 args: [],
1627                 kwargs: {
1628                     fields: ['name', 'website_url'],
1629                     domain: domain,
1630                     order: 'id desc',
1631                     context: website.get_context(),
1632                 }
1633             }).then(this.proxy('fetched_existing'));
1634         },
1635         fetched_existing: function (records) {
1636             this.records = records;
1637             this.display_attachments();
1638         },
1639         display_attachments: function () {
1640             this.$('.help-block').empty();
1641             var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1642
1643             var from = this.page * per_screen;
1644             var records = this.records;
1645
1646             // Create rows of 3 records
1647             var rows = _(records).chain()
1648                 .slice(from, from + per_screen)
1649                 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1650                 .values()
1651                 .value();
1652
1653             this.$('.existing-attachments').replaceWith(
1654                 openerp.qweb.render(
1655                     'website.editor.dialog.image.existing.content', {rows: rows}));
1656             this.parent.$('.pager')
1657                 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1658                 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1659         },
1660         select_existing: function (e) {
1661             var link = $(e.currentTarget).attr('src');
1662             this.link = link;
1663             this.selected_existing(link);
1664         },
1665         selected_existing: function (link) {
1666             this.$('.existing-attachment-cell.media_selected').removeClass("media_selected");
1667             var $select = this.$('.existing-attachment-cell img').filter(function () {
1668                 return $(this).attr("src") == link;
1669             }).first();
1670             $select.parent().addClass("media_selected");
1671             return $select;
1672         },
1673
1674         try_remove: function (e) {
1675             var $help_block = this.$('.help-block').empty();
1676             var self = this;
1677             var $a = $(e.target);
1678             var id = parseInt($a.data('id'), 10);
1679             var attachment = _.findWhere(this.records, {id: id});
1680             var $both = $a.parent().children();
1681
1682             $both.css({borderWidth: "5px", borderColor: "#f00"});
1683
1684             return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1685                 model: 'ir.attachment',
1686                 method: 'try_remove',
1687                 args: [],
1688                 kwargs: {
1689                     ids: [id],
1690                     context: website.get_context()
1691                 }
1692             }).then(function (prevented) {
1693                 if (_.isEmpty(prevented)) {
1694                     self.records = _.without(self.records, attachment);
1695                     self.display_attachments();
1696                     return;
1697                 }
1698                 $both.css({borderWidth: "", borderColor: ""});
1699                 $help_block.replaceWith(openerp.qweb.render(
1700                     'website.editor.dialog.image.existing.error', {
1701                         views: prevented[id]
1702                     }
1703                 ));
1704             });
1705         },
1706     });
1707
1708     website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1709         init: function (parent, editor, media) {
1710             this._super(parent, editor, media);
1711
1712             this.on('start', this, this.proxy('started'));
1713             this.on('save', this, this.proxy('saved'));
1714         },
1715         started: function (holder) {
1716             if (!this.media) {
1717                 var selection = this.editor.getSelection();
1718                 this.media = selection && selection.getSelectedElement();
1719             }
1720
1721             var el = this.media;
1722             if (!el || !el.is('img')) {
1723                 return;
1724             }
1725             holder.url = el.getAttribute('src');
1726         },
1727         saved: function (data) {
1728             var element, editor = this.editor;
1729             if (!(element = this.media)) {
1730                 element = editor.document.createElement('img');
1731                 element.addClass('img');
1732                 element.addClass('img-responsive');
1733                 // focus event handler interactions between bootstrap (modal)
1734                 // and ckeditor (RTE) lead to blowing the stack in Safari and
1735                 // Chrome (but not FF) when this is done synchronously =>
1736                 // defer insertion so modal has been hidden & destroyed before
1737                 // it happens
1738                 setTimeout(function () {
1739                     editor.insertElement(element);
1740                 }, 0);
1741             }
1742
1743             var style = data.style;
1744             element.setAttribute('src', data.url);
1745             element.removeAttribute('data-cke-saved-src');
1746             if (style) { element.addClass(style); }
1747         },
1748     });
1749
1750     function get_selected_link(editor) {
1751         var sel = editor.getSelection(),
1752             el = sel.getSelectedElement();
1753         if (el && el.is('a')) { return el; }
1754
1755         var range = sel.getRanges(true)[0];
1756         if (!range) { return null; }
1757
1758         range.shrink(CKEDITOR.SHRINK_TEXT);
1759         var commonAncestor = range.getCommonAncestor();
1760         var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1761             return element.data('oe-model') === 'ir.ui.view';
1762         });
1763         if (!viewRoot) { return null; }
1764         // if viewRoot is the first link, don't edit it.
1765         return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1766                 .contains('a', true);
1767     }
1768
1769     website.editor.FontIconsDialog = website.editor.Media.extend({
1770         template: 'website.editor.dialog.font-icons',
1771         events : _.extend({}, website.editor.Dialog.prototype.events, {
1772             change: 'update_preview',
1773             'click .font-icons-icon': function (e) {
1774                 e.preventDefault();
1775                 e.stopPropagation();
1776
1777                 this.$('#fa-icon').val(e.target.getAttribute('data-id'));
1778                 this.update_preview();
1779             },
1780             'click #fa-preview span': function (e) {
1781                 e.preventDefault();
1782                 e.stopPropagation();
1783
1784                 this.$('#fa-size').val(e.target.getAttribute('data-size'));
1785                 this.update_preview();
1786             },
1787         }),
1788
1789         // List of FontAwesome icons in 4.0.3, extracted from the cheatsheet.
1790         // Each icon provides the unicode codepoint as ``text`` and the class
1791         // name as ``id`` so the whole thing can be fed directly to select2
1792         // without post-processing and do the right thing (except for the part
1793         // where we still need to implement ``initSelection``)
1794         // TODO: add id/name to the text in order to allow FAYT selection of icons?
1795         icons: [{"text": "\uf000", "id": "fa-glass"}, {"text": "\uf001", "id": "fa-music"}, {"text": "\uf002", "id": "fa-search"}, {"text": "\uf003", "id": "fa-envelope-o"}, {"text": "\uf004", "id": "fa-heart"}, {"text": "\uf005", "id": "fa-star"}, {"text": "\uf006", "id": "fa-star-o"}, {"text": "\uf007", "id": "fa-user"}, {"text": "\uf008", "id": "fa-film"}, {"text": "\uf009", "id": "fa-th-large"}, {"text": "\uf00a", "id": "fa-th"}, {"text": "\uf00b", "id": "fa-th-list"}, {"text": "\uf00c", "id": "fa-check"}, {"text": "\uf00d", "id": "fa-times"}, {"text": "\uf00e", "id": "fa-search-plus"}, {"text": "\uf010", "id": "fa-search-minus"}, {"text": "\uf011", "id": "fa-power-off"}, {"text": "\uf012", "id": "fa-signal"}, {"text": "\uf013", "id": "fa-cog"}, {"text": "\uf014", "id": "fa-trash-o"}, {"text": "\uf015", "id": "fa-home"}, {"text": "\uf016", "id": "fa-file-o"}, {"text": "\uf017", "id": "fa-clock-o"}, {"text": "\uf018", "id": "fa-road"}, {"text": "\uf019", "id": "fa-download"}, {"text": "\uf01a", "id": "fa-arrow-circle-o-down"}, {"text": "\uf01b", "id": "fa-arrow-circle-o-up"}, {"text": "\uf01c", "id": "fa-inbox"}, {"text": "\uf01d", "id": "fa-play-circle-o"}, {"text": "\uf01e", "id": "fa-repeat"}, {"text": "\uf021", "id": "fa-refresh"}, {"text": "\uf022", "id": "fa-list-alt"}, {"text": "\uf023", "id": "fa-lock"}, {"text": "\uf024", "id": "fa-flag"}, {"text": "\uf025", "id": "fa-headphones"}, {"text": "\uf026", "id": "fa-volume-off"}, {"text": "\uf027", "id": "fa-volume-down"}, {"text": "\uf028", "id": "fa-volume-up"}, {"text": "\uf029", "id": "fa-qrcode"}, {"text": "\uf02a", "id": "fa-barcode"}, {"text": "\uf02b", "id": "fa-tag"}, {"text": "\uf02c", "id": "fa-tags"}, {"text": "\uf02d", "id": "fa-book"}, {"text": "\uf02e", "id": "fa-bookmark"}, {"text": "\uf02f", "id": "fa-print"}, {"text": "\uf030", "id": "fa-camera"}, {"text": "\uf031", "id": "fa-font"}, {"text": "\uf032", "id": "fa-bold"}, {"text": "\uf033", "id": "fa-italic"}, {"text": "\uf034", "id": "fa-text-height"}, {"text": "\uf035", "id": "fa-text-width"}, {"text": "\uf036", "id": "fa-align-left"}, {"text": "\uf037", "id": "fa-align-center"}, {"text": "\uf038", "id": "fa-align-right"}, {"text": "\uf039", "id": "fa-align-justify"}, {"text": "\uf03a", "id": "fa-list"}, {"text": "\uf03b", "id": "fa-outdent"}, {"text": "\uf03c", "id": "fa-indent"}, {"text": "\uf03d", "id": "fa-video-camera"}, {"text": "\uf03e", "id": "fa-picture-o"}, {"text": "\uf040", "id": "fa-pencil"}, {"text": "\uf041", "id": "fa-map-marker"}, {"text": "\uf042", "id": "fa-adjust"}, {"text": "\uf043", "id": "fa-tint"}, {"text": "\uf044", "id": "fa-pencil-square-o"}, {"text": "\uf045", "id": "fa-share-square-o"}, {"text": "\uf046", "id": "fa-check-square-o"}, {"text": "\uf047", "id": "fa-arrows"}, {"text": "\uf048", "id": "fa-step-backward"}, {"text": "\uf049", "id": "fa-fast-backward"}, {"text": "\uf04a", "id": "fa-backward"}, {"text": "\uf04b", "id": "fa-play"}, {"text": "\uf04c", "id": "fa-pause"}, {"text": "\uf04d", "id": "fa-stop"}, {"text": "\uf04e", "id": "fa-forward"}, {"text": "\uf050", "id": "fa-fast-forward"}, {"text": "\uf051", "id": "fa-step-forward"}, {"text": "\uf052", "id": "fa-eject"}, {"text": "\uf053", "id": "fa-chevron-left"}, {"text": "\uf054", "id": "fa-chevron-right"}, {"text": "\uf055", "id": "fa-plus-circle"}, {"text": "\uf056", "id": "fa-minus-circle"}, {"text": "\uf057", "id": "fa-times-circle"}, {"text": "\uf058", "id": "fa-check-circle"}, {"text": "\uf059", "id": "fa-question-circle"}, {"text": "\uf05a", "id": "fa-info-circle"}, {"text": "\uf05b", "id": "fa-crosshairs"}, {"text": "\uf05c", "id": "fa-times-circle-o"}, {"text": "\uf05d", "id": "fa-check-circle-o"}, {"text": "\uf05e", "id": "fa-ban"}, {"text": "\uf060", "id": "fa-arrow-left"}, {"text": "\uf061", "id": "fa-arrow-right"}, {"text": "\uf062", "id": "fa-arrow-up"}, {"text": "\uf063", "id": "fa-arrow-down"}, {"text": "\uf064", "id": "fa-share"}, {"text": "\uf065", "id": "fa-expand"}, {"text": "\uf066", "id": "fa-compress"}, {"text": "\uf067", "id": "fa-plus"}, {"text": "\uf068", "id": "fa-minus"}, {"text": "\uf069", "id": "fa-asterisk"}, {"text": "\uf06a", "id": "fa-exclamation-circle"}, {"text": "\uf06b", "id": "fa-gift"}, {"text": "\uf06c", "id": "fa-leaf"}, {"text": "\uf06d", "id": "fa-fire"}, {"text": "\uf06e", "id": "fa-eye"}, {"text": "\uf070", "id": "fa-eye-slash"}, {"text": "\uf071", "id": "fa-exclamation-triangle"}, {"text": "\uf072", "id": "fa-plane"}, {"text": "\uf073", "id": "fa-calendar"}, {"text": "\uf074", "id": "fa-random"}, {"text": "\uf075", "id": "fa-comment"}, {"text": "\uf076", "id": "fa-magnet"}, {"text": "\uf077", "id": "fa-chevron-up"}, {"text": "\uf078", "id": "fa-chevron-down"}, {"text": "\uf079", "id": "fa-retweet"}, {"text": "\uf07a", "id": "fa-shopping-cart"}, {"text": "\uf07b", "id": "fa-folder"}, {"text": "\uf07c", "id": "fa-folder-open"}, {"text": "\uf07d", "id": "fa-arrows-v"}, {"text": "\uf07e", "id": "fa-arrows-h"}, {"text": "\uf080", "id": "fa-bar-chart-o"}, {"text": "\uf081", "id": "fa-twitter-square"}, {"text": "\uf082", "id": "fa-facebook-square"}, {"text": "\uf083", "id": "fa-camera-retro"}, {"text": "\uf084", "id": "fa-key"}, {"text": "\uf085", "id": "fa-cogs"}, {"text": "\uf086", "id": "fa-comments"}, {"text": "\uf087", "id": "fa-thumbs-o-up"}, {"text": "\uf088", "id": "fa-thumbs-o-down"}, {"text": "\uf089", "id": "fa-star-half"}, {"text": "\uf08a", "id": "fa-heart-o"}, {"text": "\uf08b", "id": "fa-sign-out"}, {"text": "\uf08c", "id": "fa-linkedin-square"}, {"text": "\uf08d", "id": "fa-thumb-tack"}, {"text": "\uf08e", "id": "fa-external-link"}, {"text": "\uf090", "id": "fa-sign-in"}, {"text": "\uf091", "id": "fa-trophy"}, {"text": "\uf092", "id": "fa-github-square"}, {"text": "\uf093", "id": "fa-upload"}, {"text": "\uf094", "id": "fa-lemon-o"}, {"text": "\uf095", "id": "fa-phone"}, {"text": "\uf096", "id": "fa-square-o"}, {"text": "\uf097", "id": "fa-bookmark-o"}, {"text": "\uf098", "id": "fa-phone-square"}, {"text": "\uf099", "id": "fa-twitter"}, {"text": "\uf09a", "id": "fa-facebook"}, {"text": "\uf09b", "id": "fa-github"}, {"text": "\uf09c", "id": "fa-unlock"}, {"text": "\uf09d", "id": "fa-credit-card"}, {"text": "\uf09e", "id": "fa-rss"}, {"text": "\uf0a0", "id": "fa-hdd-o"}, {"text": "\uf0a1", "id": "fa-bullhorn"}, {"text": "\uf0f3", "id": "fa-bell"}, {"text": "\uf0a3", "id": "fa-certificate"}, {"text": "\uf0a4", "id": "fa-hand-o-right"}, {"text": "\uf0a5", "id": "fa-hand-o-left"}, {"text": "\uf0a6", "id": "fa-hand-o-up"}, {"text": "\uf0a7", "id": "fa-hand-o-down"}, {"text": "\uf0a8", "id": "fa-arrow-circle-left"}, {"text": "\uf0a9", "id": "fa-arrow-circle-right"}, {"text": "\uf0aa", "id": "fa-arrow-circle-up"}, {"text": "\uf0ab", "id": "fa-arrow-circle-down"}, {"text": "\uf0ac", "id": "fa-globe"}, {"text": "\uf0ad", "id": "fa-wrench"}, {"text": "\uf0ae", "id": "fa-tasks"}, {"text": "\uf0b0", "id": "fa-filter"}, {"text": "\uf0b1", "id": "fa-briefcase"}, {"text": "\uf0b2", "id": "fa-arrows-alt"}, {"text": "\uf0c0", "id": "fa-users"}, {"text": "\uf0c1", "id": "fa-link"}, {"text": "\uf0c2", "id": "fa-cloud"}, {"text": "\uf0c3", "id": "fa-flask"}, {"text": "\uf0c4", "id": "fa-scissors"}, {"text": "\uf0c5", "id": "fa-files-o"}, {"text": "\uf0c6", "id": "fa-paperclip"}, {"text": "\uf0c7", "id": "fa-floppy-o"}, {"text": "\uf0c8", "id": "fa-square"}, {"text": "\uf0c9", "id": "fa-bars"}, {"text": "\uf0ca", "id": "fa-list-ul"}, {"text": "\uf0cb", "id": "fa-list-ol"}, {"text": "\uf0cc", "id": "fa-strikethrough"}, {"text": "\uf0cd", "id": "fa-underline"}, {"text": "\uf0ce", "id": "fa-table"}, {"text": "\uf0d0", "id": "fa-magic"}, {"text": "\uf0d1", "id": "fa-truck"}, {"text": "\uf0d2", "id": "fa-pinterest"}, {"text": "\uf0d3", "id": "fa-pinterest-square"}, {"text": "\uf0d4", "id": "fa-google-plus-square"}, {"text": "\uf0d5", "id": "fa-google-plus"}, {"text": "\uf0d6", "id": "fa-money"}, {"text": "\uf0d7", "id": "fa-caret-down"}, {"text": "\uf0d8", "id": "fa-caret-up"}, {"text": "\uf0d9", "id": "fa-caret-left"}, {"text": "\uf0da", "id": "fa-caret-right"}, {"text": "\uf0db", "id": "fa-columns"}, {"text": "\uf0dc", "id": "fa-sort"}, {"text": "\uf0dd", "id": "fa-sort-asc"}, {"text": "\uf0de", "id": "fa-sort-desc"}, {"text": "\uf0e0", "id": "fa-envelope"}, {"text": "\uf0e1", "id": "fa-linkedin"}, {"text": "\uf0e2", "id": "fa-undo"}, {"text": "\uf0e3", "id": "fa-gavel"}, {"text": "\uf0e4", "id": "fa-tachometer"}, {"text": "\uf0e5", "id": "fa-comment-o"}, {"text": "\uf0e6", "id": "fa-comments-o"}, {"text": "\uf0e7", "id": "fa-bolt"}, {"text": "\uf0e8", "id": "fa-sitemap"}, {"text": "\uf0e9", "id": "fa-umbrella"}, {"text": "\uf0ea", "id": "fa-clipboard"}, {"text": "\uf0eb", "id": "fa-lightbulb-o"}, {"text": "\uf0ec", "id": "fa-exchange"}, {"text": "\uf0ed", "id": "fa-cloud-download"}, {"text": "\uf0ee", "id": "fa-cloud-upload"}, {"text": "\uf0f0", "id": "fa-user-md"}, {"text": "\uf0f1", "id": "fa-stethoscope"}, {"text": "\uf0f2", "id": "fa-suitcase"}, {"text": "\uf0a2", "id": "fa-bell-o"}, {"text": "\uf0f4", "id": "fa-coffee"}, {"text": "\uf0f5", "id": "fa-cutlery"}, {"text": "\uf0f6", "id": "fa-file-text-o"}, {"text": "\uf0f7", "id": "fa-building-o"}, {"text": "\uf0f8", "id": "fa-hospital-o"}, {"text": "\uf0f9", "id": "fa-ambulance"}, {"text": "\uf0fa", "id": "fa-medkit"}, {"text": "\uf0fb", "id": "fa-fighter-jet"}, {"text": "\uf0fc", "id": "fa-beer"}, {"text": "\uf0fd", "id": "fa-h-square"}, {"text": "\uf0fe", "id": "fa-plus-square"}, {"text": "\uf100", "id": "fa-angle-double-left"}, {"text": "\uf101", "id": "fa-angle-double-right"}, {"text": "\uf102", "id": "fa-angle-double-up"}, {"text": "\uf103", "id": "fa-angle-double-down"}, {"text": "\uf104", "id": "fa-angle-left"}, {"text": "\uf105", "id": "fa-angle-right"}, {"text": "\uf106", "id": "fa-angle-up"}, {"text": "\uf107", "id": "fa-angle-down"}, {"text": "\uf108", "id": "fa-desktop"}, {"text": "\uf109", "id": "fa-laptop"}, {"text": "\uf10a", "id": "fa-tablet"}, {"text": "\uf10b", "id": "fa-mobile"}, {"text": "\uf10c", "id": "fa-circle-o"}, {"text": "\uf10d", "id": "fa-quote-left"}, {"text": "\uf10e", "id": "fa-quote-right"}, {"text": "\uf110", "id": "fa-spinner"}, {"text": "\uf111", "id": "fa-circle"}, {"text": "\uf112", "id": "fa-reply"}, {"text": "\uf113", "id": "fa-github-alt"}, {"text": "\uf114", "id": "fa-folder-o"}, {"text": "\uf115", "id": "fa-folder-open-o"}, {"text": "\uf118", "id": "fa-smile-o"}, {"text": "\uf119", "id": "fa-frown-o"}, {"text": "\uf11a", "id": "fa-meh-o"}, {"text": "\uf11b", "id": "fa-gamepad"}, {"text": "\uf11c", "id": "fa-keyboard-o"}, {"text": "\uf11d", "id": "fa-flag-o"}, {"text": "\uf11e", "id": "fa-flag-checkered"}, {"text": "\uf120", "id": "fa-terminal"}, {"text": "\uf121", "id": "fa-code"}, {"text": "\uf122", "id": "fa-reply-all"}, {"text": "\uf122", "id": "fa-mail-reply-all"}, {"text": "\uf123", "id": "fa-star-half-o"}, {"text": "\uf124", "id": "fa-location-arrow"}, {"text": "\uf125", "id": "fa-crop"}, {"text": "\uf126", "id": "fa-code-fork"}, {"text": "\uf127", "id": "fa-chain-broken"}, {"text": "\uf128", "id": "fa-question"}, {"text": "\uf129", "id": "fa-info"}, {"text": "\uf12a", "id": "fa-exclamation"}, {"text": "\uf12b", "id": "fa-superscript"}, {"text": "\uf12c", "id": "fa-subscript"}, {"text": "\uf12d", "id": "fa-eraser"}, {"text": "\uf12e", "id": "fa-puzzle-piece"}, {"text": "\uf130", "id": "fa-microphone"}, {"text": "\uf131", "id": "fa-microphone-slash"}, {"text": "\uf132", "id": "fa-shield"}, {"text": "\uf133", "id": "fa-calendar-o"}, {"text": "\uf134", "id": "fa-fire-extinguisher"}, {"text": "\uf135", "id": "fa-rocket"}, {"text": "\uf136", "id": "fa-maxcdn"}, {"text": "\uf137", "id": "fa-chevron-circle-left"}, {"text": "\uf138", "id": "fa-chevron-circle-right"}, {"text": "\uf139", "id": "fa-chevron-circle-up"}, {"text": "\uf13a", "id": "fa-chevron-circle-down"}, {"text": "\uf13b", "id": "fa-html5"}, {"text": "\uf13c", "id": "fa-css3"}, {"text": "\uf13d", "id": "fa-anchor"}, {"text": "\uf13e", "id": "fa-unlock-alt"}, {"text": "\uf140", "id": "fa-bullseye"}, {"text": "\uf141", "id": "fa-ellipsis-h"}, {"text": "\uf142", "id": "fa-ellipsis-v"}, {"text": "\uf143", "id": "fa-rss-square"}, {"text": "\uf144", "id": "fa-play-circle"}, {"text": "\uf145", "id": "fa-ticket"}, {"text": "\uf146", "id": "fa-minus-square"}, {"text": "\uf147", "id": "fa-minus-square-o"}, {"text": "\uf148", "id": "fa-level-up"}, {"text": "\uf149", "id": "fa-level-down"}, {"text": "\uf14a", "id": "fa-check-square"}, {"text": "\uf14b", "id": "fa-pencil-square"}, {"text": "\uf14c", "id": "fa-external-link-square"}, {"text": "\uf14d", "id": "fa-share-square"}, {"text": "\uf14e", "id": "fa-compass"}, {"text": "\uf150", "id": "fa-caret-square-o-down"}, {"text": "\uf151", "id": "fa-caret-square-o-up"}, {"text": "\uf152", "id": "fa-caret-square-o-right"}, {"text": "\uf153", "id": "fa-eur"}, {"text": "\uf154", "id": "fa-gbp"}, {"text": "\uf155", "id": "fa-usd"}, {"text": "\uf156", "id": "fa-inr"}, {"text": "\uf157", "id": "fa-jpy"}, {"text": "\uf158", "id": "fa-rub"}, {"text": "\uf159", "id": "fa-krw"}, {"text": "\uf15a", "id": "fa-btc"}, {"text": "\uf15b", "id": "fa-file"}, {"text": "\uf15c", "id": "fa-file-text"}, {"text": "\uf15d", "id": "fa-sort-alpha-asc"}, {"text": "\uf15e", "id": "fa-sort-alpha-desc"}, {"text": "\uf160", "id": "fa-sort-amount-asc"}, {"text": "\uf161", "id": "fa-sort-amount-desc"}, {"text": "\uf162", "id": "fa-sort-numeric-asc"}, {"text": "\uf163", "id": "fa-sort-numeric-desc"}, {"text": "\uf164", "id": "fa-thumbs-up"}, {"text": "\uf165", "id": "fa-thumbs-down"}, {"text": "\uf166", "id": "fa-youtube-square"}, {"text": "\uf167", "id": "fa-youtube"}, {"text": "\uf168", "id": "fa-xing"}, {"text": "\uf169", "id": "fa-xing-square"}, {"text": "\uf16a", "id": "fa-youtube-play"}, {"text": "\uf16b", "id": "fa-dropbox"}, {"text": "\uf16c", "id": "fa-stack-overflow"}, {"text": "\uf16d", "id": "fa-instagram"}, {"text": "\uf16e", "id": "fa-flickr"}, {"text": "\uf170", "id": "fa-adn"}, {"text": "\uf171", "id": "fa-bitbucket"}, {"text": "\uf172", "id": "fa-bitbucket-square"}, {"text": "\uf173", "id": "fa-tumblr"}, {"text": "\uf174", "id": "fa-tumblr-square"}, {"text": "\uf175", "id": "fa-long-arrow-down"}, {"text": "\uf176", "id": "fa-long-arrow-up"}, {"text": "\uf177", "id": "fa-long-arrow-left"}, {"text": "\uf178", "id": "fa-long-arrow-right"}, {"text": "\uf179", "id": "fa-apple"}, {"text": "\uf17a", "id": "fa-windows"}, {"text": "\uf17b", "id": "fa-android"}, {"text": "\uf17c", "id": "fa-linux"}, {"text": "\uf17d", "id": "fa-dribbble"}, {"text": "\uf17e", "id": "fa-skype"}, {"text": "\uf180", "id": "fa-foursquare"}, {"text": "\uf181", "id": "fa-trello"}, {"text": "\uf182", "id": "fa-female"}, {"text": "\uf183", "id": "fa-male"}, {"text": "\uf184", "id": "fa-gittip"}, {"text": "\uf185", "id": "fa-sun-o"}, {"text": "\uf186", "id": "fa-moon-o"}, {"text": "\uf187", "id": "fa-archive"}, {"text": "\uf188", "id": "fa-bug"}, {"text": "\uf189", "id": "fa-vk"}, {"text": "\uf18a", "id": "fa-weibo"}, {"text": "\uf18b", "id": "fa-renren"}, {"text": "\uf18c", "id": "fa-pagelines"}, {"text": "\uf18d", "id": "fa-stack-exchange"}, {"text": "\uf18e", "id": "fa-arrow-circle-o-right"}, {"text": "\uf190", "id": "fa-arrow-circle-o-left"}, {"text": "\uf191", "id": "fa-caret-square-o-left"}, {"text": "\uf192", "id": "fa-dot-circle-o"}, {"text": "\uf193", "id": "fa-wheelchair"}, {"text": "\uf194", "id": "fa-vimeo-square"}, {"text": "\uf195", "id": "fa-try"}, {"text": "\uf196", "id": "fa-plus-square-o"}],
1796         /**
1797          * Initializes select2: in Chrome and Safari, <select> font apparently
1798          * isn't customizable (?) and the fontawesome glyphs fail to appear.
1799          */
1800         start: function () {
1801             return this._super().then(this.proxy('load_data'));
1802         },
1803         search: function (needle) {
1804             var icons = this.icons;
1805             if (needle) {
1806                 icons = _(icons).filter(function (icon) {
1807                     return icon.id.substring(3).indexOf(needle) !== -1;
1808                 });
1809             }
1810
1811             this.$('div.font-icons-icons').html(
1812                 openerp.qweb.render(
1813                     'website.editor.dialog.font-icons.icons',
1814                     {icons: icons}));
1815         },
1816         /**
1817          * Removes existing FontAwesome classes on the bound element, and sets
1818          * all the new ones if necessary.
1819          */
1820         save: function () {
1821             var style = this.media.$.attributes.style ? this.media.$.attributes.style.textContent : '';
1822             var classes = (this.media.$.className||"").split(/\s+/);
1823             var non_fa_classes = _.reject(classes, function (cls) {
1824                 return cls === 'fa' || /^fa-/.test(cls);
1825             });
1826             var final_classes = non_fa_classes.concat(this.get_fa_classes());
1827             this.media.$.className = final_classes.join(' ');
1828             this.media.renameNode("span");
1829             this.media.$.attributes.style.textContent = style;
1830             this._super();
1831         },
1832         /**
1833          * Looks up the various FontAwesome classes on the bound element and
1834          * sets the corresponding template/form elements to the right state.
1835          * If multiple classes of the same category are present on an element
1836          * (e.g. fa-lg and fa-3x) the last one occurring will be selected,
1837          * which may not match the visual look of the element.
1838          */
1839         load_data: function () {
1840             var classes = (this.media&&this.media.$.className||"").split(/\s+/);
1841             for (var i = 0; i < classes.length; i++) {
1842                 var cls = classes[i];
1843                 switch(cls) {
1844                 case 'fa-2x':case 'fa-3x':case 'fa-4x':case 'fa-5x':
1845                     // size classes
1846                     this.$('#fa-size').val(cls);
1847                     continue;
1848                 case 'fa-spin':
1849                 case 'fa-rotate-90':case 'fa-rotate-180':case 'fa-rotate-270':
1850                 case 'fa-flip-horizontal':case 'fa-rotate-vertical':
1851                     this.$('#fa-rotation').val(cls);
1852                     continue;
1853                 case 'fa-fw':
1854                     continue;
1855                 case 'fa-border':
1856                     this.$('#fa-border').prop('checked', true);
1857                     continue;
1858                 default:
1859                     if (!/^fa-/.test(cls)) { continue; }
1860                     this.$('#fa-icon').val(cls);
1861                 }
1862             }
1863             this.update_preview();
1864         },
1865         /**
1866          * Serializes the dialog to an array of FontAwesome classes. Includes
1867          * the base ``fa``.
1868          */
1869         get_fa_classes: function () {
1870             return [
1871                 'fa',
1872                 this.$('#fa-icon').val(),
1873                 this.$('#fa-size').val(),
1874                 this.$('#fa-rotation').val(),
1875                 this.$('#fa-border').prop('checked') ? 'fa-border' : ''
1876             ];
1877         },
1878         update_preview: function () {
1879             this.$preview.empty();
1880             var $preview = this.$('#fa-preview').empty();
1881
1882             var sizes = ['', 'fa-2x', 'fa-3x', 'fa-4x', 'fa-5x'];
1883             var classes = this.get_fa_classes();
1884             var no_sizes = _.difference(classes, sizes).join(' ');
1885             var selected = false;
1886             for (var i = sizes.length - 1; i >= 0; i--) {
1887                 var size = sizes[i];
1888
1889                 var $p = $('<span>')
1890                         .attr('data-size', size)
1891                         .addClass(size)
1892                         .addClass(no_sizes);
1893
1894                 if ((size && _.contains(classes, size)) || (size === "" && !selected)) {
1895                     this.$preview.append($p.clone());
1896                     this.$('#fa-size').val(size);
1897                     $p.addClass('font-icons-selected');
1898                     selected = true;
1899                 }
1900                 $preview.prepend($p);
1901             }
1902         },
1903         clear: function () {
1904             this.media.$.className = this.media.$.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' ');
1905         },
1906     });
1907
1908     website.editor.VideoDialog = website.editor.Media.extend({
1909         template: 'website.editor.dialog.video',
1910         events : _.extend({}, website.editor.Dialog.prototype.events, {
1911             'click input#urlvideo ~ button': 'get_video',
1912             'click input#embedvideo ~ button': 'get_embed_video',
1913             'change input#urlvideo': 'change_input',
1914             'keyup input#urlvideo': 'change_input',
1915             'change input#embedvideo': 'change_input',
1916             'keyup input#embedvideo': 'change_input'
1917         }),
1918         start: function () {
1919             this.$iframe = this.$("iframe");
1920             var $media = $(this.media && this.media.$);
1921             if ($media.hasClass("media_iframe_video")) {
1922                 var src = $media.data('src');
1923                 this.$("input#urlvideo").val(src);
1924                 this.$("#autoplay").attr("checked", src.indexOf('autoplay=1') != -1);
1925                 this.get_video();
1926             }
1927             return this._super();
1928         },
1929         change_input: function (e) {
1930             var $input = $(e.target);
1931             var $button = $input.parent().find("button");
1932             if ($input.val() === "") {
1933                 $button.addClass("btn-default").removeClass("btn-primary");
1934             } else {
1935                 $button.removeClass("btn-default").addClass("btn-primary");
1936             }
1937         },
1938         get_url: function () {
1939             var video_id = this.$("#video_id").val();
1940             var video_type = this.$("#video_type").val();
1941             switch (video_type) {
1942                 case "youtube":
1943                     return "//www.youtube.com/embed/" + video_id + "?autoplay=" + (this.$("#autoplay").is(":checked") ? 1 : 0);
1944                 case "vimeo":
1945                     return "//player.vimeo.com/video/" + video_id + "?autoplay=" + (this.$("#autoplay").is(":checked") ? 1 : 0);
1946                 case "dailymotion":
1947                     return "//www.dailymotion.com/embed/video/" + video_id + "?autoplay=" + (this.$("#autoplay").is(":checked") ? 1 : 0);
1948                 default:
1949                     return video_id;
1950             }
1951         },
1952         get_embed_video: function (event) {
1953             event.preventDefault();
1954             var embedvideo = this.$("input#embedvideo").val().match(/src=["']?([^"']+)["' ]?/);
1955             if (embedvideo) {
1956                 this.$("input#urlvideo").val(embedvideo[1]);
1957                 this.get_video(event);
1958             }
1959             return false;
1960         },
1961         get_video: function (event) {
1962             if (event) event.preventDefault();
1963             var needle = this.$("input#urlvideo").val();
1964             var video_id;
1965             var video_type;
1966
1967             if (needle.indexOf(".youtube.") != -1) {
1968                 video_type = "youtube";
1969                 video_id = needle.match(/\.youtube\.[a-z]+\/(embed\/|watch\?v=)?([^\/?&]+)/i)[2];
1970             } else if (needle.indexOf("//youtu.") != -1) {
1971                 video_type = "youtube";
1972                 video_id = needle.match(/youtube\.[a-z]+\/([^\/?&]+)/i)[1];
1973             } else if (needle.indexOf("player.vimeo.") != -1 || needle.indexOf("//vimeo.") != -1) {
1974                 video_type = "vimeo";
1975                 video_id = needle.match(/vimeo\.[a-z]+\/(video\/)?([^?&]+)/i)[2];
1976             } else if (needle.indexOf(".dailymotion.") != -1) {
1977                 video_type = "dailymotion";
1978                 video_id = needle.match(/dailymotion\.[a-z]+\/(embed\/)?(video\/)?([^\/?&]+)/i)[3];
1979             } else {
1980                 video_type = "";
1981                 video_id = needle;
1982             }
1983
1984             this.$("#video_id").val(video_id);
1985             this.$("#video_type").val(video_type);
1986
1987             this.$iframe.attr("src", this.get_url());
1988             return false;
1989         },
1990         save: function () {
1991             var video_id = this.$("#video_id").val();
1992             if (!video_id) {
1993                 this.$("button.btn-primary").click();
1994                 video_id = this.$("#video_id").val();
1995             }
1996             var video_type = this.$("#video_type").val();
1997             var style = this.media.$.attributes.style ? this.media.$.attributes.style.textContent : '';
1998             var $iframe = $(
1999                 '<div class="media_iframe_video" data-src="'+this.get_url()+'" style="'+style+'">'+
2000                     '<div class="css_editable_mode_display">&nbsp;</div>'+
2001                     '<iframe src="'+this.get_url()+'" frameborder="0" allowfullscreen="allowfullscreen"></iframe>'+
2002                 '</div>');
2003             $(this.media.$).replaceWith($iframe);
2004             this.media.$ = $iframe[0];
2005             this._super();
2006         },
2007         clear: function () {
2008             delete this.media.$.dataset.src;
2009             this.media.$.className = this.media.$.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' ');
2010         },
2011     });
2012
2013     website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
2014     var OBSERVER_CONFIG = {
2015         childList: true,
2016         attributes: true,
2017         characterData: true,
2018         subtree: true,
2019         attributeOldValue: true,
2020     };
2021     var observer = new website.Observer(function (mutations) {
2022         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
2023         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
2024         //       will not mark dirty on attribute changes (@class, img/@src,
2025         //       a/@href, ...)
2026         _(mutations).chain()
2027             .filter(function (m) {
2028                 // ignore any SVG target, these blokes are like weird mon
2029                 if (m.target && m.target instanceof SVGElement) {
2030                     return false;
2031                 }
2032
2033                 // ignore any change related to mundane image-edit-button
2034                 if (m.target && m.target.className
2035                         && m.target.className.indexOf('image-edit-button') !== -1) {
2036                     return false;
2037                 }
2038                 switch(m.type) {
2039                 case 'attributes':
2040                     // ignore special attributes and .cke_focus class being added or removed
2041                     var ignored_attrs = ['id', 'contenteditable', 'attributeeditable']
2042                     if (_.contains(ignored_attrs, m.attributeName)) { return false; }
2043                     // if attribute is not a class, can't be .cke_focus change
2044                     if (m.attributeName !== 'class') { return true; }
2045
2046                     // find out what classes were added or removed
2047                     var oldClasses = (m.oldValue || '').split(/\s+/);
2048                     var newClasses = m.target.className.split(/\s+/);
2049                     var change = _.union(_.difference(oldClasses, newClasses),
2050                                          _.difference(newClasses, oldClasses));
2051                     // ignore mutation if the *only* change is .cke_focus
2052                     return change.length !== 1 || change[0] === 'cke_focus';
2053                 case 'childList':
2054                     setTimeout(function () {
2055                         fixup_browser_crap(m.addedNodes);
2056                     }, 0);
2057                     // Remove ignorable nodes from addedNodes or removedNodes,
2058                     // if either set remains non-empty it's considered to be an
2059                     // impactful change. Otherwise it's ignored.
2060                     return !!remove_mundane_nodes(m.addedNodes).length ||
2061                            !!remove_mundane_nodes(m.removedNodes).length;
2062                 default:
2063                     return true;
2064                 }
2065             })
2066             .map(function (m) {
2067                 var node = m.target;
2068                 while (node && !$(node).hasClass('oe_editable')) {
2069                     node = node.parentNode;
2070                 }
2071                 return node;
2072             })
2073             .compact()
2074             .uniq()
2075             .each(function (node) { $(node).trigger('content_changed'); })
2076     });
2077     function remove_mundane_nodes(nodes) {
2078         if (!nodes || !nodes.length) { return []; }
2079
2080         var output = [];
2081         for(var i=0; i<nodes.length; ++i) {
2082             var node = nodes[i];
2083             if (node.nodeType === document.ELEMENT_NODE) {
2084                 if (node.nodeName === 'BR' && node.getAttribute('type') === '_moz') {
2085                     // <br type="_moz"> appears when focusing RTE in FF, ignore
2086                     continue;
2087                 } else if (node.nodeName === 'DIV' && $(node).hasClass('oe_drop_zone')) {
2088                     // ignore dropzone inserted by snippets
2089                     continue
2090                 }
2091             }
2092
2093             output.push(node);
2094         }
2095         return output;
2096     }
2097
2098     var programmatic_styles = {
2099         float: 1,
2100         display: 1,
2101         position: 1,
2102         top: 1,
2103         left: 1,
2104         right: 1,
2105         bottom: 1,
2106     };
2107     function fixup_browser_crap(nodes) {
2108         if (!nodes || !nodes.length) { return; }
2109         /**
2110          * Checks that the node only has a @style, not e.g. @class or whatever
2111          */
2112         function has_only_style(node) {
2113             for (var i = 0; i < node.attributes.length; i++) {
2114                 var attr = node.attributes[i];
2115                 if (attr.attributeName !== 'style') {
2116                     return false;
2117                 }
2118             }
2119             return true;
2120         }
2121         function has_programmatic_style(node) {
2122             for (var i = 0; i < node.style.length; i++) {
2123               var style = node.style[i];
2124               if (programmatic_styles[style]) {
2125                   return true;
2126               }
2127             }
2128             return false;
2129         }
2130
2131         for (var i=0; i<nodes.length; ++i) {
2132             var node = nodes[i];
2133             if (node.nodeType !== document.ELEMENT_NODE) { continue; }
2134
2135             if (node.nodeName === 'SPAN'
2136                     && has_only_style(node)
2137                     && !has_programmatic_style(node)) {
2138                 // On backspace, webkit browsers create a <span> with a bunch of
2139                 // inline styles "remembering" where they come from. Refs:
2140                 //    http://www.neotericdesign.com/blog/2013/3/working-around-chrome-s-contenteditable-span-bug
2141                 //    https://code.google.com/p/chromium/issues/detail?id=226941
2142                 //    https://bugs.webkit.org/show_bug.cgi?id=114791
2143                 //    http://dev.ckeditor.com/ticket/9998
2144                 var child, parent = node.parentNode;
2145                 while (child = node.firstChild) {
2146                     parent.insertBefore(child, node);
2147                 }
2148                 parent.removeChild(node);
2149                 // chances are we had e.g.
2150                 //  <p>foo</p>
2151                 //  <p>bar</p>
2152                 // merged the lines getting this in webkit
2153                 //  <p>foo<span>bar</span></p>
2154                 // after unwrapping the span, we have 2 text nodes
2155                 //  <p>[foo][bar]</p>
2156                 // where we probably want only one. Normalize will merge
2157                 // adjacent text nodes. However, does not merge text and cdata
2158                 parent.normalize();
2159             }
2160         }
2161     }
2162 })();