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;
40 if (element.is('img') && !element.data( 'cke-realelement' ) && !element.isReadOnly()) {
45 element = get_selected_link(editor) || evt.data.element;
46 if (element.isReadOnly() || !element.is('a')) { return; }
48 editor.getSelection().selectElement(element);
52 //noinspection JSValidateTypes
53 editor.addCommand('link', {
54 exec: function (editor, data) {
61 //noinspection JSValidateTypes
62 editor.addCommand('image', {
63 exec: function (editor, data) {
71 editor.ui.addButton('Link', {
75 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
77 editor.ui.addButton('Image', {
81 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
85 CKEDITOR.plugins.add( 'tablebutton', {
86 requires: 'panelbutton,floatpanel',
87 init: function( editor ) {
90 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
93 // use existing 'table' icon
95 modes: { wysiwyg: true },
97 // panel opens in iframe, @css is CSS file <link>-ed within
98 // frame document, @attributes are set on iframe itself.
100 css: '/website/static/src/css/editor.css',
101 attributes: { 'role': 'listbox', 'aria-label': label, },
104 onBlock: function (panel, block) {
105 block.autoSize = true;
106 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
111 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
112 var $e = $(e.target);
113 var y = $e.index() + 1;
114 var x = $e.closest('tr').index() + 1;
117 .find('td').removeClass('selected').end()
118 .find('tr:lt(' + String(x) + ')')
119 .children().filter(function () { return $(this).index() < y; })
120 .addClass('selected');
121 }).on('click', 'td', function (e) {
122 var $e = $(e.target);
124 //noinspection JSPotentiallyInvalidConstructorUsage
125 var table = new CKEDITOR.dom.element(
126 $(openerp.qweb.render('website.editor.table', {
127 rows: $e.closest('tr').index() + 1,
128 cols: $e.index() + 1,
131 editor.insertElement(table);
132 setTimeout(function () {
133 //noinspection JSPotentiallyInvalidConstructorUsage
134 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
135 var range = editor.createRange();
136 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
141 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
142 CKEDITOR.ui.fire('ready', this);
148 CKEDITOR.plugins.add('oeref', {
151 init: function (editor) {
152 editor.widgets.add('oeref', {
153 editables: { text: '*' },
155 upcast: function (el) {
156 return el.attributes['data-oe-type'];
162 var editor = new website.EditorBar();
163 var $body = $(document.body);
164 editor.prependTo($body).then(function () {
165 if (location.search.indexOf("unable_editor") >= 0) {
169 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
171 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
172 website.EditorBar = openerp.Widget.extend({
173 template: 'website.editorbar',
175 'click button[data-action=edit]': 'edit',
176 'click button[data-action=save]': 'save',
177 'click button[data-action=cancel]': 'cancel',
180 customize_setup: function() {
182 var view_name = $(document.documentElement).data('view-xmlid');
183 var menu = $('#customize-menu');
184 this.$('#customize-menu-button').click(function(event) {
186 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
188 _.each(result, function (item) {
190 menu.append('<li class="dropdown-header">' + item.name + '</li>');
192 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
193 item.id, item.active ? '' : '-empty', item.name));
196 // Adding Static Menus
197 menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
198 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
202 menu.on('click', 'a[data-action!=ace]', function (event) {
203 var view_id = $(event.currentTarget).data('view-id');
204 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
206 }).then( function(result) {
207 window.location.reload();
214 this.saving_mutex = new openerp.Mutex();
216 this.$('#website-top-edit').hide();
217 this.$('#website-top-view').show();
219 $('.dropdown-toggle').dropdown();
220 this.customize_setup();
223 edit: this.$('button[data-action=edit]'),
224 save: this.$('button[data-action=save]'),
225 cancel: this.$('button[data-action=cancel]'),
228 this.rte = new website.RTE(this);
229 this.rte.on('change', this, this.proxy('rte_changed'));
232 this._super.apply(this, arguments),
233 this.rte.prependTo(this.$('#website-top-edit .nav.pull-right'))
238 this.$buttons.edit.prop('disabled', true);
239 this.$('#website-top-view').hide();
240 this.$('#website-top-edit').show();
241 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
243 var $editables = $('[data-oe-model][data-oe-xpath]')
244 // FIXME: propagation should make "meta" blocks non-editable in the first place...
246 .not('[data-oe-type]')
247 .not('.oe_snippet_editor')
248 .prop('contentEditable', true)
249 .addClass('oe_editable');
250 this.rte.start_edition($editables);
252 rte_changed: function () {
253 this.$buttons.save.prop('disabled', false);
258 observer.disconnect();
259 var defs = _(CKEDITOR.instances).chain()
260 .filter(function (editor) { return editor.element.hasClass('oe_dirty'); })
261 .map(function (editor) {
262 var $el = $(editor.element.$);
263 // TODO: Add a queue with concurrency limit in webclient
264 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
265 return self.saving_mutex.exec(function () {
266 return self.saveEditor(editor)
268 var data = $el.data();
269 console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
273 return $.when.apply(null, defs).then(function () {
274 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
278 * Saves an RTE content, which always corresponds to a view section (?).
280 saveEditor: function (editor) {
281 var element = editor.element;
283 element.removeClass('cke_focus')
284 .removeClass('oe_dirty')
285 .removeClass('oe_editable')
286 .removeAttribute('contentEditable');
287 var data = element.getOuterHtml();
288 return openerp.jsonRpc('/web/dataset/call', 'call', {
291 args: [element.data('oe-id'), data, element.data('oe-xpath'), website.get_context()],
294 cancel: function () {
295 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
299 /* ----- RICH TEXT EDITOR ---- */
300 website.RTE = openerp.Widget.extend({
302 id: 'oe_rte_toolbar',
303 className: 'oe_right oe_rte_toolbar',
304 // editor.ui.items -> possible commands &al
305 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
307 init: function (EditorBar) {
308 this.EditorBar = EditorBar;
309 this._super.apply(this, arguments);
312 start_edition: function ($elements) {
318 var editor = CKEDITOR.inline(this, self._config());
319 editor.on('instanceReady', function () {
320 self.trigger('instanceReady');
321 observer.observe(node, OBSERVER_CONFIG);
323 $node.one('content_changed', function () {
324 $node.addClass('oe_dirty');
325 self.trigger('change');
330 _current_editor: function () {
331 return CKEDITOR.currentInstance;
333 _config: function () {
334 // base plugins minus
335 // - magicline (captures mousein/mouseout -> breaks draggable)
336 // - contextmenu & tabletools (disable contextual menu)
337 // - bunch of unused plugins
339 'a11yhelp', 'basicstyles', 'bidi', 'blockquote',
340 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
341 'elementspath', 'enterkey', 'entities', 'filebrowser',
342 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
343 'indentblock', 'indentlist', 'justify',
344 'list', 'pastefromword', 'pastetext', 'preview',
345 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
346 'tab', 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
351 // Disable auto-generated titles
352 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
354 plugins: plugins.join(','),
356 // FIXME: currently breaks RTE?
357 // Ensure no config file is loaded
360 allowedContent: true,
361 // Don't insert paragraphs around content in e.g. <li>
362 autoParagraph: false,
363 filebrowserImageUploadUrl: "/website/attach",
364 // Support for sharedSpaces in 4.x
365 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
366 // Place toolbar in controlled location
367 sharedSpaces: { top: 'oe_rte_toolbar' },
369 name: 'clipboard', items: [
372 name: 'basicstyles', items: [
373 "Bold", "Italic", "Underline", "Strike", "Subscript",
374 "Superscript", "TextColor", "BGColor", "RemoveFormat"
376 name: 'span', items: [
377 "Link", "Blockquote", "BulletedList",
378 "NumberedList", "Indent", "Outdent"
380 name: 'justify', items: [
381 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
383 name: 'special', items: [
384 "Image", "TableButton"
386 name: 'styles', items: [
390 // styles dropdown in toolbar
392 {name: "Normal", element: 'p'},
393 {name: "Heading 1", element: 'h1'},
394 {name: "Heading 2", element: 'h2'},
395 {name: "Heading 3", element: 'h3'},
396 {name: "Heading 4", element: 'h4'},
397 {name: "Heading 5", element: 'h5'},
398 {name: "Heading 6", element: 'h6'},
399 {name: "Formatted", element: 'pre'},
400 {name: "Address", element: 'address'},
402 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
403 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
404 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
405 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
406 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
407 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
413 website.editor = { };
414 website.editor.Dialog = openerp.Widget.extend({
416 'hidden.bs.modal': 'destroy',
417 'click button.save': 'save',
419 init: function (editor) {
421 this.editor = editor;
424 var sup = this._super();
432 this.$el.modal('hide');
436 website.editor.LinkDialog = website.editor.Dialog.extend({
437 template: 'website.editor.dialog.link',
438 events: _.extend({}, website.editor.Dialog.prototype.events, {
439 'change .url-source': function (e) { this.changed($(e.target)); },
440 'mousedown': function (e) {
441 var $target = $(e.target).closest('.list-group-item');
442 if (!$target.length || $target.hasClass('active')) {
443 // clicked outside groups, or clicked in active groups
449 .siblings().removeClass('active')
450 .addBack().removeClass('has-error');
452 'click button.remove': 'remove_link',
454 init: function (editor) {
456 // url -> name mapping for existing pages
457 this.pages = Object.create(null);
461 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
462 this.editor.getSelection().selectElement(element);
464 this.element = element;
466 this.add_removal_button();
470 this.fetch_pages().done(this.proxy('fill_pages')),
472 ).done(this.proxy('bind_data'));
474 add_removal_button: function () {
475 this.$('.modal-footer').prepend(
477 'website.editor.dialog.link.footer-button'));
479 remove_link: function () {
480 var editor = this.editor;
481 // same issue as in make_link
482 setTimeout(function () {
483 editor.removeStyle(new CKEDITOR.style({
485 type: CKEDITOR.STYLE_INLINE,
486 alwaysRemoveElement: true,
492 * Greatly simplified version of CKEDITOR's
493 * plugins.link.dialogs.link.onOk.
495 * @param {String} url
496 * @param {Boolean} [new_window=false]
497 * @param {String} [label=null]
499 make_link: function (url, new_window, label) {
500 var attributes = {href: url, 'data-cke-saved-href': url};
503 attributes['target'] = '_blank';
505 to_remove.push('target');
509 this.element.setAttributes(attributes);
510 this.element.removeAttributes(to_remove);
512 var selection = this.editor.getSelection();
513 var range = selection.getRanges(true)[0];
515 if (range.collapsed) {
516 //noinspection JSPotentiallyInvalidConstructorUsage
517 var text = new CKEDITOR.dom.text(label || url);
518 range.insertNode(text);
519 range.selectNodeContents(text);
522 //noinspection JSPotentiallyInvalidConstructorUsage
524 type: CKEDITOR.STYLE_INLINE,
526 attributes: attributes,
527 }).applyToRange(range);
529 // focus dance between RTE & dialog blow up the stack in Safari
530 // and Chrome, so defer select() until dialog has been closed
531 setTimeout(function () {
537 var self = this, _super = this._super.bind(this);
538 var $e = this.$('.list-group-item.active .url-source');
541 $e.closest('.form-group').addClass('has-error');
546 if ($e.hasClass('email-address')) {
547 this.make_link('mailto:' + val, false, val);
548 } else if ($e.hasClass('existing')) {
549 self.make_link(val, false, this.pages[val]);
550 } else if ($e.hasClass('pages')) {
551 // Create the page, get the URL back
552 done = $.get(_.str.sprintf(
553 '/pagenew/%s?noredirect', encodeURIComponent(val)))
554 .then(function (response) {
555 self.make_link(response, false, val);
558 this.make_link(val, this.$('input.window-new').prop('checked'));
562 bind_data: function () {
563 var href = this.element && (this.element.data( 'cke-saved-href')
564 || this.element.getAttribute('href'));
565 if (!href) { return; }
568 if (match = /(mailto):(.+)/.exec(href)) {
569 $control = this.$('input.email-address').val(match[2]);
570 } else if(href in this.pages) {
571 $control = this.$('select.existing').val(href);
574 $control = this.$('input.url').val(href);
577 this.changed($control);
579 this.$('input.window-new').prop(
580 'checked', this.element.getAttribute('target') === '_blank');
582 changed: function ($e) {
583 this.$('.url-source').not($e).val('');
586 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
587 * if the editor is set directly on a link it will thus not work.
589 get_selected_link: function () {
590 return get_selected_link(this.editor);
592 fetch_pages: function () {
593 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
595 method: 'list_pages',
598 context: website.get_context()
602 fill_pages: function (results) {
604 var pages = this.$('select.existing')[0];
605 _(results).each(function (result) {
606 self.pages[result.url] = result.name;
608 pages.options[pages.options.length] =
609 new Option(result.name, result.url);
613 website.editor.ImageDialog = website.editor.Dialog.extend({
614 template: 'website.editor.dialog.image',
615 events: _.extend({}, website.editor.Dialog.prototype.events, {
616 'change .url-source': function (e) { this.changed($(e.target)); },
617 'click button.filepicker': function () {
618 this.$('input[type=file]').click();
620 'change input[type=file]': 'file_selection',
621 'change input.url': 'preview_image',
622 'click .existing-attachments a': 'select_existing',
625 var selection = this.editor.getSelection();
626 var el = selection && selection.getSelectedElement();
628 if (el && el.is('img')) {
630 this.set_image(el.getAttribute('src'));
635 this.fetch_existing().then(this.proxy('fetched_existing')));
638 var url = this.$('input.url').val();
639 var element, editor = this.editor;
640 if (!(element = this.element)) {
641 element = editor.document.createElement('img');
642 // focus event handler interactions between bootstrap (modal)
643 // and ckeditor (RTE) lead to blowing the stack in Safari and
644 // Chrome (but not FF) when this is done synchronously =>
645 // defer insertion so modal has been hidden & destroyed before
647 setTimeout(function () {
648 editor.insertElement(element);
651 element.setAttribute('src', url);
656 * Sets the provided image url as the dialog's value-to-save and
657 * refreshes the preview element to use it.
659 set_image: function (url) {
660 this.$('input.url').val(url);
661 this.preview_image();
664 file_selection: function (e) {
665 this.$('button.filepicker').removeClass('btn-danger btn-success');
668 var callback = _.uniqueId('func_');
669 this.$('input[name=func]').val(callback);
671 window[callback] = function (url, error) {
672 delete window[callback];
673 self.file_selected(url, error);
675 this.$('form').submit();
677 file_selected: function(url, error) {
678 var $button = this.$('button.filepicker');
680 $button.addClass('btn-danger');
683 $button.addClass('btn-success');
686 preview_image: function () {
687 var image = this.$('input.url').val();
688 if (!image) { return; }
690 this.$('img.image-preview').attr('src', image);
693 fetch_existing: function () {
694 // FIXME: lazy load attachments?
695 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
696 model: 'ir.attachment',
697 method: 'search_read',
701 domain: [['res_model', '=', 'ir.ui.view']],
703 context: website.get_context(),
707 fetched_existing: function (records) {
708 // Create rows of 3 records
709 var rows = _(records).chain()
710 .groupBy(function (_, index) { return Math.floor(index / 3); })
713 this.$('.existing-attachments').replaceWith(
714 openerp.qweb.render('website.editor.dialog.image.existing', {rows: rows}));
716 select_existing: function (e) {
718 this.set_image(e.currentTarget.getAttribute('href'));
722 function get_selected_link(editor) {
723 var sel = editor.getSelection(),
724 el = sel.getSelectedElement();
725 if (el && el.is('a')) { return el; }
727 var range = sel.getRanges(true)[0];
728 if (!range) { return null; }
730 range.shrink(CKEDITOR.SHRINK_TEXT);
731 return editor.elementPath(range.getCommonAncestor())
736 var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
737 var OBSERVER_CONFIG = {
742 attributeOldValue: true,
744 var observer = new Observer(function (mutations) {
745 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
746 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
747 // will not mark dirty on attribute changes (@class, img/@src,
750 .filter(function (m) {
752 case 'attributes': // ignore .cke_focus being added or removed
753 // if attribute is not a class, can't be .cke_focus change
754 if (m.attributeName !== 'class') { return true; }
756 // find out what classes were added or removed
757 var oldClasses = m.oldValue.split(/\s+/);
758 var newClasses = m.target.className.split(/\s+/);
759 var change = _.union(_.difference(oldClasses, newClasses),
760 _.difference(newClasses, oldClasses));
761 // ignore mutation if the *only* change is .cke_focus
762 return change.length !== 1 || change[0] === 'cke_focus';
764 // <br type="_moz"> appears when focusing RTE in FF, ignore
765 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
772 while (node && !$(node).hasClass('oe_editable')) {
773 node = node.parentNode;
779 .each(function (node) { $(node).trigger('content_changed'); })