[IMP] website editor: better rendering of the popover when failure write raise by...
[odoo/odoo.git] / addons / website / static / src / js / website.editor.js
index d839252..30af6fd 100644 (file)
@@ -2,19 +2,33 @@
     'use strict';
 
     var website = openerp.website;
-    // $.fn.data automatically parses value, '0'|'1' -> 0|1
+    var _t = openerp._t;
 
-    website.templates.push('/website/static/src/xml/website.editor.xml');
+    website.add_template_file('/website/static/src/xml/website.editor.xml');
     website.dom_ready.done(function () {
         var is_smartphone = $(document.body)[0].clientWidth < 767;
 
         if (!is_smartphone) {
             website.ready().then(website.init_editor);
         }
+
+        $(document).on('click', 'a.js_link2post', function (ev) {
+            ev.preventDefault();
+            website.form(this.pathname, 'POST');
+        });
+
+        $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
+            // Prevent dropdown closing when a contenteditable children is focused
+            if (ev.originalEvent
+                    && $(ev.target).has(ev.originalEvent.target).length
+                    && $(ev.originalEvent.target).is('[contenteditable]')) {
+                ev.preventDefault();
+            }
+        });
     });
 
     function link_dialog(editor) {
-        return new website.editor.LinkDialog(editor).appendTo(document.body);
+        return new website.editor.RTELinkDialog(editor).appendTo(document.body);
     }
     function image_dialog(editor) {
         return new website.editor.RTEImageDialog(editor).appendTo(document.body);
                     link_dialog(editor);
                 }, null, null, 500);
 
+                var previousSelection;
+                editor.on('selectionChange', function (evt) {
+                    var selected = evt.data.path.lastElement;
+                    if (previousSelection) {
+                        // cleanup previous selection
+                        $(previousSelection).next().remove();
+                        previousSelection = null;
+                    }
+                    if (!selected.is('img')
+                            || selected.data('cke-realelement')
+                            || selected.isReadOnly()
+                            || selected.data('oe-model') === 'ir.ui.view') {
+                        return;
+                    }
+
+                    // display button
+                    var $el = $(previousSelection = selected.$);
+                    var $btn = $('<button type="button" class="btn btn-primary image-edit-button" contenteditable="false">Edit</button>')
+                        .insertAfter($el)
+                        .click(function (e) {
+                            e.preventDefault();
+                            e.stopPropagation();
+                            image_dialog(editor);
+                        });
+
+                    var position = $el.position();
+                    $btn.css({
+                        position: 'absolute',
+                        top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
+                        left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
+                    });
+                });
+                editor.on('destroy', function (evt) {
+                    if (previousSelection) {
+                        $('.image-edit-button').remove();
+                    }
+                });
+
                 //noinspection JSValidateTypes
                 editor.addCommand('link', {
-                    exec: function (editor, data) {
+                    exec: function (editor) {
                         link_dialog(editor);
                         return true;
                     },
                 });
                 //noinspection JSValidateTypes
                 editor.addCommand('image', {
-                    exec: function (editor, data) {
+                    exec: function (editor) {
                         image_dialog(editor);
                         return true;
                     },
                     editables: { text: '*' },
 
                     upcast: function (el) {
-                        return el.attributes['data-oe-type'];
+                        return el.attributes['data-oe-type']
+                            && el.attributes['data-oe-type'] !== 'monetary';
                     },
                 });
+                editor.widgets.add('monetary', {
+                    editables: { text: 'span.oe_currency_value' },
+
+                    upcast: function (el) {
+                        return el.attributes['data-oe-type'] === 'monetary';
+                    }
+                });
             }
         });
 
