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, data) {
66 //noinspection JSValidateTypes
67 editor.addCommand('image', {
68 exec: function (editor, data) {
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("unable_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(result) {
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'))
257 this.$buttons.edit.prop('disabled', true);
258 this.$('#website-top-view').hide();
259 this.$('#website-top-edit').show();
260 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
262 this.rte.start_edition();
264 rte_changed: function () {
265 this.$buttons.save.prop('disabled', false);
270 observer.disconnect();
271 var editor = this.rte.editor;
272 var root = editor.element.$;
274 // FIXME: select editables then filter by dirty?
275 var defs = this.rte.fetch_editables(root)
276 .removeClass('oe_editable cke_focus')
277 .removeAttr('contentEditable')
281 // TODO: Add a queue with concurrency limit in webclient
282 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
283 return self.saving_mutex.exec(function () {
284 return self.saveElement($el)
286 var data = $el.data();
287 console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
291 return $.when.apply(null, defs).then(function () {
296 * Saves an RTE content, which always corresponds to a view section (?).
298 saveElement: function ($el) {
299 $el.removeClass('oe_dirty');
300 var markup = $el.prop('outerHTML');
301 return openerp.jsonRpc('/web/dataset/call', 'call', {
304 args: [$el.data('oe-id'), markup,
305 $el.data('oe-xpath') || null,
306 website.get_context()],
309 cancel: function () {
314 /* ----- RICH TEXT EDITOR ---- */
315 website.RTE = openerp.Widget.extend({
317 id: 'oe_rte_toolbar',
318 className: 'oe_right oe_rte_toolbar',
319 // editor.ui.items -> possible commands &al
320 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
322 init: function (EditorBar) {
323 this.EditorBar = EditorBar;
324 this._super.apply(this, arguments);
327 start_edition: function ($elements) {
329 // create a single editor for the whole page
330 var root = document.getElementById('wrapwrap');
331 $(root).on('dragstart', 'img', function (e) {
334 var editor = this.editor = CKEDITOR.inline(root, self._config());
335 editor.on('instanceReady', function () {
336 editor.setReadOnly(false);
337 // ckeditor set root to editable, disable it (only inner
338 // sections are editable)
339 // FIXME: are there cases where the whole editor is editable?
340 editor.editable().setReadOnly(true);
342 self.setup_editables(root);
344 self.trigger('rte:ready');
348 setup_editables: function (root) {
349 // selection of editable sub-items was previously in
350 // EditorBar#edit, but for some unknown reason the elements were
351 // apparently removed and recreated (?) at editor initalization,
352 // and observer setup was lost.
354 // setup dirty-marking for each editable element
355 this.fetch_editables(root)
356 .addClass('oe_editable')
360 // only explicitly set contenteditable on view sections,
361 // cke widgets system will do the widgets themselves
362 if ($node.data('oe-model') === 'ir.ui.view') {
363 node.contentEditable = true;
366 observer.observe(node, OBSERVER_CONFIG);
367 $node.one('content_changed', function () {
368 $node.addClass('oe_dirty');
369 self.trigger('change');
374 fetch_editables: function (root) {
375 return $(root).find('[data-oe-model]')
376 // FIXME: propagation should make "meta" blocks non-editable in the first place...
378 .not('.oe_snippet_editor')
379 .filter(function () {
381 // keep view sections and fields which are *not* in
382 // view sections for toplevel editables
383 return $this.data('oe-model') === 'ir.ui.view'
384 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
388 _current_editor: function () {
389 return CKEDITOR.currentInstance;
391 _config: function () {
392 // base plugins minus
393 // - magicline (captures mousein/mouseout -> breaks draggable)
394 // - contextmenu & tabletools (disable contextual menu)
395 // - bunch of unused plugins
397 'a11yhelp', 'basicstyles', 'blockquote',
398 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
399 'elementspath', 'enterkey', 'entities', 'filebrowser',
400 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
401 'indentblock', 'indentlist', 'justify',
402 'list', 'pastefromword', 'pastetext', 'preview',
403 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
404 'tab', 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
409 // Disable auto-generated titles
410 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
412 plugins: plugins.join(','),
414 // FIXME: currently breaks RTE?
415 // Ensure no config file is loaded
418 allowedContent: true,
419 // Don't insert paragraphs around content in e.g. <li>
420 autoParagraph: false,
421 // Don't automatically add or <br> in empty block-level
422 // elements when edition starts
423 fillEmptyBlocks: false,
424 filebrowserImageUploadUrl: "/website/attach",
425 // Support for sharedSpaces in 4.x
426 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
427 // Place toolbar in controlled location
428 sharedSpaces: { top: 'oe_rte_toolbar' },
430 name: 'clipboard', items: [
433 name: 'basicstyles', items: [
434 "Bold", "Italic", "Underline", "Strike", "Subscript",
435 "Superscript", "TextColor", "BGColor", "RemoveFormat"
437 name: 'span', items: [
438 "Link", "Blockquote", "BulletedList",
439 "NumberedList", "Indent", "Outdent"
441 name: 'justify', items: [
442 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
444 name: 'special', items: [
445 "Image", "TableButton"
447 name: 'styles', items: [
451 // styles dropdown in toolbar
453 {name: "Normal", element: 'p'},
454 {name: "Heading 1", element: 'h1'},
455 {name: "Heading 2", element: 'h2'},
456 {name: "Heading 3", element: 'h3'},
457 {name: "Heading 4", element: 'h4'},
458 {name: "Heading 5", element: 'h5'},
459 {name: "Heading 6", element: 'h6'},
460 {name: "Formatted", element: 'pre'},
461 {name: "Address", element: 'address'},
463 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
464 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
465 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
466 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
467 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
468 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
474 website.editor = { };
475 website.editor.Dialog = openerp.Widget.extend({
477 'hidden.bs.modal': 'destroy',
478 'click button.save': 'save',
480 init: function (editor) {
482 this.editor = editor;
485 var sup = this._super();
486 this.$el.modal({backdrop: 'static'});
493 this.$el.modal('hide');
497 website.editor.LinkDialog = website.editor.Dialog.extend({
498 template: 'website.editor.dialog.link',
499 events: _.extend({}, website.editor.Dialog.prototype.events, {
500 'change .url-source': function (e) { this.changed($(e.target)); },
501 'mousedown': function (e) {
502 var $target = $(e.target).closest('.list-group-item');
503 if (!$target.length || $target.hasClass('active')) {
504 // clicked outside groups, or clicked in active groups
508 this.changed($target.find('.url-source'));
510 'click button.remove': 'remove_link',
511 'change input#link-text': function (e) {
512 this.text = $(e.target).val()
515 init: function (editor) {
517 // url -> name mapping for existing pages
518 this.pages = Object.create(null);
523 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
524 this.editor.getSelection().selectElement(element);
526 this.element = element;
528 this.add_removal_button();
532 this.fetch_pages().done(this.proxy('fill_pages')),
534 ).done(this.proxy('bind_data'));
536 add_removal_button: function () {
537 this.$('.modal-footer').prepend(
539 'website.editor.dialog.link.footer-button'));
541 remove_link: function () {
542 var editor = this.editor;
543 // same issue as in make_link
544 setTimeout(function () {
545 editor.removeStyle(new CKEDITOR.style({
547 type: CKEDITOR.STYLE_INLINE,
548 alwaysRemoveElement: true,
554 * Greatly simplified version of CKEDITOR's
555 * plugins.link.dialogs.link.onOk.
557 * @param {String} url
558 * @param {Boolean} [new_window=false]
559 * @param {String} [label=null]
561 make_link: function (url, new_window, label) {
562 var attributes = {href: url, 'data-cke-saved-href': url};
565 attributes['target'] = '_blank';
567 to_remove.push('target');
571 this.element.setAttributes(attributes);
572 this.element.removeAttributes(to_remove);
573 if (this.text) { this.element.setText(this.text); }
575 var selection = this.editor.getSelection();
576 var range = selection.getRanges(true)[0];
578 if (range.collapsed) {
579 //noinspection JSPotentiallyInvalidConstructorUsage
580 var text = new CKEDITOR.dom.text(
581 this.text || label || url);
582 range.insertNode(text);
583 range.selectNodeContents(text);
586 //noinspection JSPotentiallyInvalidConstructorUsage
588 type: CKEDITOR.STYLE_INLINE,
590 attributes: attributes,
591 }).applyToRange(range);
593 // focus dance between RTE & dialog blow up the stack in Safari
594 // and Chrome, so defer select() until dialog has been closed
595 setTimeout(function () {
601 var self = this, _super = this._super.bind(this);
602 var $e = this.$('.list-group-item.active .url-source');
604 if (!val || !$e[0].checkValidity()) {
605 // FIXME: error message
606 $e.closest('.form-group').addClass('has-error');
611 if ($e.hasClass('email-address')) {
612 this.make_link('mailto:' + val, false, val);
613 } else if ($e.hasClass('existing')) {
614 self.make_link(val, false, this.pages[val]);
615 } else if ($e.hasClass('pages')) {
616 // Create the page, get the URL back
617 done = $.get(_.str.sprintf(
618 '/pagenew/%s?noredirect', encodeURI(val)))
619 .then(function (response) {
620 self.make_link(response, false, val);
623 this.make_link(val, this.$('input.window-new').prop('checked'));
627 bind_data: function () {
628 var href = this.element && (this.element.data( 'cke-saved-href')
629 || this.element.getAttribute('href'));
630 if (!href) { return; }
633 if (match = /mailto:(.+)/.exec(href)) {
634 $control = this.$('input.email-address').val(match[1]);
635 } else if (href in this.pages) {
636 $control = this.$('select.existing').val(href);
637 } else if (match = /\/page\/(.+)/.exec(href)) {
638 var actual_href = '/page/website.' + match[1];
639 if (actual_href in this.pages) {
640 $control = this.$('select.existing').val(actual_href);
644 $control = this.$('input.url').val(href);
647 this.changed($control);
649 this.$('input#link-text').val(this.element.getText());
650 this.$('input.window-new').prop(
651 'checked', this.element.getAttribute('target') === '_blank');
653 changed: function ($e) {
654 this.$('.url-source').not($e).val('');
655 $e.closest('.list-group-item')
657 .siblings().removeClass('active')
658 .addBack().removeClass('has-error');
661 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
662 * if the editor is set directly on a link it will thus not work.
664 get_selected_link: function () {
665 return get_selected_link(this.editor);
667 fetch_pages: function () {
668 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
670 method: 'list_pages',
673 context: website.get_context()
677 fill_pages: function (results) {
679 var pages = this.$('select.existing')[0];
680 _(results).each(function (result) {
681 self.pages[result.url] = result.name;
683 pages.options[pages.options.length] =
684 new Option(result.name, result.url);
689 * ImageDialog widget. Lets users change an image, including uploading a
690 * new image in OpenERP or selecting the image style (if supported by
693 * Initialized as usual, but the caller can hook into two events:
695 * @event start({url, style}) called during dialog initialization and
696 * opening, the handler can *set* the ``url``
697 * and ``style`` properties on its parameter
698 * to provide these as default values to the
700 * @event save({url, style}) called during dialog finalization, the handler
701 * is provided with the image url and style
702 * selected by the users (or possibly the ones
703 * originally passed in)
705 website.editor.ImageDialog = website.editor.Dialog.extend({
706 template: 'website.editor.dialog.image',
707 events: _.extend({}, website.editor.Dialog.prototype.events, {
708 'change .url-source': function (e) { this.changed($(e.target)); },
709 'click button.filepicker': function () {
710 this.$('input[type=file]').click();
712 'change input[type=file]': 'file_selection',
713 'change input.url': 'preview_image',
714 'click a[href=#existing]': 'browse_existing',
715 'change select.image-style': 'preview_image',
719 var $options = this.$('.image-style').children();
720 this.image_styles = $options.map(function () { return this.value; }).get();
722 var o = { url: null, style: null, };
723 // avoid typos, prevent addition of new properties to the object
724 Object.preventExtensions(o);
725 this.trigger('start', o);
729 this.$('.image-style').val(o.style);
731 this.set_image(o.url);
734 return this._super();
737 this.trigger('save', {
738 url: this.$('input.url').val(),
739 style: this.$('.image-style').val(),
741 return this._super();
745 * Sets the provided image url as the dialog's value-to-save and
746 * refreshes the preview element to use it.
748 set_image: function (url) {
749 this.$('input.url').val(url);
750 this.preview_image();
753 file_selection: function (e) {
754 this.$('button.filepicker').removeClass('btn-danger btn-success');
757 var callback = _.uniqueId('func_');
758 this.$('input[name=func]').val(callback);
760 window[callback] = function (url, error) {
761 delete window[callback];
762 self.file_selected(url, error);
764 this.$('form').submit();
766 file_selected: function(url, error) {
767 var $button = this.$('button.filepicker');
769 $button.addClass('btn-danger');
772 $button.addClass('btn-success');
775 preview_image: function () {
776 var image = this.$('input.url').val();
777 if (!image) { return; }
779 this.$('img.image-preview')
781 .removeClass(this.image_styles.join(' '))
782 .addClass(this.$('select.image-style').val());
784 browse_existing: function (e) {
786 new website.editor.ExistingImageDialog(this).appendTo(document.body);
789 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
791 this._super.apply(this, arguments);
793 this.on('start', this, this.proxy('started'));
794 this.on('save', this, this.proxy('saved'));
796 started: function (holder) {
797 var selection = this.editor.getSelection();
798 var el = selection && selection.getSelectedElement();
801 if (el && el.is('img')) {
803 _(this.image_styles).each(function (style) {
804 if (el.hasClass(style)) {
805 holder.style = style;
808 holder.url = el.getAttribute('src');
811 saved: function (data) {
812 var element, editor = this.editor;
813 if (!(element = this.element)) {
814 element = editor.document.createElement('img');
815 element.addClass('img');
816 // focus event handler interactions between bootstrap (modal)
817 // and ckeditor (RTE) lead to blowing the stack in Safari and
818 // Chrome (but not FF) when this is done synchronously =>
819 // defer insertion so modal has been hidden & destroyed before
821 setTimeout(function () {
822 editor.insertElement(element);
826 var style = data.style;
827 element.setAttribute('src', data.url);
828 element.removeAttribute('data-cke-saved-src');
829 $(element.$).removeClass(this.image_styles.join(' '));
830 if (style) { element.addClass(style); }
834 var IMAGES_PER_ROW = 6;
836 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
837 template: 'website.editor.dialog.image.existing',
838 events: _.extend({}, website.editor.Dialog.prototype.events, {
839 'click .existing-attachments img': 'select_existing',
840 'click .pager > li': function (e) {
842 var $target = $(e.currentTarget);
843 if ($target.hasClass('disabled')) {
846 this.page += $target.hasClass('previous') ? -1 : 1;
847 this.display_attachments();
850 init: function (parent) {
853 this.parent = parent;
854 this._super(parent.editor);
860 this.fetch_existing().then(this.proxy('fetched_existing')));
863 fetch_existing: function () {
864 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
865 model: 'ir.attachment',
866 method: 'search_read',
869 fields: ['name', 'website_url'],
870 domain: [['res_model', '=', 'ir.ui.view']],
872 context: website.get_context(),
876 fetched_existing: function (records) {
877 this.records = records;
878 this.display_attachments();
880 display_attachments: function () {
881 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
883 var from = this.page * per_screen;
884 var records = this.records;
886 // Create rows of 3 records
887 var rows = _(records).chain()
888 .slice(from, from + per_screen)
889 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
893 this.$('.existing-attachments').replaceWith(
895 'website.editor.dialog.image.existing.content', {rows: rows}));
897 .find('li.previous').toggleClass('disabled', (from === 0)).end()
898 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
901 select_existing: function (e) {
902 var link = $(e.currentTarget).attr('src');
904 this.parent.set_image(link);
910 function get_selected_link(editor) {
911 var sel = editor.getSelection(),
912 el = sel.getSelectedElement();
913 if (el && el.is('a')) { return el; }
915 var range = sel.getRanges(true)[0];
916 if (!range) { return null; }
918 range.shrink(CKEDITOR.SHRINK_TEXT);
919 var commonAncestor = range.getCommonAncestor();
920 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
921 return element.data('oe-model') === 'ir.ui.view'
923 if (!viewRoot) { return null; }
924 // if viewRoot is the first link, don't edit it.
925 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
926 .contains('a', true);
930 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
931 var OBSERVER_CONFIG = {
936 attributeOldValue: true,
938 var observer = new website.Observer(function (mutations) {
939 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
940 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
941 // will not mark dirty on attribute changes (@class, img/@src,
944 .filter(function (m) {
946 case 'attributes': // ignore .cke_focus being added or removed
947 // if attribute is not a class, can't be .cke_focus change
948 if (m.attributeName !== 'class') { return true; }
950 // find out what classes were added or removed
951 var oldClasses = (m.oldValue || '').split(/\s+/);
952 var newClasses = m.target.className.split(/\s+/);
953 var change = _.union(_.difference(oldClasses, newClasses),
954 _.difference(newClasses, oldClasses));
955 // ignore mutation if the *only* change is .cke_focus
956 return change.length !== 1 || change[0] === 'cke_focus';
958 // <br type="_moz"> appears when focusing RTE in FF, ignore
959 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
966 while (node && !$(node).hasClass('oe_editable')) {
967 node = node.parentNode;
969 $(m.target).trigger('node_changed');
974 .each(function (node) { $(node).trigger('content_changed'); })