4 var website = openerp.website;
6 website.templates.push('/website/static/src/xml/website.editor.xml');
7 website.dom_ready.done(function () {
8 // $.fn.data automatically parses value, '0'|'1' -> 0|1
9 website.is_editable = $(document.documentElement).data('editable');
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 website.init_editor = function () {
25 CKEDITOR.plugins.add('customdialogs', {
26 requires: 'link,image',
27 init: function (editor) {
28 editor.on('doubleclick', function (evt) {
29 if (evt.data.dialog === 'link') {
30 delete evt.data.dialog;
32 } else if(evt.data.dialog === 'image') {
33 delete evt.data.dialog;
36 // priority should be smaller than dialog (999) but bigger
37 // than link or image (default=10)
40 editor.addCommand('link', {
41 exec: function (editor, data) {
48 editor.addCommand('image', {
49 exec: function (editor, data) {
58 CKEDITOR.plugins.add( 'tablebutton', {
59 requires: 'panelbutton,floatpanel',
60 init: function( editor ) {
63 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
66 // use existing 'table' icon
68 modes: { wysiwyg: true },
70 // panel opens in iframe, @css is CSS file <link>-ed within
71 // frame document, @attributes are set on iframe itself.
73 css: '/website/static/src/css/editor.css',
74 attributes: { 'role': 'listbox', 'aria-label': label, },
77 onBlock: function (panel, block) {
78 block.autoSize = true;
79 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
84 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
86 var y = $e.index() + 1;
87 var x = $e.closest('tr').index() + 1;
90 .find('td').removeClass('selected').end()
91 .find('tr:lt(' + String(x) + ')')
92 .children().filter(function () { return $(this).index() < y; })
93 .addClass('selected');
94 }).on('click', 'td', function (e) {
97 var table = new CKEDITOR.dom.element(
98 $(openerp.qweb.render('website.editor.table', {
99 rows: $e.closest('tr').index() + 1,
100 cols: $e.index() + 1,
103 editor.insertElement(table);
104 setTimeout(function () {
105 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
106 var range = editor.createRange();
107 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
112 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
113 CKEDITOR.ui.fire('ready', this);
119 var editor = new website.EditorBar();
120 var $body = $(document.body);
121 editor.prependTo($body).then(function () {
122 if (location.search.indexOf("unable_editor") >= 0) {
126 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
128 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
129 website.EditorBar = openerp.Widget.extend({
130 template: 'website.editorbar',
132 'click button[data-action=edit]': 'edit',
133 'click button[data-action=save]': 'save',
134 'click button[data-action=cancel]': 'cancel',
137 customize_setup: function() {
139 var view_name = $(document.documentElement).data('view-xmlid');
140 var menu = $('#customize-menu');
141 this.$('#customize-menu-button').click(function(event) {
143 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
145 _.each(result, function (item) {
147 menu.append('<li class="dropdown-header">' + item.name + '</li>');
149 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
150 item.id, item.active ? '' : '-empty', item.name));
153 // Adding Static Menus
154 menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
155 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">Advanced view editor</a></li>');
159 menu.on('click', 'a[data-action!=ace]', function (event) {
160 var view_id = $(event.currentTarget).data('view-id');
161 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
163 }).then( function(result) {
164 window.location.reload();
171 this.saving_mutex = new openerp.Mutex();
173 this.$('#website-top-edit').hide();
174 this.$('#website-top-view').show();
176 $('.dropdown-toggle').dropdown();
177 this.customize_setup();
180 edit: this.$('button[data-action=edit]'),
181 save: this.$('button[data-action=save]'),
182 cancel: this.$('button[data-action=cancel]'),
185 this.rte = new website.RTE(this);
186 this.rte.on('change', this, this.proxy('rte_changed'));
189 this._super.apply(this, arguments),
190 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
195 this.$buttons.edit.prop('disabled', true);
196 this.$('#website-top-view').hide();
197 this.$('#website-top-edit').show();
198 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
200 var $editables = $('[data-oe-model]')
202 // FIXME: propagation should make "meta" blocks non-editable in the first place...
203 .not('.oe_snippets,.oe_snippet, .oe_snippet *')
204 .prop('contentEditable', true)
205 .addClass('oe_editable');
206 var $rte_ables = $editables.not('[data-oe-type]');
207 var $raw_editables = $editables.not($rte_ables);
209 // temporary: on raw editables, links are still active so an
210 // editable link, containing a link or within a link becomes very
211 // hard to edit. Disable linking for these.
212 $raw_editables.parents('a')
213 .add($raw_editables.find('a'))
214 .on('click', function (e) {
218 this.rte.start_edition($rte_ables);
219 $raw_editables.each(function () {
220 observer.observe(this, OBSERVER_CONFIG);
221 }).one('content_changed', function () {
222 $(this).addClass('oe_dirty');
226 rte_changed: function () {
227 this.$buttons.save.prop('disabled', false);
232 observer.disconnect();
233 $('.oe_dirty').each(function (i, v) {
235 // TODO: Add a queue with concurrency limit in webclient
236 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
237 var def = self.saving_mutex.exec(function () {
238 return self.saveElement($el).then(function () {
239 $el.removeClass('oe_dirty');
240 }).fail(function () {
241 var data = $el.data();
242 console.error(_.str.sprintf('Could not save %s#%d#%s', data.oeModel, data.oeId, data.oeField));
247 return $.when.apply(null, defs).then(function () {
248 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
251 saveElement: function ($el) {
252 var data = $el.data();
253 var html = $el.html();
254 var xpath = data.oeXpath;
256 var $w = $el.clone();
257 $w.removeClass('oe_dirty');
258 _.each(['model', 'id', 'field', 'xpath'], function(d) {$w.removeAttr('data-oe-' + d);});
260 .removeClass('oe_editable')
261 .prop('contentEditable', false);
262 html = $w.wrap('<div>').parent().html();
264 return openerp.jsonRpc('/web/dataset/call', 'call', {
267 args: [data.oeModel, data.oeId, data.oeField, html, xpath, website.get_context()]
270 cancel: function () {
271 window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?|#.*/g, '');
275 /* ----- RICH TEXT EDITOR ---- */
276 website.RTE = openerp.Widget.extend({
278 id: 'oe_rte_toolbar',
279 className: 'oe_right oe_rte_toolbar',
280 // editor.ui.items -> possible commands &al
281 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
283 init: function (EditorBar) {
284 this.EditorBar = EditorBar;
285 this._super.apply(this, arguments);
288 start_edition: function ($elements) {
291 .not('span, [data-oe-type]')
295 var editor = CKEDITOR.inline(this, self._config());
296 editor.on('instanceReady', function () {
297 self.trigger('instanceReady');
298 observer.observe(node, OBSERVER_CONFIG);
300 $node.one('content_changed', function () {
301 $node.addClass('oe_dirty');
302 self.trigger('change');
307 _current_editor: function () {
308 return CKEDITOR.currentInstance;
310 _config: function () {
311 var removed_plugins = [
312 // remove custom context menu
313 'contextmenu,tabletools,liststyle',
314 // magicline captures mousein/mouseout => draggable does not work
320 // Disable auto-generated titles
321 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
323 removePlugins: removed_plugins.join(','),
325 // Ensure no config file is loaded
328 allowedContent: true,
329 // Don't insert paragraphs around content in e.g. <li>
330 autoParagraph: false,
331 filebrowserImageUploadUrl: "/website/attach",
332 // Support for sharedSpaces in 4.x
333 extraPlugins: 'sharedspace,customdialogs,tablebutton',
334 // Place toolbar in controlled location
335 sharedSpaces: { top: 'oe_rte_toolbar' },
337 name: 'clipboard', items: [
340 name: 'basicstyles', items: [
341 "Bold", "Italic", "Underline", "Strike", "Subscript",
342 "Superscript", "TextColor", "BGColor", "RemoveFormat"
344 name: 'span', items: [
345 "Link", "Blockquote", "BulletedList",
346 "NumberedList", "Indent", "Outdent"
348 name: 'justify', items: [
349 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
351 name: 'special', items: [
352 "Image", "TableButton"
354 name: 'styles', items: [
358 // styles dropdown in toolbar
360 {name: "Normal", element: 'p'},
361 {name: "Heading 1", element: 'h1'},
362 {name: "Heading 2", element: 'h2'},
363 {name: "Heading 3", element: 'h3'},
364 {name: "Heading 4", element: 'h4'},
365 {name: "Heading 5", element: 'h5'},
366 {name: "Heading 6", element: 'h6'},
367 {name: "Formatted", element: 'pre'},
368 {name: "Address", element: 'address'},
370 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
371 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
372 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
373 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
374 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
375 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
381 website.editor = { };
382 website.editor.Dialog = openerp.Widget.extend({
384 'hidden.bs.modal': 'destroy',
385 'click button.save': 'save',
387 init: function (editor) {
389 this.editor = editor;
392 var sup = this._super();
400 this.$el.modal('hide');
404 website.editor.LinkDialog = website.editor.Dialog.extend({
405 template: 'website.editor.dialog.link',
406 events: _.extend({}, website.editor.Dialog.prototype.events, {
407 'change .url-source': function (e) { this.changed($(e.target)); },
408 'mousedown': function (e) {
409 var $target = $(e.target).closest('.list-group-item');
410 if (!$target.length || $target.hasClass('active')) {
411 // clicked outside groups, or clicked in active groups
417 .siblings().removeClass('active')
418 .addBack().removeClass('has-error');
420 'click button.remove': 'remove_link',
422 init: function (editor) {
424 // url -> name mapping for existing pages
425 this.pages = Object.create(null);
429 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
430 this.editor.getSelection().selectElement(element);
432 this.element = element;
434 this.add_removal_button();
438 this.fetch_pages().done(this.proxy('fill_pages')),
440 ).done(this.proxy('bind_data'));
442 add_removal_button: function () {
443 this.$('.modal-footer').prepend(
445 'website.editor.dialog.link.footer-button'));
447 remove_link: function () {
448 var editor = this.editor;
449 // same issue as in make_link
450 setTimeout(function () {
451 editor.execCommand('unlink');
456 * Greatly simplified version of CKEDITOR's
457 * plugins.link.dialogs.link.onOk.
459 * @param {String} url
460 * @param {Boolean} [new_window=false]
461 * @param {String} [label=null]
463 make_link: function (url, new_window, label) {
464 var attributes = {href: url, 'data-cke-saved-href': url};
467 attributes['target'] = '_blank';
469 to_remove.push('target');
473 this.element.setAttributes(attributes);
474 this.element.removeAttributes(to_remove);
476 var selection = this.editor.getSelection();
477 var range = selection.getRanges(true)[0];
479 if (range.collapsed) {
480 var text = new CKEDITOR.dom.text(label || url);
481 range.insertNode(text);
482 range.selectNodeContents(text);
486 type: CKEDITOR.STYLE_INLINE,
488 attributes: attributes,
489 }).applyToRange(range);
491 // focus dance between RTE & dialog blow up the stack in Safari
492 // and Chrome, so defer select() until dialog has been closed
493 setTimeout(function () {
499 var self = this, _super = this._super.bind(this);
500 var $e = this.$('.list-group-item.active .url-source');
503 $e.closest('.form-group').addClass('has-error');
508 if ($e.hasClass('email-address')) {
509 this.make_link('mailto:' + val, false, val);
510 } else if ($e.hasClass('existing')) {
511 self.make_link(val, false, this.pages[val]);
512 } else if ($e.hasClass('pages')) {
513 // Create the page, get the URL back
514 done = $.get(_.str.sprintf(
515 '/pagenew/%s?noredirect', encodeURIComponent(val)))
516 .then(function (response) {
517 self.make_link(response, false, val);
520 this.make_link(val, this.$('input.window-new').prop('checked'));
524 bind_data: function () {
525 var href = this.element && (this.element.data( 'cke-saved-href')
526 || this.element.getAttribute('href'));
527 if (!href) { return; }
530 if (match = /(mailto):(.+)/.exec(href)) {
531 $control = this.$('input.email-address').val(match[2]);
532 } else if(href in this.pages) {
533 $control = this.$('select.existing').val(href);
536 $control = this.$('input.url').val(href);
539 this.changed($control);
541 this.$('input.window-new').prop(
542 'checked', this.element.getAttribute('target') === '_blank');
544 changed: function ($e) {
545 this.$('.url-source').not($e).val('');
548 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
549 * if the editor is set directly on a link it will thus not work.
551 get_selected_link: function () {
552 var sel = this.editor.getSelection(),
553 el = sel.getSelectedElement();
554 if (el && el.is('a')) { return el; }
556 var range = sel.getRanges(true)[0];
557 if (!range) { return null; }
559 range.shrink(CKEDITOR.SHRINK_TEXT);
560 return this.editor.elementPath(range.getCommonAncestor())
564 fetch_pages: function () {
565 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
567 method: 'list_pages',
570 context: website.get_context()
574 fill_pages: function (results) {
576 var pages = this.$('select.existing')[0];
577 _(results).each(function (result) {
578 self.pages[result.url] = result.name;
580 pages.options[pages.options.length] =
581 new Option(result.name, result.url);
585 website.editor.ImageDialog = website.editor.Dialog.extend({
586 template: 'website.editor.dialog.image',
587 events: _.extend({}, website.editor.Dialog.prototype.events, {
588 'change .url-source': function (e) { this.changed($(e.target)); },
589 'click button.filepicker': function () {
590 this.$('input[type=file]').click();
592 'change input[type=file]': 'file_selection',
593 'change input.url': 'preview_image',
594 'click a[href=#existing]': 'browse_existing',
595 'change select.image-style': 'preview_image',
598 var selection = this.editor.getSelection();
599 var el = selection && selection.getSelectedElement();
602 var $select = this.$('.image-style');
603 var $options = $select.children();
604 this.image_styles = $options.map(function () { return this.value; }).get();
606 if (el && el.is('img')) {
608 _(this.image_styles).each(function (style) {
609 if (el.hasClass(style)) {
613 // set_image must follow setup of image style
614 this.set_image(el.getAttribute('src'));
617 return this._super();
620 var url = this.$('input.url').val();
621 var style = this.$('.image-style').val();
622 var element, editor = this.editor;
623 if (!(element = this.element)) {
624 element = editor.document.createElement('img');
625 // focus event handler interactions between bootstrap (modal)
626 // and ckeditor (RTE) lead to blowing the stack in Safari and
627 // Chrome (but not FF) when this is done synchronously =>
628 // defer insertion so modal has been hidden & destroyed before
630 setTimeout(function () {
631 editor.insertElement(element);
634 element.setAttribute('src', url);
635 $(element.$).removeClass(this.image_styles.join(' '));
636 if (style) { element.addClass(style); }
638 return this._super();
642 * Sets the provided image url as the dialog's value-to-save and
643 * refreshes the preview element to use it.
645 set_image: function (url) {
646 this.$('input.url').val(url);
647 this.preview_image();
650 file_selection: function (e) {
651 this.$('button.filepicker').removeClass('btn-danger btn-success');
654 var callback = _.uniqueId('func_');
655 this.$('input[name=func]').val(callback);
657 window[callback] = function (url, error) {
658 delete window[callback];
659 self.file_selected(url, error);
661 this.$('form').submit();
663 file_selected: function(url, error) {
664 var $button = this.$('button.filepicker');
666 $button.addClass('btn-danger');
669 $button.addClass('btn-success');
672 preview_image: function () {
673 var image = this.$('input.url').val();
674 if (!image) { return; }
676 this.$('img.image-preview')
678 .removeClass(this.image_styles.join(' '))
679 .addClass(this.$('select.image-style').val());
682 browse_existing: function (e) {
684 new website.editor.ExistingImageDialog(this).appendTo(document.body);
688 var IMAGES_PER_ROW = 3;
690 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
691 template: 'website.editor.dialog.image.existing',
692 events: _.extend({}, website.editor.Dialog.prototype.events, {
693 'click .existing-attachments a': 'select_existing',
694 'click .pager > li': function (e) {
696 var $target = $(e.currentTarget);
697 if ($target.hasClass('disabled')) {
700 this.page += $target.hasClass('previous') ? -1 : 1;
701 this.display_attachments();
704 init: function (parent) {
707 this.parent = parent;
708 this._super(parent.editor);
714 this.fetch_existing().then(this.proxy('fetched_existing')));
717 fetch_existing: function () {
718 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
719 model: 'ir.attachment',
720 method: 'search_read',
724 domain: [['res_model', '=', 'ir.ui.view']],
726 context: website.get_context(),
730 fetched_existing: function (records) {
731 this.records = records;
732 this.display_attachments();
734 display_attachments: function () {
735 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
737 var from = this.page * per_screen;
738 var records = this.records;
740 // Create rows of 3 records
741 var rows = _(records).chain()
742 .slice(from, from + per_screen)
743 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
747 this.$('.existing-attachments').replaceWith(
749 'website.editor.dialog.image.existing.content', {rows: rows}));
751 .find('li.previous').toggleClass('disabled', (from === 0)).end()
752 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
755 select_existing: function (e) {
757 this.$('a.thumbnail.selected').removeClass('selected');
759 $(e.currentTarget).addClass('selected');
762 var link = this.$('a.thumbnail.selected').attr('href');
764 this.parent.set_image(link);
771 var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
772 var OBSERVER_CONFIG = {
777 attributeOldValue: true,
779 var observer = new Observer(function (mutations) {
780 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
781 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
782 // will not mark dirty on attribute changes (@class, img/@src,
785 .filter(function (m) {
787 case 'attributes': // ignore .cke_focus being added or removed
788 // if attribute is not a class, can't be .cke_focus change
789 if (m.attributeName !== 'class') { return true; }
791 // find out what classes were added or removed
792 var oldClasses = m.oldValue.split(/\s+/);
793 var newClasses = m.target.className.split(/\s+/);
794 var change = _.union(_.difference(oldClasses, newClasses),
795 _.difference(newClasses, oldClasses));
796 // ignore mutation if the *only* change is .cke_focus
797 return change.length !== 1 || change[0] === 'cke_focus';
799 // <br type="_moz"> appears when focusing RTE in FF, ignore
800 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
807 while (node && !$(node).hasClass('oe_editable')) {
808 node = node.parentNode;
814 .each(function (node) { $(node).trigger('content_changed'); })