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.RTEImageDialog(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 () {
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',
499 'change input#link-text': function (e) {
500 this.text = $(e.target).val()
503 init: function (editor) {
505 // url -> name mapping for existing pages
506 this.pages = Object.create(null);
511 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
512 this.editor.getSelection().selectElement(element);
514 this.element = element;
516 this.add_removal_button();
520 this.fetch_pages().done(this.proxy('fill_pages')),
522 ).done(this.proxy('bind_data'));
524 add_removal_button: function () {
525 this.$('.modal-footer').prepend(
527 'website.editor.dialog.link.footer-button'));
529 remove_link: function () {
530 var editor = this.editor;
531 // same issue as in make_link
532 setTimeout(function () {
533 editor.removeStyle(new CKEDITOR.style({
535 type: CKEDITOR.STYLE_INLINE,
536 alwaysRemoveElement: true,
542 * Greatly simplified version of CKEDITOR's
543 * plugins.link.dialogs.link.onOk.
545 * @param {String} url
546 * @param {Boolean} [new_window=false]
547 * @param {String} [label=null]
549 make_link: function (url, new_window, label) {
550 var attributes = {href: url, 'data-cke-saved-href': url};
553 attributes['target'] = '_blank';
555 to_remove.push('target');
559 this.element.setAttributes(attributes);
560 this.element.removeAttributes(to_remove);
561 if (this.text) { this.element.setText(this.text); }
563 var selection = this.editor.getSelection();
564 var range = selection.getRanges(true)[0];
566 if (range.collapsed) {
567 //noinspection JSPotentiallyInvalidConstructorUsage
568 var text = new CKEDITOR.dom.text(
569 this.text || label || url);
570 range.insertNode(text);
571 range.selectNodeContents(text);
574 //noinspection JSPotentiallyInvalidConstructorUsage
576 type: CKEDITOR.STYLE_INLINE,
578 attributes: attributes,
579 }).applyToRange(range);
581 // focus dance between RTE & dialog blow up the stack in Safari
582 // and Chrome, so defer select() until dialog has been closed
583 setTimeout(function () {
589 var self = this, _super = this._super.bind(this);
590 var $e = this.$('.list-group-item.active .url-source');
592 if (!val || !$e[0].checkValidity()) {
593 // FIXME: error message
594 $e.closest('.form-group').addClass('has-error');
599 if ($e.hasClass('email-address')) {
600 this.make_link('mailto:' + val, false, val);
601 } else if ($e.hasClass('existing')) {
602 self.make_link(val, false, this.pages[val]);
603 } else if ($e.hasClass('pages')) {
604 // Create the page, get the URL back
605 done = $.get(_.str.sprintf(
606 '/pagenew/%s?noredirect', encodeURIComponent(val)))
607 .then(function (response) {
608 self.make_link(response, false, val);
611 this.make_link(val, this.$('input.window-new').prop('checked'));
615 bind_data: function () {
616 var href = this.element && (this.element.data( 'cke-saved-href')
617 || this.element.getAttribute('href'));
618 if (!href) { return; }
621 if (match = /mailto:(.+)/.exec(href)) {
622 $control = this.$('input.email-address').val(match[1]);
623 } else if (href in this.pages) {
624 $control = this.$('select.existing').val(href);
625 } else if (match = /\/page\/(.+)/.exec(href)) {
626 var actual_href = '/page/website.' + match[1];
627 if (actual_href in this.pages) {
628 $control = this.$('select.existing').val(actual_href);
632 $control = this.$('input.url').val(href);
635 this.changed($control);
637 this.$('input#link-text').val(this.element.getText());
638 this.$('input.window-new').prop(
639 'checked', this.element.getAttribute('target') === '_blank');
641 changed: function ($e) {
642 this.$('.url-source').not($e).val('');
643 $e.closest('.list-group-item')
645 .siblings().removeClass('active')
646 .addBack().removeClass('has-error');
649 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
650 * if the editor is set directly on a link it will thus not work.
652 get_selected_link: function () {
653 return get_selected_link(this.editor);
655 fetch_pages: function () {
656 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
658 method: 'list_pages',
661 context: website.get_context()
665 fill_pages: function (results) {
667 var pages = this.$('select.existing')[0];
668 _(results).each(function (result) {
669 self.pages[result.url] = result.name;
671 pages.options[pages.options.length] =
672 new Option(result.name, result.url);
677 * ImageDialog widget. Lets users change an image, including uploading a
678 * new image in OpenERP or selecting the image style (if supported by
681 * Initialized as usual, but the caller can hook into two events:
683 * @event start({url, style}) called during dialog initialization and
684 * opening, the handler can *set* the ``url``
685 * and ``style`` properties on its parameter
686 * to provide these as default values to the
688 * @event save({url, style}) called during dialog finalization, the handler
689 * is provided with the image url and style
690 * selected by the users (or possibly the ones
691 * originally passed in)
693 website.editor.ImageDialog = website.editor.Dialog.extend({
694 template: 'website.editor.dialog.image',
695 events: _.extend({}, website.editor.Dialog.prototype.events, {
696 'change .url-source': function (e) { this.changed($(e.target)); },
697 'click button.filepicker': function () {
698 this.$('input[type=file]').click();
700 'change input[type=file]': 'file_selection',
701 'change input.url': 'preview_image',
702 'click a[href=#existing]': 'browse_existing',
703 'change select.image-style': 'preview_image',
707 var $options = this.$('.image-style').children();
708 this.image_styles = $options.map(function () { return this.value; }).get();
710 var o = { url: null, style: null, };
711 // avoid typos, prevent addition of new properties to the object
712 Object.preventExtensions(o);
713 this.trigger('start', o);
717 this.$('.image-style').val(o.style)
719 this.set_image(o.url);
722 return this._super();
725 this.trigger('save', {
726 url: this.$('input.url').val(),
727 style: this.$('.image-style').val(),
729 return this._super();
733 * Sets the provided image url as the dialog's value-to-save and
734 * refreshes the preview element to use it.
736 set_image: function (url) {
737 this.$('input.url').val(url);
738 this.preview_image();
741 file_selection: function (e) {
742 this.$('button.filepicker').removeClass('btn-danger btn-success');
745 var callback = _.uniqueId('func_');
746 this.$('input[name=func]').val(callback);
748 window[callback] = function (url, error) {
749 delete window[callback];
750 self.file_selected(url, error);
752 this.$('form').submit();
754 file_selected: function(url, error) {
755 var $button = this.$('button.filepicker');
757 $button.addClass('btn-danger');
760 $button.addClass('btn-success');
763 preview_image: function () {
764 var image = this.$('input.url').val();
765 if (!image) { return; }
767 this.$('img.image-preview')
769 .removeClass(this.image_styles.join(' '))
770 .addClass(this.$('select.image-style').val());
772 browse_existing: function (e) {
774 new website.editor.ExistingImageDialog(this).appendTo(document.body);
777 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
779 this._super.apply(this, arguments);
781 this.on('start', this, this.proxy('started'));
782 this.on('save', this, this.proxy('saved'));
784 started: function (holder) {
785 var selection = this.editor.getSelection();
786 var el = selection && selection.getSelectedElement();
789 if (el && el.is('img')) {
791 _(this.image_styles).each(function (style) {
792 if (el.hasClass(style)) {
793 holder.style = style;
796 holder.url = el.getAttribute('src');
799 saved: function (data) {
800 var element, editor = this.editor;
801 if (!(element = this.element)) {
802 element = editor.document.createElement('img');
803 // focus event handler interactions between bootstrap (modal)
804 // and ckeditor (RTE) lead to blowing the stack in Safari and
805 // Chrome (but not FF) when this is done synchronously =>
806 // defer insertion so modal has been hidden & destroyed before
808 setTimeout(function () {
809 editor.insertElement(element);
813 var style = data.style;
814 element.setAttribute('src', data.url);
815 $(element.$).removeClass(this.image_styles.join(' '));
816 if (style) { element.addClass(style); }
820 var IMAGES_PER_ROW = 6;
822 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
823 template: 'website.editor.dialog.image.existing',
824 events: _.extend({}, website.editor.Dialog.prototype.events, {
825 'click .existing-attachments img': 'select_existing',
826 'click .pager > li': function (e) {
828 var $target = $(e.currentTarget);
829 if ($target.hasClass('disabled')) {
832 this.page += $target.hasClass('previous') ? -1 : 1;
833 this.display_attachments();
836 init: function (parent) {
839 this.parent = parent;
840 this._super(parent.editor);
846 this.fetch_existing().then(this.proxy('fetched_existing')));
849 fetch_existing: function () {
850 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
851 model: 'ir.attachment',
852 method: 'search_read',
855 fields: ['name', 'website_url'],
856 domain: [['res_model', '=', 'ir.ui.view']],
858 context: website.get_context(),
862 fetched_existing: function (records) {
863 this.records = records;
864 this.display_attachments();
866 display_attachments: function () {
867 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
869 var from = this.page * per_screen;
870 var records = this.records;
872 // Create rows of 3 records
873 var rows = _(records).chain()
874 .slice(from, from + per_screen)
875 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
879 this.$('.existing-attachments').replaceWith(
881 'website.editor.dialog.image.existing.content', {rows: rows}));
883 .find('li.previous').toggleClass('disabled', (from === 0)).end()
884 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
887 select_existing: function (e) {
888 var link = $(e.currentTarget).attr('src');
890 this.parent.set_image(link);
896 function get_selected_link(editor) {
897 var sel = editor.getSelection(),
898 el = sel.getSelectedElement();
899 if (el && el.is('a')) { return el; }
901 var range = sel.getRanges(true)[0];
902 if (!range) { return null; }
904 range.shrink(CKEDITOR.SHRINK_TEXT);
905 var commonAncestor = range.getCommonAncestor();
906 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
907 return element.data('oe-model') === 'ir.ui.view'
909 if (!viewRoot) { return null; }
910 // if viewRoot is the first link, don't edit it.
911 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
912 .contains('a', true);
916 var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
917 var OBSERVER_CONFIG = {
922 attributeOldValue: true,
924 var observer = new Observer(function (mutations) {
925 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
926 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
927 // will not mark dirty on attribute changes (@class, img/@src,
930 .filter(function (m) {
932 case 'attributes': // ignore .cke_focus being added or removed
933 // if attribute is not a class, can't be .cke_focus change
934 if (m.attributeName !== 'class') { return true; }
936 // find out what classes were added or removed
937 var oldClasses = m.oldValue.split(/\s+/);
938 var newClasses = m.target.className.split(/\s+/);
939 var change = _.union(_.difference(oldClasses, newClasses),
940 _.difference(newClasses, oldClasses));
941 // ignore mutation if the *only* change is .cke_focus
942 return change.length !== 1 || change[0] === 'cke_focus';
944 // <br type="_moz"> appears when focusing RTE in FF, ignore
945 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
952 while (node && !$(node).hasClass('oe_editable')) {
953 node = node.parentNode;
955 $(m.target).trigger('node_changed');
960 .each(function (node) { $(node).trigger('content_changed'); })