+        CKEDITOR.plugins.add('bootstrapcombo', {
+            requires: 'richcombo',
+
+            init: function (editor) {
+                var config = editor.config;
+
+                editor.ui.addRichCombo('BootstrapLinkCombo', {
+                    // default title
+                    label: "Links",
+                    // hover
+                    title: "Link styling",
+                    toolbar: 'styles,10',
+                    allowedContent: ['a'],
+
+                    panel: {
+                                           css: [
+                            '/website/static/lib/bootstrap/css/bootstrap.css',
+                            CKEDITOR.skin.getPath( 'editor' )
+                        ].concat( config.contentsCss ),
+                        multiSelect: true,
+                    },
+
+                    types: {
+                        'basic': 'btn-default',
+                        'primary': 'btn-primary',
+                        'success': 'btn-success',
+                        'info': 'btn-info',
+                        'warning': 'btn-warning',
+                        'danger': 'btn-danger',
+                    },
+
+                    sizes: {
+                        'large': 'btn-lg',
+                        'default': '',
+                        'small': 'btn-sm',
+                        'extra small': 'btn-xs',
+                    },
+
+                    init: function () {
+                        this.add('', 'Reset');
+                        this.startGroup("Types");
+                        for(var type in this.types) {
+                            if (!this.types.hasOwnProperty(type)) { continue; }
+                            var cls = this.types[type];
+                            var el = _.str.sprintf(
+                                '<span class="btn %s">%s</span>',
+                                cls, type);
+                            this.add(type, el);
+                        }
+                        this.startGroup("Sizes");
+                        for (var size in this.sizes) {
+                            if (!this.sizes.hasOwnProperty(size)) { continue; }
+                            cls = this.sizes[size];
+
+                            el = _.str.sprintf(
+                                '<span class="btn btn-default %s">%s</span>',
+                                cls, size);
+                            this.add(size, el);
+                        }
+                        this.commit();
+                    },
+                    onRender: function () {
+                        var self = this;
+                        editor.on('selectionChange', function (e) {
+                            var path = e.data.path, el;
+
+                            if (!(el = path.contains('a'))) {
+                                self.element = null;
+                                self.disable();
+                                return;
+                            }
+
+                            self.enable();
+                            // This is crap, but getting the currently selected
+                            // element from within onOpen absolutely does not
+                            // work, so store the "current" element in the
+                            // widget instead
+                            self.element = el;
+                        });
+                        setTimeout(function () {
+                            // Because I can't find any normal hook where the
+                            // bloody button's bloody element is available
+                            self.disable();
+                        }, 0);
+                    },
+                    onOpen: function () {
+                        this.showAll();
+                        this.unmarkAll();
+
+                        for(var val in this.types) {
+                            if (!this.types.hasOwnProperty(val)) { continue; }
+                            var cls = this.types[val];
+                            if (!this.element.hasClass(cls)) { continue; }
+
+                            this.mark(val);
+                            break;
+                        }
+
+                        var found;
+                        for(val in this.sizes) {
+                            if (!this.sizes.hasOwnProperty(val)) { continue; }
+                            cls = this.sizes[val];
+                            if (!cls || !this.element.hasClass(cls)) { continue; }
+
+                            found = true;
+                            this.mark(val);
+                            break;
+                        }
+                        if (!found && this.element.hasClass('btn')) {
+                            this.mark('default');
+                        }
+                    },
+                    onClick: function (value) {
+                        editor.focus();
+                        editor.fire('saveShapshot');
+
+                        // basic btn setup
+                        var el = this.element;
+                        if (!el.hasClass('btn')) {
+                            el.addClass('btn');
+                            el.addClass('btn-default');
+                        }
+
+                        if (!value) {
+                            this.setClass(this.types);
+                            this.setClass(this.sizes);
+                            el.removeClass('btn');
+                        } else if (value in this.types) {
+                            this.setClass(this.types, value);
+                        } else if (value in this.sizes) {
+                            this.setClass(this.sizes, value);
+                        }
+
+                        editor.fire('saveShapshot');
+                    },
+                    setClass: function (classMap, value) {
+                        var element = this.element;
+                        _(classMap).each(function (cls) {
+                            if (!cls) { return; }
+                            element.removeClass(cls);
+                        }.bind(this));
+
+                        var cls = classMap[value];
+                        if (cls) {
+                            element.addClass(cls);
+                        }
+                    }
+                });
+            },
+        });
+
         var editor = new website.EditorBar();
         var $body = $(document.body);
         editor.prependTo($body).then(function () {
-            if (location.search.indexOf("unable_editor") >= 0) {
+            if (location.search.indexOf("enable_editor") >= 0) {
                 editor.edit();
             }
         });
             'click button[data-action=edit]': 'edit',
             'click button[data-action=save]': 'save',
             'click button[data-action=cancel]': 'cancel',
+            'click a[data-action=new_page]': 'new_page',
         },
         container: 'body',
         customize_setup: function() {
                             }
                         });
                         // Adding Static Menus
