4 var website = openerp.website;
5 // $.fn.data automatically parses value, '0'|'1' -> 0|1
7 website.templates.push('/website/static/src/xml/website.editor.xml');
8 website.dom_ready.done(function () {
9 var is_smartphone = $(document.body)[0].clientWidth < 767;
12 website.ready().then(website.init_editor);
16 function link_dialog(editor) {
17 return new website.editor.LinkDialog(editor).appendTo(document.body);
19 function image_dialog(editor) {
20 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
23 // only enable editors manually
24 CKEDITOR.disableAutoInline = true;
25 // EDIT ALL THE THINGS
26 CKEDITOR.dtd.$editable = $.extend(
27 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
28 // Disable removal of empty elements on CKEDITOR activation. Empty
29 // elements are used for e.g. support of FontAwesome icons
30 CKEDITOR.dtd.$removeEmpty = {};
32 website.init_editor = function () {
33 CKEDITOR.plugins.add('customdialogs', {
34 // requires: 'link,image',
35 init: function (editor) {
36 editor.on('doubleclick', function (evt) {
37 var element = evt.data.element;
39 && !element.data('cke-realelement')
40 && !element.isReadOnly()
41 && (element.data('oe-model') !== 'ir.ui.view')) {
46 element = get_selected_link(editor) || evt.data.element;
47 if (element.isReadOnly()
49 || element.data('oe-model')) {
53 editor.getSelection().selectElement(element);
57 //noinspection JSValidateTypes
58 editor.addCommand('link', {
59 exec: function (editor) {
66 //noinspection JSValidateTypes
67 editor.addCommand('image', {
68 exec: function (editor) {
76 editor.ui.addButton('Link', {
80 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
82 editor.ui.addButton('Image', {
86 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
89 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
92 CKEDITOR.plugins.add( 'tablebutton', {
93 requires: 'panelbutton,floatpanel',
94 init: function( editor ) {
97 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
100 // use existing 'table' icon
102 modes: { wysiwyg: true },
104 // panel opens in iframe, @css is CSS file <link>-ed within
105 // frame document, @attributes are set on iframe itself.
107 css: '/website/static/src/css/editor.css',
108 attributes: { 'role': 'listbox', 'aria-label': label, },
111 onBlock: function (panel, block) {
112 block.autoSize = true;
113 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
118 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
119 var $e = $(e.target);
120 var y = $e.index() + 1;
121 var x = $e.closest('tr').index() + 1;
124 .find('td').removeClass('selected').end()
125 .find('tr:lt(' + String(x) + ')')
126 .children().filter(function () { return $(this).index() < y; })
127 .addClass('selected');
128 }).on('click', 'td', function (e) {
129 var $e = $(e.target);
131 //noinspection JSPotentiallyInvalidConstructorUsage
132 var table = new CKEDITOR.dom.element(
133 $(openerp.qweb.render('website.editor.table', {
134 rows: $e.closest('tr').index() + 1,
135 cols: $e.index() + 1,
138 editor.insertElement(table);
139 setTimeout(function () {
140 //noinspection JSPotentiallyInvalidConstructorUsage
141 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
142 var range = editor.createRange();
143 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
148 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
149 CKEDITOR.ui.fire('ready', this);
155 CKEDITOR.plugins.add('oeref', {
158 init: function (editor) {
159 editor.widgets.add('oeref', {
160 editables: { text: '*' },
162 upcast: function (el) {
163 return el.attributes['data-oe-type']
164 && el.attributes['data-oe-type'] !== 'monetary';
167 editor.widgets.add('monetary', {
168 editables: { text: 'span.oe_currency_value' },
170 upcast: function (el) {
171 return el.attributes['data-oe-type'] === 'monetary';
177 var editor = new website.EditorBar();
178 var $body = $(document.body);
179 editor.prependTo($body).then(function () {
180 if (location.search.indexOf("enable_editor") >= 0) {
184 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
186 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
187 website.EditorBar = openerp.Widget.extend({
188 template: 'website.editorbar',
190 'click button[data-action=edit]': 'edit',
191 'click button[data-action=save]': 'save',
192 'click button[data-action=cancel]': 'cancel',
195 customize_setup: function() {
197 var view_name = $(document.documentElement).data('view-xmlid');
198 var menu = $('#customize-menu');
199 this.$('#customize-menu-button').click(function(event) {
201 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
203 _.each(result, function (item) {
205 menu.append('<li class="dropdown-header">' + item.name + '</li>');
207 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
208 item.id, item.active ? '' : '-empty', item.name));
211 // Adding Static Menus
212 menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
213 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
214 self.trigger('rte:customize_menu_ready');
218 menu.on('click', 'a[data-action!=ace]', function (event) {
219 var view_id = $(event.currentTarget).data('view-id');
220 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
222 }).then( function() {
223 window.location.reload();
230 this.saving_mutex = new openerp.Mutex();
232 this.$('#website-top-edit').hide();
233 this.$('#website-top-view').show();
235 $('.dropdown-toggle').dropdown();
236 this.customize_setup();
239 edit: this.$('button[data-action=edit]'),
240 save: this.$('button[data-action=save]'),
241 cancel: this.$('button[data-action=cancel]'),
244 this.rte = new website.RTE(this);
245 this.rte.on('change', this, this.proxy('rte_changed'));
246 this.rte.on('rte:ready', this, function () {
247 self.trigger('rte:ready');
251 this._super.apply(this, arguments),
252 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
256 this.$buttons.edit.prop('disabled', true);
257 this.$('#website-top-view').hide();
258 this.$('#website-top-edit').show();
259 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
261 this.rte.start_edition();
263 rte_changed: function () {
264 this.$buttons.save.prop('disabled', false);
269 observer.disconnect();
270 var editor = this.rte.editor;
271 var root = editor.element.$;
273 // FIXME: select editables then filter by dirty?
274 var defs = this.rte.fetch_editables(root)
276 .removeAttr('contentEditable')
277 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
280 // TODO: Add a queue with concurrency limit in webclient
281 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
282 return self.saving_mutex.exec(function () {
283 return self.saveElement($el)
284 .then(undefined, function (thing, response) {
285 // because ckeditor regenerates all the dom,
286 // we can't just setup the popover here as
287 // everything will be destroyed by the DOM
288 // regeneration. Add markings instead, and
289 // returns a new rejection with all relevant
291 var id = _.uniqueId('carlos_danger_');
292 $el.addClass('oe_dirty oe_carlos_danger');
294 return $.Deferred().reject({
296 error: response.data,
301 return $.when.apply(null, defs).then(function () {
303 }, function (failed) {
304 // If there were errors, re-enable edition
305 self.rte.start_edition(true).then(function () {
306 // jquery's deferred being a pain in the ass
307 if (!_.isArray(failed)) { failed = [failed]; }
309 _(failed).each(function (failure) {
310 $(root).find('.' + failure.id)
311 .removeClass(failure.id)
314 content: failure.error.message,
315 placement: 'auto top',
317 // Force-show popovers so users will notice them.
324 * Saves an RTE content, which always corresponds to a view section (?).
326 saveElement: function ($el) {
327 var markup = $el.prop('outerHTML');
328 return openerp.jsonRpc('/web/dataset/call', 'call', {
331 args: [$el.data('oe-id'), markup,
332 $el.data('oe-xpath') || null,
333 website.get_context()],
336 cancel: function () {
341 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
342 /* ----- RICH TEXT EDITOR ---- */
343 website.RTE = openerp.Widget.extend({
345 id: 'oe_rte_toolbar',
346 className: 'oe_right oe_rte_toolbar',
347 // editor.ui.items -> possible commands &al
348 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
350 init: function (EditorBar) {
351 this.EditorBar = EditorBar;
352 this._super.apply(this, arguments);
356 * In Webkit-based browsers, triple-click will select a paragraph up to
357 * the start of the next "paragraph" including any empty space
358 * inbetween. When said paragraph is removed or altered, it nukes
359 * the empty space and brings part of the content of the next
360 * "paragraph" (which may well be e.g. an image) into the current one,
361 * completely fucking up layouts and breaking snippets.
363 * Try to fuck around with selections on triple-click to attempt to
364 * fix this garbage behavior.
366 * Note: for consistent behavior we may actually want to take over
367 * triple-clicks, in all browsers in order to ensure consistent cross-
368 * platform behavior instead of being at the mercy of rendering engines
369 * & platform selection quirks?
371 webkitSelectionFixer: function (root) {
372 root.addEventListener('click', function (e) {
373 // only webkit seems to have a fucked up behavior, ignore others
374 // FIXME: $.browser goes away in jquery 1.9...
375 if (!$.browser.webkit) { return; }
376 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
377 // The detail attribute indicates the number of times a mouse button has been pressed
378 // we just want the triple click
379 if (e.detail !== 3) { return; }
382 // Get closest block-level element to the triple-clicked
383 // element (using ckeditor's block list because why not)
384 var $closest_block = $(e.target).closest(blocks_selector);
386 // manually set selection range to the content of the
387 // triple-clicked block-level element, to avoid crossing over
388 // between block-level elements
389 document.getSelection().selectAllChildren($closest_block[0]);
392 tableNavigation: function (root) {
394 $(root).on('keydown', function (e) {
396 if (e.which !== 9) { return; }
398 if (self.handleTab(e)) {
404 * Performs whatever operation is necessary on a [TAB] hit, returns
405 * ``true`` if the event's default should be cancelled (if the TAB was
406 * handled by the function)
408 handleTab: function (event) {
409 var forward = !event.shiftKey;
411 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
412 var $cell = $(root).closest('td,th');
414 if (!$cell.length) { return false; }
418 // find cell in same row
419 var row = cell.parentNode;
420 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
422 document.getSelection().selectAllChildren(sibling);
426 // find cell in previous/next row
427 var table = row.parentNode;
428 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
430 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
431 document.getSelection().selectAllChildren(new_cell);
435 // at edge cells, copy word/openoffice behavior: if going backwards
436 // from first cell do nothing, if going forwards from last cell add
439 var row_size = row.cells.length;
440 var new_row = document.createElement('tr');
442 var newcell = document.createElement('td');
444 newcell.textContent = '\u200B';
445 new_row.appendChild(newcell);
447 table.appendChild(new_row);
448 document.getSelection().selectAllChildren(new_row.cells[0]);
454 * Makes the page editable
456 * @param {Boolean} [restart=false] in case the edition was already set
457 * up once and is being re-enabled.
458 * @returns {$.Deferred} deferred indicating when the RTE is ready
460 start_edition: function (restart) {
462 // create a single editor for the whole page
463 var root = document.getElementById('wrapwrap');
465 $(root).on('dragstart', 'img', function (e) {
468 this.webkitSelectionFixer(root);
469 this.tableNavigation(root);
471 var def = $.Deferred();
472 var editor = this.editor = CKEDITOR.inline(root, self._config());
473 editor.on('instanceReady', function () {
474 editor.setReadOnly(false);
475 // ckeditor set root to editable, disable it (only inner
476 // sections are editable)
477 // FIXME: are there cases where the whole editor is editable?
478 editor.editable().setReadOnly(true);
480 self.setup_editables(root);
482 // disable firefox's broken table resizing thing
483 document.execCommand("enableObjectResizing", false, "false");
484 document.execCommand("enableInlineTableEditing", false, "false");
486 self.trigger('rte:ready');
492 setup_editables: function (root) {
493 // selection of editable sub-items was previously in
494 // EditorBar#edit, but for some unknown reason the elements were
495 // apparently removed and recreated (?) at editor initalization,
496 // and observer setup was lost.
498 // setup dirty-marking for each editable element
499 this.fetch_editables(root)
500 .addClass('oe_editable')
504 // only explicitly set contenteditable on view sections,
505 // cke widgets system will do the widgets themselves
506 if ($node.data('oe-model') === 'ir.ui.view') {
507 node.contentEditable = true;
510 observer.observe(node, OBSERVER_CONFIG);
511 $node.one('content_changed', function () {
512 $node.addClass('oe_dirty');
513 self.trigger('change');
518 fetch_editables: function (root) {
519 return $(root).find('[data-oe-model]')
520 // FIXME: propagation should make "meta" blocks non-editable in the first place...
522 .not('.oe_snippet_editor')
523 .filter(function () {
525 // keep view sections and fields which are *not* in
526 // view sections for top-level editables
527 return $this.data('oe-model') === 'ir.ui.view'
528 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
532 _current_editor: function () {
533 return CKEDITOR.currentInstance;
535 _config: function () {
536 // base plugins minus
537 // - magicline (captures mousein/mouseout -> breaks draggable)
538 // - contextmenu & tabletools (disable contextual menu)
539 // - bunch of unused plugins
541 'a11yhelp', 'basicstyles', 'blockquote',
542 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
543 'elementspath', 'enterkey', 'entities', 'filebrowser',
544 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
545 'indentblock', 'indentlist', 'justify',
546 'list', 'pastefromword', 'pastetext', 'preview',
547 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
548 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
553 // Disable auto-generated titles
554 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
556 plugins: plugins.join(','),
558 // FIXME: currently breaks RTE?
559 // Ensure no config file is loaded
562 allowedContent: true,
563 // Don't insert paragraphs around content in e.g. <li>
564 autoParagraph: false,
565 // Don't automatically add or <br> in empty block-level
566 // elements when edition starts
567 fillEmptyBlocks: false,
568 filebrowserImageUploadUrl: "/website/attach",
569 // Support for sharedSpaces in 4.x
570 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
571 // Place toolbar in controlled location
572 sharedSpaces: { top: 'oe_rte_toolbar' },
574 name: 'clipboard', items: [
577 name: 'basicstyles', items: [
578 "Bold", "Italic", "Underline", "Strike", "Subscript",
579 "Superscript", "TextColor", "BGColor", "RemoveFormat"
581 name: 'span', items: [
582 "Link", "Blockquote", "BulletedList",
583 "NumberedList", "Indent", "Outdent"
585 name: 'justify', items: [
586 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
588 name: 'special', items: [
589 "Image", "TableButton"
591 name: 'styles', items: [
595 // styles dropdown in toolbar
597 {name: "Normal", element: 'p'},
598 {name: "Heading 1", element: 'h1'},
599 {name: "Heading 2", element: 'h2'},
600 {name: "Heading 3", element: 'h3'},
601 {name: "Heading 4", element: 'h4'},
602 {name: "Heading 5", element: 'h5'},
603 {name: "Heading 6", element: 'h6'},
604 {name: "Formatted", element: 'pre'},
605 {name: "Address", element: 'address'},
607 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
608 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
609 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
610 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
611 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
612 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
618 website.editor = { };
619 website.editor.Dialog = openerp.Widget.extend({
621 'hidden.bs.modal': 'destroy',
622 'click button.save': 'save',
624 init: function (editor) {
626 this.editor = editor;
629 var sup = this._super();
630 this.$el.modal({backdrop: 'static'});
637 this.$el.modal('hide');
641 website.editor.LinkDialog = website.editor.Dialog.extend({
642 template: 'website.editor.dialog.link',
643 events: _.extend({}, website.editor.Dialog.prototype.events, {
644 'change .url-source': function (e) { this.changed($(e.target)); },
645 'mousedown': function (e) {
646 var $target = $(e.target).closest('.list-group-item');
647 if (!$target.length || $target.hasClass('active')) {
648 // clicked outside groups, or clicked in active groups
652 this.changed($target.find('.url-source'));
654 'click button.remove': 'remove_link',
655 'change input#link-text': function (e) {
656 this.text = $(e.target).val()
659 init: function (editor) {
661 // url -> name mapping for existing pages
662 this.pages = Object.create(null);
667 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
668 this.editor.getSelection().selectElement(element);
670 this.element = element;
672 this.add_removal_button();
676 this.fetch_pages().done(this.proxy('fill_pages')),
677 this.fetch_menus().done(this.proxy('fill_menus')),
679 ).done(this.proxy('bind_data'));
681 add_removal_button: function () {
682 this.$('.modal-footer').prepend(
684 'website.editor.dialog.link.footer-button'));
686 remove_link: function () {
687 var editor = this.editor;
688 // same issue as in make_link
689 setTimeout(function () {
690 editor.removeStyle(new CKEDITOR.style({
692 type: CKEDITOR.STYLE_INLINE,
693 alwaysRemoveElement: true,
699 * Greatly simplified version of CKEDITOR's
700 * plugins.link.dialogs.link.onOk.
702 * @param {String} url
703 * @param {Boolean} [new_window=false]
704 * @param {String} [label=null]
706 make_link: function (url, new_window, label) {
707 var attributes = {href: url, 'data-cke-saved-href': url};
710 attributes['target'] = '_blank';
712 to_remove.push('target');
716 this.element.setAttributes(attributes);
717 this.element.removeAttributes(to_remove);
718 if (this.text) { this.element.setText(this.text); }
720 var selection = this.editor.getSelection();
721 var range = selection.getRanges(true)[0];
723 if (range.collapsed) {
724 //noinspection JSPotentiallyInvalidConstructorUsage
725 var text = new CKEDITOR.dom.text(
726 this.text || label || url);
727 range.insertNode(text);
728 range.selectNodeContents(text);
731 //noinspection JSPotentiallyInvalidConstructorUsage
733 type: CKEDITOR.STYLE_INLINE,
735 attributes: attributes,
736 }).applyToRange(range);
738 // focus dance between RTE & dialog blow up the stack in Safari
739 // and Chrome, so defer select() until dialog has been closed
740 setTimeout(function () {
746 var self = this, _super = this._super.bind(this);
747 var $e = this.$('.list-group-item.active .url-source');
749 if (!val || !$e[0].checkValidity()) {
750 // FIXME: error message
751 $e.closest('.form-group').addClass('has-error');
756 if ($e.hasClass('email-address')) {
757 this.make_link('mailto:' + val, false, val);
758 } else if ($e.hasClass('existing')) {
759 self.make_link(val, false, this.pages[val]);
760 } else if ($e.hasClass('pages')) {
761 // Create the page, get the URL back
762 done = $.get(_.str.sprintf(
763 '/pagenew/%s?noredirect', encodeURI(val)))
764 .then(function (response) {
765 self.make_link(response, false, val);
766 var parent_id = self.$('.add-to-menu').val();
768 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
769 model: 'website.menu',
774 'sequence': 0, // TODO: better tree widget
775 'website_id': website.id,
776 'parent_id': parent_id|0,
779 context: website.get_context()
785 this.make_link(val, this.$('input.window-new').prop('checked'));
789 bind_data: function () {
790 var href = this.element && (this.element.data( 'cke-saved-href')
791 || this.element.getAttribute('href'));
792 if (!href) { return; }
795 if (match = /mailto:(.+)/.exec(href)) {
796 $control = this.$('input.email-address').val(match[1]);
797 } else if (href in this.pages) {
798 $control = this.$('select.existing').val(href);
799 } else if (match = /\/page\/(.+)/.exec(href)) {
800 var actual_href = '/page/website.' + match[1];
801 if (actual_href in this.pages) {
802 $control = this.$('select.existing').val(actual_href);
806 $control = this.$('input.url').val(href);
809 this.changed($control);
811 this.$('input#link-text').val(this.element.getText());
812 this.$('input.window-new').prop(
813 'checked', this.element.getAttribute('target') === '_blank');
815 changed: function ($e) {
816 this.$('.url-source').not($e).val('');
817 $e.closest('.list-group-item')
819 .siblings().removeClass('active')
820 .addBack().removeClass('has-error');
823 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
824 * if the editor is set directly on a link it will thus not work.
826 get_selected_link: function () {
827 return get_selected_link(this.editor);
829 fetch_pages: function () {
830 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
832 method: 'list_pages',
835 context: website.get_context()
839 fill_pages: function (results) {
841 var pages = this.$('select.existing')[0];
842 _(results).each(function (result) {
843 self.pages[result.url] = result.name;
845 pages.options[pages.options.length] =
846 new Option(result.name, result.url);
849 fetch_menus: function () {
850 var context = website.get_context();
851 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
852 model: 'website.menu',
854 args: [[context.website_id]],
860 fill_menus: function (results) {
862 var menus = this.$('select.add-to-menu')[0];
863 _(results).each(function (result) {
864 var name = (new Array(result.level).join('|-')) + ' ' + result.name;
865 menus.options[menus.options.length] =
866 new Option(name, result.id || 0);
871 * ImageDialog widget. Lets users change an image, including uploading a
872 * new image in OpenERP or selecting the image style (if supported by
875 * Initialized as usual, but the caller can hook into two events:
877 * @event start({url, style}) called during dialog initialization and
878 * opening, the handler can *set* the ``url``
879 * and ``style`` properties on its parameter
880 * to provide these as default values to the
882 * @event save({url, style}) called during dialog finalization, the handler
883 * is provided with the image url and style
884 * selected by the users (or possibly the ones
885 * originally passed in)
887 website.editor.ImageDialog = website.editor.Dialog.extend({
888 template: 'website.editor.dialog.image',
889 events: _.extend({}, website.editor.Dialog.prototype.events, {
890 'change .url-source': function (e) { this.changed($(e.target)); },
891 'click button.filepicker': function () {
892 this.$('input[type=file]').click();
894 'change input[type=file]': 'file_selection',
895 'change input.url': 'preview_image',
896 'click a[href=#existing]': 'browse_existing',
897 'change select.image-style': 'preview_image',
901 var $options = this.$('.image-style').children();
902 this.image_styles = $options.map(function () { return this.value; }).get();
904 var o = { url: null, style: null, };
905 // avoid typos, prevent addition of new properties to the object
906 Object.preventExtensions(o);
907 this.trigger('start', o);
911 this.$('.image-style').val(o.style);
913 this.set_image(o.url);
916 return this._super();
919 this.trigger('save', {
920 url: this.$('input.url').val(),
921 style: this.$('.image-style').val(),
923 return this._super();
927 * Sets the provided image url as the dialog's value-to-save and
928 * refreshes the preview element to use it.
930 set_image: function (url) {
931 this.$('input.url').val(url);
932 this.preview_image();
935 file_selection: function () {
936 this.$('button.filepicker').removeClass('btn-danger btn-success');
939 var callback = _.uniqueId('func_');
940 this.$('input[name=func]').val(callback);
942 window[callback] = function (url, error) {
943 delete window[callback];
944 self.file_selected(url, error);
946 this.$('form').submit();
948 file_selected: function(url, error) {
949 var $button = this.$('button.filepicker');
951 $button.addClass('btn-danger');
954 $button.addClass('btn-success');
957 preview_image: function () {
958 var image = this.$('input.url').val();
959 if (!image) { return; }
961 this.$('img.image-preview')
963 .removeClass(this.image_styles.join(' '))
964 .addClass(this.$('select.image-style').val());
966 browse_existing: function (e) {
968 new website.editor.ExistingImageDialog(this).appendTo(document.body);
971 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
973 this._super.apply(this, arguments);
975 this.on('start', this, this.proxy('started'));
976 this.on('save', this, this.proxy('saved'));
978 started: function (holder) {
979 var selection = this.editor.getSelection();
980 var el = selection && selection.getSelectedElement();
983 if (el && el.is('img')) {
985 _(this.image_styles).each(function (style) {
986 if (el.hasClass(style)) {
987 holder.style = style;
990 holder.url = el.getAttribute('src');
993 saved: function (data) {
994 var element, editor = this.editor;
995 if (!(element = this.element)) {
996 element = editor.document.createElement('img');
997 element.addClass('img');
998 // focus event handler interactions between bootstrap (modal)
999 // and ckeditor (RTE) lead to blowing the stack in Safari and
1000 // Chrome (but not FF) when this is done synchronously =>
1001 // defer insertion so modal has been hidden & destroyed before
1003 setTimeout(function () {
1004 editor.insertElement(element);
1008 var style = data.style;
1009 element.setAttribute('src', data.url);
1010 element.removeAttribute('data-cke-saved-src');
1011 $(element.$).removeClass(this.image_styles.join(' '));
1012 if (style) { element.addClass(style); }
1016 var IMAGES_PER_ROW = 6;
1017 var IMAGES_ROWS = 4;
1018 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1019 template: 'website.editor.dialog.image.existing',
1020 events: _.extend({}, website.editor.Dialog.prototype.events, {
1021 'click .existing-attachments img': 'select_existing',
1022 'click .pager > li': function (e) {
1024 var $target = $(e.currentTarget);
1025 if ($target.hasClass('disabled')) {
1028 this.page += $target.hasClass('previous') ? -1 : 1;
1029 this.display_attachments();
1032 init: function (parent) {
1035 this.parent = parent;
1036 this._super(parent.editor);
1039 start: function () {
1042 this.fetch_existing().then(this.proxy('fetched_existing')));
1045 fetch_existing: function () {
1046 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1047 model: 'ir.attachment',
1048 method: 'search_read',
1051 fields: ['name', 'website_url'],
1052 domain: [['res_model', '=', 'ir.ui.view']],
1054 context: website.get_context(),
1058 fetched_existing: function (records) {
1059 this.records = records;
1060 this.display_attachments();
1062 display_attachments: function () {
1063 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1065 var from = this.page * per_screen;
1066 var records = this.records;
1068 // Create rows of 3 records
1069 var rows = _(records).chain()
1070 .slice(from, from + per_screen)
1071 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1075 this.$('.existing-attachments').replaceWith(
1076 openerp.qweb.render(
1077 'website.editor.dialog.image.existing.content', {rows: rows}));
1079 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1080 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1083 select_existing: function (e) {
1084 var link = $(e.currentTarget).attr('src');
1086 this.parent.set_image(link);
1092 function get_selected_link(editor) {
1093 var sel = editor.getSelection(),
1094 el = sel.getSelectedElement();
1095 if (el && el.is('a')) { return el; }
1097 var range = sel.getRanges(true)[0];
1098 if (!range) { return null; }
1100 range.shrink(CKEDITOR.SHRINK_TEXT);
1101 var commonAncestor = range.getCommonAncestor();
1102 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1103 return element.data('oe-model') === 'ir.ui.view'
1105 if (!viewRoot) { return null; }
1106 // if viewRoot is the first link, don't edit it.
1107 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1108 .contains('a', true);
1112 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1113 var OBSERVER_CONFIG = {
1116 characterData: true,
1118 attributeOldValue: true,
1120 var observer = new website.Observer(function (mutations) {
1121 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1122 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1123 // will not mark dirty on attribute changes (@class, img/@src,
1125 _(mutations).chain()
1126 .filter(function (m) {
1128 case 'attributes': // ignore .cke_focus being added or removed
1129 // if attribute is not a class, can't be .cke_focus change
1130 if (m.attributeName !== 'class') { return true; }
1132 // find out what classes were added or removed
1133 var oldClasses = (m.oldValue || '').split(/\s+/);
1134 var newClasses = m.target.className.split(/\s+/);
1135 var change = _.union(_.difference(oldClasses, newClasses),
1136 _.difference(newClasses, oldClasses));
1137 // ignore mutation if the *only* change is .cke_focus
1138 return change.length !== 1 || change[0] === 'cke_focus';
1140 // <br type="_moz"> appears when focusing RTE in FF, ignore
1141 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1147 var node = m.target;
1148 while (node && !$(node).hasClass('oe_editable')) {
1149 node = node.parentNode;
1151 $(m.target).trigger('node_changed');
1156 .each(function (node) { $(node).trigger('content_changed'); })