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.RTELinkDialog(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]')
521 .not('.oe_snippet_editor')
522 .filter(function () {
524 // keep view sections and fields which are *not* in
525 // view sections for top-level editables
526 return $this.data('oe-model') === 'ir.ui.view'
527 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
531 _current_editor: function () {
532 return CKEDITOR.currentInstance;
534 _config: function () {
535 // base plugins minus
536 // - magicline (captures mousein/mouseout -> breaks draggable)
537 // - contextmenu & tabletools (disable contextual menu)
538 // - bunch of unused plugins
540 'a11yhelp', 'basicstyles', 'blockquote',
541 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
542 'elementspath', 'enterkey', 'entities', 'filebrowser',
543 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
544 'indentblock', 'indentlist', 'justify',
545 'list', 'pastefromword', 'pastetext', 'preview',
546 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
547 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
552 // Disable auto-generated titles
553 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
555 plugins: plugins.join(','),
557 // FIXME: currently breaks RTE?
558 // Ensure no config file is loaded
561 allowedContent: true,
562 // Don't insert paragraphs around content in e.g. <li>
563 autoParagraph: false,
564 // Don't automatically add or <br> in empty block-level
565 // elements when edition starts
566 fillEmptyBlocks: false,
567 filebrowserImageUploadUrl: "/website/attach",
568 // Support for sharedSpaces in 4.x
569 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
570 // Place toolbar in controlled location
571 sharedSpaces: { top: 'oe_rte_toolbar' },
573 name: 'clipboard', items: [
576 name: 'basicstyles', items: [
577 "Bold", "Italic", "Underline", "Strike", "Subscript",
578 "Superscript", "TextColor", "BGColor", "RemoveFormat"
580 name: 'span', items: [
581 "Link", "Blockquote", "BulletedList",
582 "NumberedList", "Indent", "Outdent"
584 name: 'justify', items: [
585 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
587 name: 'special', items: [
588 "Image", "TableButton"
590 name: 'styles', items: [
594 // styles dropdown in toolbar
596 {name: "Normal", element: 'p'},
597 {name: "Heading 1", element: 'h1'},
598 {name: "Heading 2", element: 'h2'},
599 {name: "Heading 3", element: 'h3'},
600 {name: "Heading 4", element: 'h4'},
601 {name: "Heading 5", element: 'h5'},
602 {name: "Heading 6", element: 'h6'},
603 {name: "Formatted", element: 'pre'},
604 {name: "Address", element: 'address'},
606 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
607 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
608 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
609 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
610 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
611 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
617 website.editor = { };
618 website.editor.Dialog = openerp.Widget.extend({
620 'hidden.bs.modal': 'destroy',
621 'click button.save': 'save',
623 init: function (editor) {
625 this.editor = editor;
628 var sup = this._super();
629 this.$el.modal({backdrop: 'static'});
636 this.$el.modal('hide');
640 website.editor.LinkDialog = website.editor.Dialog.extend({
641 template: 'website.editor.dialog.link',
642 events: _.extend({}, website.editor.Dialog.prototype.events, {
643 'change .url-source': function (e) { this.changed($(e.target)); },
644 'mousedown': function (e) {
645 var $target = $(e.target).closest('.list-group-item');
646 if (!$target.length || $target.hasClass('active')) {
647 // clicked outside groups, or clicked in active groups
651 this.changed($target.find('.url-source'));
653 'click button.remove': 'remove_link',
654 'change input#link-text': function (e) {
655 this.text = $(e.target).val()
658 init: function (editor) {
660 // url -> name mapping for existing pages
661 this.pages = Object.create(null);
666 this.fetch_pages().done(this.proxy('fill_pages')),
667 this.fetch_menus().done(this.proxy('fill_menus')),
669 ).done(this.proxy('bind_data'));
672 var self = this, _super = this._super.bind(this);
673 var $e = this.$('.list-group-item.active .url-source');
675 if (!val || !$e[0].checkValidity()) {
676 // FIXME: error message
677 $e.closest('.form-group').addClass('has-error');
682 if ($e.hasClass('email-address')) {
683 this.make_link('mailto:' + val, false, val);
684 } else if ($e.hasClass('existing')) {
685 self.make_link(val, false, this.pages[val]);
686 } else if ($e.hasClass('pages')) {
687 // Create the page, get the URL back
688 done = $.get(_.str.sprintf(
689 '/pagenew/%s?noredirect', encodeURI(val)))
690 .then(function (response) {
691 self.make_link(response, false, val);
692 var parent_id = self.$('.add-to-menu').val();
694 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
695 model: 'website.menu',
700 'sequence': 0, // TODO: better tree widget
701 'website_id': website.id,
702 'parent_id': parent_id|0,
705 context: website.get_context()
711 this.make_link(val, this.$('input.window-new').prop('checked'));
715 make_link: function (url, new_window, label) {
717 bind_data: function () {
718 var href = this.element && (this.element.data( 'cke-saved-href')
719 || this.element.getAttribute('href'));
720 if (!href) { return; }
723 if (match = /mailto:(.+)/.exec(href)) {
724 $control = this.$('input.email-address').val(match[1]);
725 } else if (href in this.pages) {
726 $control = this.$('select.existing').val(href);
727 } else if (match = /\/page\/(.+)/.exec(href)) {
728 var actual_href = '/page/website.' + match[1];
729 if (actual_href in this.pages) {
730 $control = this.$('select.existing').val(actual_href);
734 $control = this.$('input.url').val(href);
737 this.changed($control);
739 this.$('input#link-text').val(this.element.getText());
740 this.$('input.window-new').prop(
741 'checked', this.element.getAttribute('target') === '_blank');
743 changed: function ($e) {
744 this.$('.url-source').not($e).val('');
745 $e.closest('.list-group-item')
747 .siblings().removeClass('active')
748 .addBack().removeClass('has-error');
750 fetch_pages: function () {
751 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
753 method: 'list_pages',
756 context: website.get_context()
760 fill_pages: function (results) {
762 var pages = this.$('select.existing')[0];
763 _(results).each(function (result) {
764 self.pages[result.url] = result.name;
766 pages.options[pages.options.length] =
767 new Option(result.name, result.url);
770 fetch_menus: function () {
771 var context = website.get_context();
772 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
773 model: 'website.menu',
775 args: [[context.website_id]],
781 fill_menus: function (tree) {
783 var menus = this.$('select.add-to-menu')[0];
784 var process_tree = function(node) {
785 var name = (new Array(node.level + 1).join('|-')) + ' ' + node.name;
786 menus.options[menus.options.length] = new Option(name, node.id);
787 node.children.forEach(function (child) {
794 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
797 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
798 this.editor.getSelection().selectElement(element);
800 this.element = element;
802 this.add_removal_button();
805 return this._super();
807 add_removal_button: function () {
808 this.$('.modal-footer').prepend(
810 'website.editor.dialog.link.footer-button'));
812 remove_link: function () {
813 var editor = this.editor;
814 // same issue as in make_link
815 setTimeout(function () {
816 editor.removeStyle(new CKEDITOR.style({
818 type: CKEDITOR.STYLE_INLINE,
819 alwaysRemoveElement: true,
825 * Greatly simplified version of CKEDITOR's
826 * plugins.link.dialogs.link.onOk.
828 * @param {String} url
829 * @param {Boolean} [new_window=false]
830 * @param {String} [label=null]
832 make_link: function (url, new_window, label) {
833 var attributes = {href: url, 'data-cke-saved-href': url};
836 attributes['target'] = '_blank';
838 to_remove.push('target');
842 this.element.setAttributes(attributes);
843 this.element.removeAttributes(to_remove);
844 if (this.text) { this.element.setText(this.text); }
846 var selection = this.editor.getSelection();
847 var range = selection.getRanges(true)[0];
849 if (range.collapsed) {
850 //noinspection JSPotentiallyInvalidConstructorUsage
851 var text = new CKEDITOR.dom.text(
852 this.text || label || url);
853 range.insertNode(text);
854 range.selectNodeContents(text);
857 //noinspection JSPotentiallyInvalidConstructorUsage
859 type: CKEDITOR.STYLE_INLINE,
861 attributes: attributes,
862 }).applyToRange(range);
864 // focus dance between RTE & dialog blow up the stack in Safari
865 // and Chrome, so defer select() until dialog has been closed
866 setTimeout(function () {
872 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
873 * if the editor is set directly on a link it will thus not work.
875 get_selected_link: function () {
876 return get_selected_link(this.editor);
881 * ImageDialog widget. Lets users change an image, including uploading a
882 * new image in OpenERP or selecting the image style (if supported by
885 * Initialized as usual, but the caller can hook into two events:
887 * @event start({url, style}) called during dialog initialization and
888 * opening, the handler can *set* the ``url``
889 * and ``style`` properties on its parameter
890 * to provide these as default values to the
892 * @event save({url, style}) called during dialog finalization, the handler
893 * is provided with the image url and style
894 * selected by the users (or possibly the ones
895 * originally passed in)
897 website.editor.ImageDialog = website.editor.Dialog.extend({
898 template: 'website.editor.dialog.image',
899 events: _.extend({}, website.editor.Dialog.prototype.events, {
900 'change .url-source': function (e) { this.changed($(e.target)); },
901 'click button.filepicker': function () {
902 this.$('input[type=file]').click();
904 'change input[type=file]': 'file_selection',
905 'change input.url': 'preview_image',
906 'click a[href=#existing]': 'browse_existing',
907 'change select.image-style': 'preview_image',
911 var $options = this.$('.image-style').children();
912 this.image_styles = $options.map(function () { return this.value; }).get();
914 var o = { url: null, style: null, };
915 // avoid typos, prevent addition of new properties to the object
916 Object.preventExtensions(o);
917 this.trigger('start', o);
921 this.$('.image-style').val(o.style);
923 this.set_image(o.url);
926 return this._super();
929 this.trigger('save', {
930 url: this.$('input.url').val(),
931 style: this.$('.image-style').val(),
933 return this._super();
937 * Sets the provided image url as the dialog's value-to-save and
938 * refreshes the preview element to use it.
940 set_image: function (url) {
941 this.$('input.url').val(url);
942 this.preview_image();
945 file_selection: function () {
946 this.$('button.filepicker').removeClass('btn-danger btn-success');
949 var callback = _.uniqueId('func_');
950 this.$('input[name=func]').val(callback);
952 window[callback] = function (url, error) {
953 delete window[callback];
954 self.file_selected(url, error);
956 this.$('form').submit();
958 file_selected: function(url, error) {
959 var $button = this.$('button.filepicker');
961 $button.addClass('btn-danger');
964 $button.addClass('btn-success');
967 preview_image: function () {
968 var image = this.$('input.url').val();
969 if (!image) { return; }
971 this.$('img.image-preview')
973 .removeClass(this.image_styles.join(' '))
974 .addClass(this.$('select.image-style').val());
976 browse_existing: function (e) {
978 new website.editor.ExistingImageDialog(this).appendTo(document.body);
981 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
983 this._super.apply(this, arguments);
985 this.on('start', this, this.proxy('started'));
986 this.on('save', this, this.proxy('saved'));
988 started: function (holder) {
989 var selection = this.editor.getSelection();
990 var el = selection && selection.getSelectedElement();
993 if (el && el.is('img')) {
995 _(this.image_styles).each(function (style) {
996 if (el.hasClass(style)) {
997 holder.style = style;
1000 holder.url = el.getAttribute('src');
1003 saved: function (data) {
1004 var element, editor = this.editor;
1005 if (!(element = this.element)) {
1006 element = editor.document.createElement('img');
1007 element.addClass('img');
1008 // focus event handler interactions between bootstrap (modal)
1009 // and ckeditor (RTE) lead to blowing the stack in Safari and
1010 // Chrome (but not FF) when this is done synchronously =>
1011 // defer insertion so modal has been hidden & destroyed before
1013 setTimeout(function () {
1014 editor.insertElement(element);
1018 var style = data.style;
1019 element.setAttribute('src', data.url);
1020 element.removeAttribute('data-cke-saved-src');
1021 $(element.$).removeClass(this.image_styles.join(' '));
1022 if (style) { element.addClass(style); }
1026 var IMAGES_PER_ROW = 6;
1027 var IMAGES_ROWS = 4;
1028 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1029 template: 'website.editor.dialog.image.existing',
1030 events: _.extend({}, website.editor.Dialog.prototype.events, {
1031 'click .existing-attachments img': 'select_existing',
1032 'click .pager > li': function (e) {
1034 var $target = $(e.currentTarget);
1035 if ($target.hasClass('disabled')) {
1038 this.page += $target.hasClass('previous') ? -1 : 1;
1039 this.display_attachments();
1042 init: function (parent) {
1045 this.parent = parent;
1046 this._super(parent.editor);
1049 start: function () {
1052 this.fetch_existing().then(this.proxy('fetched_existing')));
1055 fetch_existing: function () {
1056 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1057 model: 'ir.attachment',
1058 method: 'search_read',
1061 fields: ['name', 'website_url'],
1062 domain: [['res_model', '=', 'ir.ui.view']],
1064 context: website.get_context(),
1068 fetched_existing: function (records) {
1069 this.records = records;
1070 this.display_attachments();
1072 display_attachments: function () {
1073 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1075 var from = this.page * per_screen;
1076 var records = this.records;
1078 // Create rows of 3 records
1079 var rows = _(records).chain()
1080 .slice(from, from + per_screen)
1081 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1085 this.$('.existing-attachments').replaceWith(
1086 openerp.qweb.render(
1087 'website.editor.dialog.image.existing.content', {rows: rows}));
1089 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1090 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1093 select_existing: function (e) {
1094 var link = $(e.currentTarget).attr('src');
1096 this.parent.set_image(link);
1102 function get_selected_link(editor) {
1103 var sel = editor.getSelection(),
1104 el = sel.getSelectedElement();
1105 if (el && el.is('a')) { return el; }
1107 var range = sel.getRanges(true)[0];
1108 if (!range) { return null; }
1110 range.shrink(CKEDITOR.SHRINK_TEXT);
1111 var commonAncestor = range.getCommonAncestor();
1112 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1113 return element.data('oe-model') === 'ir.ui.view'
1115 if (!viewRoot) { return null; }
1116 // if viewRoot is the first link, don't edit it.
1117 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1118 .contains('a', true);
1122 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1123 var OBSERVER_CONFIG = {
1126 characterData: true,
1128 attributeOldValue: true,
1130 var observer = new website.Observer(function (mutations) {
1131 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1132 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1133 // will not mark dirty on attribute changes (@class, img/@src,
1135 _(mutations).chain()
1136 .filter(function (m) {
1138 case 'attributes': // ignore .cke_focus being added or removed
1139 // if attribute is not a class, can't be .cke_focus change
1140 if (m.attributeName !== 'class') { return true; }
1142 // find out what classes were added or removed
1143 var oldClasses = (m.oldValue || '').split(/\s+/);
1144 var newClasses = m.target.className.split(/\s+/);
1145 var change = _.union(_.difference(oldClasses, newClasses),
1146 _.difference(newClasses, oldClasses));
1147 // ignore mutation if the *only* change is .cke_focus
1148 return change.length !== 1 || change[0] === 'cke_focus';
1150 // <br type="_moz"> appears when focusing RTE in FF, ignore
1151 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1157 var node = m.target;
1158 while (node && !$(node).hasClass('oe_editable')) {
1159 node = node.parentNode;
1161 $(m.target).trigger('node_changed');
1166 .each(function (node) { $(node).trigger('content_changed'); })