-                        menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
-                        menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
+                        menu.append('<li class="divider"></li>');
+                       menu.append('<li><a data-action="ace" href="#">HTML Editor</a></li>');
+                        menu.append('<li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
+                        menu.append('<li><a href="/web#return_label=Website&action=website.action_module_website">Install Apps</a></li>');
                         self.trigger('rte:customize_menu_ready');
                     }
                 );
                 var view_id = $(event.currentTarget).data('view-id');
                 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
                     'view_id': view_id
-                }).then( function(result) {
+                }).then( function() {
                     window.location.reload();
                 });
             });
             );
         },
         edit: function () {
-            var self = this;
             this.$buttons.edit.prop('disabled', true);
             this.$('#website-top-view').hide();
             this.$('#website-top-edit').show();
             editor.destroy();
             // FIXME: select editables then filter by dirty?
             var defs = this.rte.fetch_editables(root)
-                .removeClass('oe_editable cke_focus')
-                .removeAttr('contentEditable')
                 .filter('.oe_dirty')
+                .removeAttr('contentEditable')
+                .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
                 .map(function () {
                     var $el = $(this);
                     // TODO: Add a queue with concurrency limit in webclient
                     // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
                     return self.saving_mutex.exec(function () {
                         return self.saveElement($el)
-                            .fail(function () {
-                                var data = $el.data();
-                                console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
+                            .then(undefined, function (thing, response) {
+                                // because ckeditor regenerates all the dom,
+                                // we can't just setup the popover here as
+                                // everything will be destroyed by the DOM
+                                // regeneration. Add markings instead, and
+                                // returns a new rejection with all relevant
+                                // info
+                                var id = _.uniqueId('carlos_danger_');
+                                $el.addClass('oe_dirty oe_carlos_danger');
+                                $el.addClass(id);
+                                return $.Deferred().reject({
+                                    id: id,
+                                    error: response.data,
+                                });
                             });
                     });
                 }).get();
             return $.when.apply(null, defs).then(function () {
                 website.reload();
+            }, function (failed) {
+                // If there were errors, re-enable edition
+                self.rte.start_edition(true).then(function () {
+                    // jquery's deferred being a pain in the ass
+                    if (!_.isArray(failed)) { failed = [failed]; }
+
+                    _(failed).each(function (failure) {
+                        var html = failure.error.exception_type === "except_osv";
+                        if (html) {
+                            var msg = $("<div/>").text(failure.error.message).html();
+                            var data = msg.substring(3,msg.length-2).split(/', u'/);
+                            failure.error.message = '<b>' + data[0] + '</b><br/>' + data[1];
+                        }
+                        $(root).find('.' + failure.id)
+                            .removeClass(failure.id)
+                            .popover({
+                                html: html,
+                                trigger: 'hover',
+                                content: failure.error.message,
+                                placement: 'auto top',
+                            })
+                            // Force-show popovers so users will notice them.
+                            .popover('show');
+                    });
+                });
             });
         },
         /**
          * Saves an RTE content, which always corresponds to a view section (?).
          */
         saveElement: function ($el) {
-            $el.removeClass('oe_dirty');
             var markup = $el.prop('outerHTML');
             return openerp.jsonRpc('/web/dataset/call', 'call', {
                 model: 'ir.ui.view',
             });
         },
         cancel: function () {
-            window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
+            website.reload();
+        },
+        new_page: function (ev) {
+            ev.preventDefault();
+            website.prompt({
+                window_title: "New Page",
+                input: "Page Title",
+            }).then(function (val) {
+                document.location = '/pagenew/' + encodeURI(val);
+            });
         },
     });
 
