4 var website = openerp.website;
5 // $.fn.data automatically parses value, '0'|'1' -> 0|1
6 website.is_editable = $(document.documentElement).data('editable');
8 website.templates.push('/website/static/src/xml/website.editor.xml');
9 website.dom_ready.done(function () {
10 var is_smartphone = $(document.body)[0].clientWidth < 767;
12 if (website.is_editable && !is_smartphone) {
13 website.ready().then(website.init_editor);
17 function link_dialog(editor) {
18 return new website.editor.LinkDialog(editor).appendTo(document.body);
20 function image_dialog(editor) {
21 return new website.editor.ImageDialog(editor).appendTo(document.body);
24 if (website.is_editable) {
25 // only enable editors manually
26 CKEDITOR.disableAutoInline = true;
27 // EDIT ALL THE THINGS
28 CKEDITOR.dtd.$editable = $.extend(
29 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
30 // Disable removal of empty elements on CKEDITOR activation. Empty
31 // elements are used for e.g. support of FontAwesome icons
32 CKEDITOR.dtd.$removeEmpty = {};
34 website.init_editor = function () {
35 CKEDITOR.plugins.add('customdialogs', {
36 // requires: 'link,image',
37 init: function (editor) {
38 editor.on('doubleclick', function (evt) {
39 var element = evt.data.element;
41 && !element.data('cke-realelement')
42 && !element.isReadOnly()
43 && (element.data('oe-model') !== 'ir.ui.view')) {
48 element = get_selected_link(editor) || evt.data.element;
49 if (element.isReadOnly()
51 || element.data('oe-model')) {
55 editor.getSelection().selectElement(element);
59 //noinspection JSValidateTypes
60 editor.addCommand('link', {
61 exec: function (editor, data) {
68 //noinspection JSValidateTypes
69 editor.addCommand('image', {
70 exec: function (editor, data) {
78 editor.ui.addButton('Link', {
82 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
84 editor.ui.addButton('Image', {
88 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
91 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
94 CKEDITOR.plugins.add( 'tablebutton', {
95 requires: 'panelbutton,floatpanel',
96 init: function( editor ) {
99 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
102 // use existing 'table' icon
104 modes: { wysiwyg: true },
106 // panel opens in iframe, @css is CSS file <link>-ed within
107 // frame document, @attributes are set on iframe itself.
109 css: '/website/static/src/css/editor.css',
110 attributes: { 'role': 'listbox', 'aria-label': label, },
113 onBlock: function (panel, block) {
114 block.autoSize = true;
115 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
120 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
121 var $e = $(e.target);
122 var y = $e.index() + 1;
123 var x = $e.closest('tr').index() + 1;
126 .find('td').removeClass('selected').end()
127 .find('tr:lt(' + String(x) + ')')
128 .children().filter(function () { return $(this).index() < y; })
129 .addClass('selected');
130 }).on('click', 'td', function (e) {
131 var $e = $(e.target);
133 //noinspection JSPotentiallyInvalidConstructorUsage
134 var table = new CKEDITOR.dom.element(
135 $(openerp.qweb.render('website.editor.table', {
136 rows: $e.closest('tr').index() + 1,
137 cols: $e.index() + 1,
140 editor.insertElement(table);
141 setTimeout(function () {
142 //noinspection JSPotentiallyInvalidConstructorUsage
143 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
144 var range = editor.createRange();
145 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
150 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
151 CKEDITOR.ui.fire('ready', this);
157 CKEDITOR.plugins.add('oeref', {
160 init: function (editor) {
161 editor.widgets.add('oeref', {
162 editables: { text: '*' },
164 upcast: function (el) {
165 return el.attributes['data-oe-type'];
171 var editor = new website.EditorBar();
172 var $body = $(document.body);
173 editor.prependTo($body).then(function () {
174 if (location.search.indexOf("unable_editor") >= 0) {
178 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
180 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
181 website.EditorBar = openerp.Widget.extend({
182 template: 'website.editorbar',
184 'click button[data-action=edit]': 'edit',
185 'click button[data-action=save]': 'save',
186 'click button[data-action=cancel]': 'cancel',
189 customize_setup: function() {
191 var view_name = $(document.documentElement).data('view-xmlid');
192 var menu = $('#customize-menu');
193 this.$('#customize-menu-button').click(function(event) {
195 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
197 _.each(result, function (item) {
199 menu.append('<li class="dropdown-header">' + item.name + '</li>');
201 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
202 item.id, item.active ? '' : '-empty', item.name));
205 // Adding Static Menus
206 menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
207 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
211 menu.on('click', 'a[data-action!=ace]', function (event) {
212 var view_id = $(event.currentTarget).data('view-id');
213 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
215 }).then( function(result) {
216 window.location.reload();
223 this.saving_mutex = new openerp.Mutex();
225 this.$('#website-top-edit').hide();
226 this.$('#website-top-view').show();
228 $('.dropdown-toggle').dropdown();
229 this.customize_setup();
232 edit: this.$('button[data-action=edit]'),
233 save: this.$('button[data-action=save]'),
234 cancel: this.$('button[data-action=cancel]'),
237 this.rte = new website.RTE(this);
238 this.rte.on('change', this, this.proxy('rte_changed'));
239 this.rte.on('rte:ready', this, function () {
240 self.trigger('rte:ready');
244 this._super.apply(this, arguments),
245 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
250 this.$buttons.edit.prop('disabled', true);
251 this.$('#website-top-view').hide();
252 this.$('#website-top-edit').show();
253 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
255 this.rte.start_edition();
257 rte_changed: function () {
258 this.$buttons.save.prop('disabled', false);
263 observer.disconnect();
264 var editor = this.rte.editor;
265 var root = editor.element.$;
267 // FIXME: select editables then filter by dirty?
268 var defs = this.rte.fetch_editables(root)
269 .removeClass('oe_editable cke_focus')
270 .removeAttr('contentEditable')
274 // TODO: Add a queue with concurrency limit in webclient
275 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
276 return self.saving_mutex.exec(function () {
277 return self.saveElement($el)
279 var data = $el.data();
280 console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
284 return $.when.apply(null, defs).then(function () {
285 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
289 * Saves an RTE content, which always corresponds to a view section (?).
291 saveElement: function ($el) {
292 $el.removeClass('oe_dirty');
293 var markup = $el.prop('outerHTML');
294 return openerp.jsonRpc('/web/dataset/call', 'call', {
297 args: [$el.data('oe-id'), markup,
298 $el.data('oe-xpath') || null,
299 website.get_context()],
302 cancel: function () {
303 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
307 /* ----- RICH TEXT EDITOR ---- */
308 website.RTE = openerp.Widget.extend({
310 id: 'oe_rte_toolbar',
311 className: 'oe_right oe_rte_toolbar',
312 // editor.ui.items -> possible commands &al
313 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
315 init: function (EditorBar) {
316 this.EditorBar = EditorBar;
317 this._super.apply(this, arguments);
320 start_edition: function ($elements) {
322 // create a single editor for the whole page
323 var root = document.getElementById('wrapwrap');
324 $(root).on('dragstart', 'img', function (e) {
327 var editor = this.editor = CKEDITOR.inline(root, self._config());
328 editor.on('instanceReady', function () {
329 editor.setReadOnly(false);
330 // ckeditor set root to editable, disable it (only inner
331 // sections are editable)
332 // FIXME: are there cases where the whole editor is editable?
333 editor.editable().setReadOnly(true);
335 self.setup_editables(root);
337 self.trigger('rte:ready');
341 setup_editables: function (root) {
342 // selection of editable sub-items was previously in
343 // EditorBar#edit, but for some unknown reason the elements were
344 // apparently removed and recreated (?) at editor initalization,
345 // and observer setup was lost.
347 // setup dirty-marking for each editable element
348 this.fetch_editables(root)
349 .prop('contentEditable', true)
350 .addClass('oe_editable')
353 observer.observe(node, OBSERVER_CONFIG);
355 $node.one('content_changed', function () {
356 $node.addClass('oe_dirty');
357 self.trigger('change');
362 fetch_editables: function (root) {
363 return $(root).find('[data-oe-model]')
364 // FIXME: propagation should make "meta" blocks non-editable in the first place...
366 .not('.oe_snippet_editor')
367 .filter(function () {
369 // keep view sections and fields which are *not* in
370 // view sections for toplevel editables
371 return $this.data('oe-model') === 'ir.ui.view'
372 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
376 _current_editor: function () {
377 return CKEDITOR.currentInstance;
379 _config: function () {
380 // base plugins minus
381 // - magicline (captures mousein/mouseout -> breaks draggable)
382 // - contextmenu & tabletools (disable contextual menu)
383 // - bunch of unused plugins
385 'a11yhelp', 'basicstyles', 'bidi', 'blockquote',
386 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
387 'elementspath', 'enterkey', 'entities', 'filebrowser',
388 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
389 'indentblock', 'indentlist', 'justify',
390 'list', 'pastefromword', 'pastetext', 'preview',
391 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
392 'tab', 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
397 // Disable auto-generated titles
398 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
400 plugins: plugins.join(','),
402 // FIXME: currently breaks RTE?
403 // Ensure no config file is loaded
406 allowedContent: true,
407 // Don't insert paragraphs around content in e.g. <li>
408 autoParagraph: false,
409 // Don't automatically add or <br> in empty block-level
410 // elements when edition starts
411 fillEmptyBlocks: false,
412 filebrowserImageUploadUrl: "/website/attach",
413 // Support for sharedSpaces in 4.x
414 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
415 // Place toolbar in controlled location
416 sharedSpaces: { top: 'oe_rte_toolbar' },
418 name: 'clipboard', items: [
421 name: 'basicstyles', items: [
422 "Bold", "Italic", "Underline", "Strike", "Subscript",
423 "Superscript", "TextColor", "BGColor", "RemoveFormat"
425 name: 'span', items: [
426 "Link", "Blockquote", "BulletedList",
427 "NumberedList", "Indent", "Outdent"
429 name: 'justify', items: [
430 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
432 name: 'special', items: [
433 "Image", "TableButton"
435 name: 'styles', items: [
439 // styles dropdown in toolbar
441 {name: "Normal", element: 'p'},
442 {name: "Heading 1", element: 'h1'},
443 {name: "Heading 2", element: 'h2'},
444 {name: "Heading 3", element: 'h3'},
445 {name: "Heading 4", element: 'h4'},
446 {name: "Heading 5", element: 'h5'},
447 {name: "Heading 6", element: 'h6'},
448 {name: "Formatted", element: 'pre'},
449 {name: "Address", element: 'address'},
451 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
452 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
453 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
454 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
455 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
456 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
462 website.editor = { };
463 website.editor.Dialog = openerp.Widget.extend({
465 'hidden.bs.modal': 'destroy',
466 'click button.save': 'save',
468 init: function (editor) {
470 this.editor = editor;
473 var sup = this._super();
481 this.$el.modal('hide');
485 website.editor.LinkDialog = website.editor.Dialog.extend({
486 template: 'website.editor.dialog.link',
487 events: _.extend({}, website.editor.Dialog.prototype.events, {
488 'change .url-source': function (e) { this.changed($(e.target)); },
489 'mousedown': function (e) {
490 var $target = $(e.target).closest('.list-group-item');
491 if (!$target.length || $target.hasClass('active')) {
492 // clicked outside groups, or clicked in active groups
496 this.changed($target.find('.url-source'));
498 'click button.remove': 'remove_link',
500 init: function (editor) {
502 // url -> name mapping for existing pages
503 this.pages = Object.create(null);
507 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
508 this.editor.getSelection().selectElement(element);
510 this.element = element;
512 this.add_removal_button();
516 this.fetch_pages().done(this.proxy('fill_pages')),
518 ).done(this.proxy('bind_data'));
520 add_removal_button: function () {
521 this.$('.modal-footer').prepend(
523 'website.editor.dialog.link.footer-button'));
525 remove_link: function () {
526 var editor = this.editor;
527 // same issue as in make_link
528 setTimeout(function () {
529 editor.removeStyle(new CKEDITOR.style({
531 type: CKEDITOR.STYLE_INLINE,
532 alwaysRemoveElement: true,
538 * Greatly simplified version of CKEDITOR's
539 * plugins.link.dialogs.link.onOk.
541 * @param {String} url
542 * @param {Boolean} [new_window=false]
543 * @param {String} [label=null]
545 make_link: function (url, new_window, label) {
546 var attributes = {href: url, 'data-cke-saved-href': url};
549 attributes['target'] = '_blank';
551 to_remove.push('target');
555 this.element.setAttributes(attributes);
556 this.element.removeAttributes(to_remove);
558 var selection = this.editor.getSelection();
559 var range = selection.getRanges(true)[0];
561 if (range.collapsed) {
562 //noinspection JSPotentiallyInvalidConstructorUsage
563 var text = new CKEDITOR.dom.text(label || url);
564 range.insertNode(text);
565 range.selectNodeContents(text);
568 //noinspection JSPotentiallyInvalidConstructorUsage
570 type: CKEDITOR.STYLE_INLINE,
572 attributes: attributes,
573 }).applyToRange(range);
575 // focus dance between RTE & dialog blow up the stack in Safari
576 // and Chrome, so defer select() until dialog has been closed
577 setTimeout(function () {
583 var self = this, _super = this._super.bind(this);
584 var $e = this.$('.list-group-item.active .url-source');
586 if (!val || !$e[0].checkValidity()) {
587 // FIXME: error message
588 $e.closest('.form-group').addClass('has-error');
593 if ($e.hasClass('email-address')) {
594 this.make_link('mailto:' + val, false, val);
595 } else if ($e.hasClass('existing')) {
596 self.make_link(val, false, this.pages[val]);
597 } else if ($e.hasClass('pages')) {
598 // Create the page, get the URL back
599 done = $.get(_.str.sprintf(
600 '/pagenew/%s?noredirect', encodeURIComponent(val)))
601 .then(function (response) {
602 self.make_link(response, false, val);
605 this.make_link(val, this.$('input.window-new').prop('checked'));
609 bind_data: function () {
610 var href = this.element && (this.element.data( 'cke-saved-href')
611 || this.element.getAttribute('href'));
612 if (!href) { return; }
615 if (match = /mailto:(.+)/.exec(href)) {
616 $control = this.$('input.email-address').val(match[1]);
617 } else if (href in this.pages) {
618 $control = this.$('select.existing').val(href);
619 } else if (match = /\/page\/(.+)/.exec(href)) {
620 var actual_href = '/page/website.' + match[1];
621 if (actual_href in this.pages) {
622 $control = this.$('select.existing').val(actual_href);
626 $control = this.$('input.url').val(href);
629 this.changed($control);
631 this.$('input.window-new').prop(
632 'checked', this.element.getAttribute('target') === '_blank');
634 changed: function ($e) {
635 this.$('.url-source').not($e).val('');
636 $e.closest('.list-group-item')
638 .siblings().removeClass('active')
639 .addBack().removeClass('has-error');
642 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
643 * if the editor is set directly on a link it will thus not work.
645 get_selected_link: function () {
646 return get_selected_link(this.editor);
648 fetch_pages: function () {
649 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
651 method: 'list_pages',
654 context: website.get_context()
658 fill_pages: function (results) {
660 var pages = this.$('select.existing')[0];
661 _(results).each(function (result) {
662 self.pages[result.url] = result.name;
664 pages.options[pages.options.length] =
665 new Option(result.name, result.url);
669 website.editor.ImageDialog = website.editor.Dialog.extend({
670 template: 'website.editor.dialog.image',
671 events: _.extend({}, website.editor.Dialog.prototype.events, {
672 'change .url-source': function (e) { this.changed($(e.target)); },
673 'click button.filepicker': function () {
674 this.$('input[type=file]').click();
676 'change input[type=file]': 'file_selection',
677 'change input.url': 'preview_image',
678 'click a[href=#existing]': 'browse_existing',
679 'change select.image-style': 'preview_image',
682 var selection = this.editor.getSelection();
683 var el = selection && selection.getSelectedElement();
686 var $select = this.$('.image-style');
687 var $options = $select.children();
688 this.image_styles = $options.map(function () { return this.value; }).get();
690 if (el && el.is('img')) {
692 _(this.image_styles).each(function (style) {
693 if (el.hasClass(style)) {
697 // set_image must follow setup of image style
698 this.set_image(el.getAttribute('src'));
701 return this._super();
704 var url = this.$('input.url').val();
705 var style = this.$('.image-style').val();
706 var element, editor = this.editor;
707 if (!(element = this.element)) {
708 element = editor.document.createElement('img');
709 // focus event handler interactions between bootstrap (modal)
710 // and ckeditor (RTE) lead to blowing the stack in Safari and
711 // Chrome (but not FF) when this is done synchronously =>
712 // defer insertion so modal has been hidden & destroyed before
714 setTimeout(function () {
715 editor.insertElement(element);
718 element.setAttribute('src', url);
719 $(element.$).removeClass(this.image_styles.join(' '));
720 if (style) { element.addClass(style); }
722 return this._super();
726 * Sets the provided image url as the dialog's value-to-save and
727 * refreshes the preview element to use it.
729 set_image: function (url) {
730 this.$('input.url').val(url);
731 this.preview_image();
734 file_selection: function (e) {
735 this.$('button.filepicker').removeClass('btn-danger btn-success');
738 var callback = _.uniqueId('func_');
739 this.$('input[name=func]').val(callback);
741 window[callback] = function (url, error) {
742 delete window[callback];
743 self.file_selected(url, error);
745 this.$('form').submit();
747 file_selected: function(url, error) {
748 var $button = this.$('button.filepicker');
750 $button.addClass('btn-danger');
753 $button.addClass('btn-success');
756 preview_image: function () {
757 var image = this.$('input.url').val();
758 if (!image) { return; }
760 this.$('img.image-preview')
762 .removeClass(this.image_styles.join(' '))
763 .addClass(this.$('select.image-style').val());
766 browse_existing: function (e) {
768 new website.editor.ExistingImageDialog(this).appendTo(document.body);
772 var IMAGES_PER_ROW = 6;
774 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
775 template: 'website.editor.dialog.image.existing',
776 events: _.extend({}, website.editor.Dialog.prototype.events, {
777 'click .existing-attachments img': 'select_existing',
778 'click .pager > li': function (e) {
780 var $target = $(e.currentTarget);
781 if ($target.hasClass('disabled')) {
784 this.page += $target.hasClass('previous') ? -1 : 1;
785 this.display_attachments();
788 init: function (parent) {
791 this.parent = parent;
792 this._super(parent.editor);
798 this.fetch_existing().then(this.proxy('fetched_existing')));
801 fetch_existing: function () {
802 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
803 model: 'ir.attachment',
804 method: 'search_read',
807 fields: ['name', 'website_url'],
808 domain: [['res_model', '=', 'ir.ui.view']],
810 context: website.get_context(),
814 fetched_existing: function (records) {
815 this.records = records;
816 this.display_attachments();
818 display_attachments: function () {
819 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
821 var from = this.page * per_screen;
822 var records = this.records;
824 // Create rows of 3 records
825 var rows = _(records).chain()
826 .slice(from, from + per_screen)
827 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
831 this.$('.existing-attachments').replaceWith(
833 'website.editor.dialog.image.existing.content', {rows: rows}));
835 .find('li.previous').toggleClass('disabled', (from === 0)).end()
836 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
839 select_existing: function (e) {
840 var link = $(e.currentTarget).attr('src');
842 this.parent.set_image(link);
848 function get_selected_link(editor) {
849 var sel = editor.getSelection(),
850 el = sel.getSelectedElement();
851 if (el && el.is('a')) { return el; }
853 var range = sel.getRanges(true)[0];
854 if (!range) { return null; }
856 range.shrink(CKEDITOR.SHRINK_TEXT);
857 var commonAncestor = range.getCommonAncestor();
858 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
859 return element.data('oe-model') === 'ir.ui.view'
861 if (!viewRoot) { return null; }
862 // if viewRoot is the first link, don't edit it.
863 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
864 .contains('a', true);
868 var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
869 var OBSERVER_CONFIG = {
874 attributeOldValue: true,
876 var observer = new Observer(function (mutations) {
877 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
878 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
879 // will not mark dirty on attribute changes (@class, img/@src,
882 .filter(function (m) {
884 case 'attributes': // ignore .cke_focus being added or removed
885 // if attribute is not a class, can't be .cke_focus change
886 if (m.attributeName !== 'class') { return true; }
888 // find out what classes were added or removed
889 var oldClasses = m.oldValue.split(/\s+/);
890 var newClasses = m.target.className.split(/\s+/);
891 var change = _.union(_.difference(oldClasses, newClasses),
892 _.difference(newClasses, oldClasses));
893 // ignore mutation if the *only* change is .cke_focus
894 return change.length !== 1 || change[0] === 'cke_focus';
896 // <br type="_moz"> appears when focusing RTE in FF, ignore
897 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
904 while (node && !$(node).hasClass('oe_editable')) {
905 node = node.parentNode;
911 .each(function (node) { $(node).trigger('content_changed'); })