+    var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
     /* ----- RICH TEXT EDITOR ---- */
     website.RTE = openerp.Widget.extend({
         tagName: 'li',
             this._super.apply(this, arguments);
         },
 
-        start_edition: function ($elements) {
+        /**
+         * In Webkit-based browsers, triple-click will select a paragraph up to
+         * the start of the next "paragraph" including any empty space
+         * inbetween. When said paragraph is removed or altered, it nukes
+         * the empty space and brings part of the content of the next
+         * "paragraph" (which may well be e.g. an image) into the current one,
+         * completely fucking up layouts and breaking snippets.
+         *
+         * Try to fuck around with selections on triple-click to attempt to
+         * fix this garbage behavior.
+         *
+         * Note: for consistent behavior we may actually want to take over
+         * triple-clicks, in all browsers in order to ensure consistent cross-
+         * platform behavior instead of being at the mercy of rendering engines
+         * & platform selection quirks?
+         */
+        webkitSelectionFixer: function (root) {
+            root.addEventListener('click', function (e) {
+                // only webkit seems to have a fucked up behavior, ignore others
+                // FIXME: $.browser goes away in jquery 1.9...
+                if (!$.browser.webkit) { return; }
+                // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
+                // The detail attribute indicates the number of times a mouse button has been pressed
+                // we just want the triple click
+                if (e.detail !== 3) { return; }
+                e.preventDefault();
+
+                // Get closest block-level element to the triple-clicked
+                // element (using ckeditor's block list because why not)
+                var $closest_block = $(e.target).closest(blocks_selector);
+
+                // manually set selection range to the content of the
+                // triple-clicked block-level element, to avoid crossing over
+                // between block-level elements
+                document.getSelection().selectAllChildren($closest_block[0]);
+            });
+        },
+        tableNavigation: function (root) {
+            var self = this;
+            $(root).on('keydown', function (e) {
+                // ignore non-TAB
+                if (e.which !== 9) { return; }
+
+                if (self.handleTab(e)) {
+                    e.preventDefault();
+                }
+            });
+        },
+        /**
+         * Performs whatever operation is necessary on a [TAB] hit, returns
+         * ``true`` if the event's default should be cancelled (if the TAB was
+         * handled by the function)
+         */
+        handleTab: function (event) {
+            var forward = !event.shiftKey;
+
+            var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
+            var $cell = $(root).closest('td,th');
+
+            if (!$cell.length) { return false; }
+
+            var cell = $cell[0];
+
+            // find cell in same row
+            var row = cell.parentNode;
+            var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
+            if (sibling) {
+                document.getSelection().selectAllChildren(sibling);
+                return true;
+            }
+
+            // find cell in previous/next row
+            var table = row.parentNode;
+            var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
+            if (sibling_row) {
+                var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
+                document.getSelection().selectAllChildren(new_cell);
+                return true;
+            }
+
+            // at edge cells, copy word/openoffice behavior: if going backwards
+            // from first cell do nothing, if going forwards from last cell add
+            // a row
+            if (forward) {
+                var row_size = row.cells.length;
+                var new_row = document.createElement('tr');
+                while(row_size--) {
+                    var newcell = document.createElement('td');
+                    // zero-width space
+                    newcell.textContent = '\u200B';
+                    new_row.appendChild(newcell);
+                }
+                table.appendChild(new_row);
+                document.getSelection().selectAllChildren(new_row.cells[0]);
+            }
+
+            return true;
+        },
+        /**
+         * Makes the page editable
+         *
+         * @param {Boolean} [restart=false] in case the edition was already set
+         *                                  up once and is being re-enabled.
+         * @returns {$.Deferred} deferred indicating when the RTE is ready
+         */
+        start_edition: function (restart) {
             var self = this;
             // create a single editor for the whole page
             var root = document.getElementById('wrapwrap');
-            $(root).on('dragstart', 'img', function (e) {
-                e.preventDefault();
-            });
+            if (!restart) {
+                $(root).on('dragstart', 'img', function (e) {
+                    e.preventDefault();
+                });
+                this.webkitSelectionFixer(root);
+                this.tableNavigation(root);
+            }
+            var def = $.Deferred();
             var editor = this.editor = CKEDITOR.inline(root, self._config());
             editor.on('instanceReady', function () {
                 editor.setReadOnly(false);
 
                 self.setup_editables(root);
 
+                try {
+                    // disable firefox's broken table resizing thing
+                    document.execCommand("enableObjectResizing", false, "false");
+                    document.execCommand("enableInlineTableEditing", false, "false");
+                } catch (e) {}
+
                 self.trigger('rte:ready');
+                def.resolve();
             });
+            return def;
         },
 
         setup_editables: function (root) {
             var self = this;
             // setup dirty-marking for each editable element
             this.fetch_editables(root)
-                .prop('contentEditable', true)
                 .addClass('oe_editable')
                 .each(function () {
                     var node = this;
-                    observer.observe(node, OBSERVER_CONFIG);
                     var $node = $(node);
+                    // only explicitly set contenteditable on view sections,
+                    // cke widgets system will do the widgets themselves
+                    if ($node.data('oe-model') === 'ir.ui.view') {
+                        node.contentEditable = true;
+                    }
+
+                    observer.observe(node, OBSERVER_CONFIG);
                     $node.one('content_changed', function () {
                         $node.addClass('oe_dirty');
                         self.trigger('change');
 
         fetch_editables: function (root) {
             return $(root).find('[data-oe-model]')
-                // FIXME: propagation should make "meta" blocks non-editable in the first place...
                 .not('link, script')
                 .not('.oe_snippet_editor')
                 .filter(function () {
                     var $this = $(this);
                     // keep view sections and fields which are *not* in
-                    // view sections for toplevel editables
+                    // view sections for top-level editables
                     return $this.data('oe-model') === 'ir.ui.view'
                        || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
                 });
             // - contextmenu & tabletools (disable contextual menu)
             // - bunch of unused plugins
             var plugins = [
-                'a11yhelp', 'basicstyles', 'bidi', 'blockquote',
+                'a11yhelp', 'basicstyles', 'blockquote',
                 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
                 'elementspath', 'enterkey', 'entities', 'filebrowser',
                 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
                 'indentblock', 'indentlist', 'justify',
                 'list', 'pastefromword', 'pastetext', 'preview',
                 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
-                'tab', 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
+                'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
             ];
             return {
                 // FIXME
                 fillEmptyBlocks: false,
                 filebrowserImageUploadUrl: "/website/attach",
                 // Support for sharedSpaces in 4.x
-                extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
+                extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
                 // Place toolbar in controlled location
                 sharedSpaces: { top: 'oe_rte_toolbar' },
                 toolbar: [{
                         "Image", "TableButton"
                     ]},{
                     name: 'styles', items: [
-                        "Styles"
+                        "Styles", "BootstrapLinkCombo"
                     ]}
                 ],
                 // styles dropdown in toolbar
                     {name: "Heading 5", element: 'h5'},
                     {name: "Heading 6", element: 'h6'},
                     {name: "Formatted", element: 'pre'},
-                    {name: "Address", element: 'address'},
-                    // emphasis
-                    {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
-                    {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
-                    {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
-                    {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
-                    {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
-                    {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
+                    {name: "Address", element: 'address'}
                 ],
             };
         },
         },
         start: function () {
             var sup = this._super();
-            this.$el.modal();
+            this.$el.modal({backdrop: 'static'});
             return sup;
         },
         save: function () {
     website.editor.LinkDialog = website.editor.Dialog.extend({
         template: 'website.editor.dialog.link',
         events: _.extend({}, website.editor.Dialog.prototype.events, {
-            'change .url-source': function (e) { this.changed($(e.target)); },
+            'change :input.url-source': function (e) { this.changed($(e.target)); },
             'mousedown': function (e) {
                 var $target = $(e.target).closest('.list-group-item');
                 if (!$target.length || $target.hasClass('active')) {
                     return;
                 }
 
-                this.changed($target.find('.url-source'));
+                this.changed($target.find('.url-source').filter(':input'));
             },
             'click button.remove': 'remove_link',
             'change input#link-text': function (e) {
         }),
         init: function (editor) {
             this._super(editor);
-            // url -> name mapping for existing pages
-            this.pages = Object.create(null);
             this.text = null;
+            // Store last-performed request to be able to cancel/abort it.
+            this.req = null;
         },
         start: function () {
+            var self = this;
+            this.$('#link-page').select2({
+                minimumInputLength: 3,
+                placeholder: _t("New or existing page"),
+                query: function (q) {
+                    // FIXME: out-of-order, abort
+                    self.fetch_pages(q.term).then(function (results) {
+                        var rs = _.map(results, function (r) {
+                            return { id: r.url, text: r.name, };
+                        });
+                        rs.push({
+                            create: true,
+                            id: q.term,
+                            text: _.str.sprintf(_t("Create page '%s'"), q.term),
+                        });
+                        q.callback({
+                            more: false,
+                            results: rs
+                        });
+                    });
+                },
+            });
+            return this._super().then(this.proxy('bind_data'));
+        },
+        save: function () {
+            var self = this, _super = this._super.bind(this);
+            var $e = this.$('.list-group-item.active .url-source').filter(':input');
+            var val = $e.val();
+            if (!val || !$e[0].checkValidity()) {
+                // FIXME: error message
+                $e.closest('.form-group').addClass('has-error');
+                $e.focus();
+                return;
+            }
+
+            var done = $.when();
+            if ($e.hasClass('email-address')) {
+                this.make_link('mailto:' + val, false, val);
+            } else if ($e.hasClass('page')) {
+                var data = $e.select2('data');
+                if (!data.create) {
+                    self.make_link(data.id, false, data.text);
+                } else {
+                    // Create the page, get the URL back
+                    done = $.get(_.str.sprintf(
+                            '/pagenew/%s?noredirect', encodeURI(data.id)))
+                        .then(function (response) {
+                            self.make_link(response, false, data.id);
+                        });
+                }
+            } else {
+                this.make_link(val, this.$('input.window-new').prop('checked'));
+            }
+            done.then(_super);
+        },
+        make_link: function (url, new_window, label) {
+        },
+        bind_data: function (text, href, new_window) {
+            href = href || this.element && (this.element.data( 'cke-saved-href')
+                                    ||  this.element.getAttribute('href'));
+            if (!href) { return; }
+
+            if (new_window === undefined) {
+                new_window = this.element.getAttribute('target') === '_blank';
+            }
+            if (text === undefined) {
+                text = this.element.getText();
+            }
+
+            var match, $control;
+            if ((match = /mailto:(.+)/.exec(href))) {
+                $control = this.$('input.email-address').val(match[1]);
+            }
+            if (!$control) {
+                $control = this.$('input.url').val(href);
+            }
+
+            this.changed($control);
+
+            this.$('input#link-text').val(text);
+            this.$('input.window-new').prop('checked', new_window);
+        },
+        changed: function ($e) {
+            this.$('.url-source').filter(':input').not($e).val('')
+                    .filter(function () { return !!$(this).data('select2'); })
+                    .select2('data', null);
+            $e.closest('.list-group-item')
+                .addClass('active')
+                .siblings().removeClass('active')
+                .addBack().removeClass('has-error');
+        },
+        fetch_pages: function (term) {
+            var self = this;
+            if (this.req) { this.req.abort(); }
+            return this.req = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
+                model: 'website',
+                method: 'search_pages',
+                args: [null, term],
+                kwargs: {
+                    limit: 9,
+                    context: website.get_context()
+                },
+            }).done(function () {
+                // request completed successfully -> unstore it
+                self.req = null;
+            });
+        },
+    });
+    website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
+        start: function () {
             var element;
             if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
                 this.editor.getSelection().selectElement(element);
                 this.add_removal_button();
             }
 
-            return $.when(
-                this.fetch_pages().done(this.proxy('fill_pages')),
-                this._super()
-            ).done(this.proxy('bind_data'));
+            return this._super();
         },
         add_removal_button: function () {
             this.$('.modal-footer').prepend(
                 }, 0);
             }
         },
-        save: function () {
-            var self = this, _super = this._super.bind(this);
-            var $e = this.$('.list-group-item.active .url-source');
-            var val = $e.val();
-            if (!val || !$e[0].checkValidity()) {
-                // FIXME: error message
-                $e.closest('.form-group').addClass('has-error');
-                return;
-            }
-
-            var done = $.when();
-            if ($e.hasClass('email-address')) {
-                this.make_link('mailto:' + val, false, val);
-            } else if ($e.hasClass('existing')) {
-                self.make_link(val, false, this.pages[val]);
-            } else if ($e.hasClass('pages')) {
-                // Create the page, get the URL back
-                done = $.get(_.str.sprintf(
-                        '/pagenew/%s?noredirect', encodeURI(val)))
-                    .then(function (response) {
-                        self.make_link(response, false, val);
-                    });
-            } else {
-                this.make_link(val, this.$('input.window-new').prop('checked'));
-            }
-            done.then(_super);
-        },
-        bind_data: function () {
-            var href = this.element && (this.element.data( 'cke-saved-href')
-                                    ||  this.element.getAttribute('href'));
-            if (!href) { return; }
-
-            var match, $control;
-            if (match = /mailto:(.+)/.exec(href)) {
-                $control = this.$('input.email-address').val(match[1]);
-            } else if (href in this.pages) {
-                $control = this.$('select.existing').val(href);
-            } else if (match = /\/page\/(.+)/.exec(href)) {
-                var actual_href = '/page/website.' + match[1];
-                if (actual_href in this.pages) {
-                    $control = this.$('select.existing').val(actual_href);
-                }
-            }
-            if (!$control) {
-                $control = this.$('input.url').val(href);
-            }
-
-            this.changed($control);
-
-            this.$('input#link-text').val(this.element.getText());
-            this.$('input.window-new').prop(
-                'checked', this.element.getAttribute('target') === '_blank');
-        },
-        changed: function ($e) {
-            this.$('.url-source').not($e).val('');
-            $e.closest('.list-group-item')
-                .addClass('active')
-                .siblings().removeClass('active')
-                .addBack().removeClass('has-error');
-        },
         /**
          * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
          * if the editor is set directly on a link it will thus not work.
         get_selected_link: function () {
             return get_selected_link(this.editor);
         },
-        fetch_pages: function () {
-            return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
-                model: 'website',
-                method: 'list_pages',
-                args: [null],
-                kwargs: {
-                    context: website.get_context()
-                },
-            });
-        },
-        fill_pages: function (results) {
-            var self = this;
-            var pages = this.$('select.existing')[0];
-            _(results).each(function (result) {
-                self.pages[result.url] = result.name;
-
-                pages.options[pages.options.length] =
-                        new Option(result.name, result.url);
-            });
-        },
     });
+
     /**
      * ImageDialog widget. Lets users change an image, including uploading a
      * new image in OpenERP or selecting the image style (if supported by
         }),
 
         start: function () {
+            this.$('.modal-footer [disabled]').text("Uploading…");
             var $options = this.$('.image-style').children();
             this.image_styles = $options.map(function () { return this.value; }).get();
 
             this.preview_image();
         },
 
-        file_selection: function (e) {
+        file_selection: function () {
+            this.$el.addClass('nosave');
             this.$('button.filepicker').removeClass('btn-danger btn-success');
 
             var self = this;
             this.set_image(url);
         },
         preview_image: function () {
+            this.$el.removeClass('nosave');
             var image = this.$('input.url').val();
             if (!image) { return; }
 
             if (!(element = this.element)) {
                 element = editor.document.createElement('img');
                 element.addClass('img');
+                element.addClass('img-responsive');
                 // focus event handler interactions between bootstrap (modal)
                 // and ckeditor (RTE) lead to blowing the stack in Safari and
                 // Chrome (but not FF) when this is done synchronously =>
 
             var style = data.style;
             element.setAttribute('src', data.url);
+            element.removeAttribute('data-cke-saved-src');
             $(element.$).removeClass(this.image_styles.join(' '));
             if (style) { element.addClass(style); }
         },
     }
 
 
-    var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
+    website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
     var OBSERVER_CONFIG = {
         childList: true,
         attributes: true,
         subtree: true,
         attributeOldValue: true,
     };
-    var observer = new Observer(function (mutations) {
+    var observer = new website.Observer(function (mutations) {
         // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
         //       relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
         //       will not mark dirty on attribute changes (@class, img/@src,