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 //noinspection JSValidateTypes
41 editor.addCommand('link', {
42 exec: function (editor, data) {
49 //noinspection JSValidateTypes
50 editor.addCommand('image', {
51 exec: function (editor, data) {
60 CKEDITOR.plugins.add( 'tablebutton', {
61 requires: 'panelbutton,floatpanel',
62 init: function( editor ) {
65 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
68 // use existing 'table' icon
70 modes: { wysiwyg: true },
72 // panel opens in iframe, @css is CSS file <link>-ed within
73 // frame document, @attributes are set on iframe itself.
75 css: '/website/static/src/css/editor.css',
76 attributes: { 'role': 'listbox', 'aria-label': label, },
79 onBlock: function (panel, block) {
80 block.autoSize = true;
81 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
86 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
88 var y = $e.index() + 1;
89 var x = $e.closest('tr').index() + 1;
92 .find('td').removeClass('selected').end()
93 .find('tr:lt(' + String(x) + ')')
94 .children().filter(function () { return $(this).index() < y; })
95 .addClass('selected');
96 }).on('click', 'td', function (e) {
99 //noinspection JSPotentiallyInvalidConstructorUsage
100 var table = new CKEDITOR.dom.element(
101 $(openerp.qweb.render('website.editor.table', {
102 rows: $e.closest('tr').index() + 1,
103 cols: $e.index() + 1,
106 editor.insertElement(table);
107 setTimeout(function () {
108 //noinspection JSPotentiallyInvalidConstructorUsage
109 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
110 var range = editor.createRange();
111 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
116 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
117 CKEDITOR.ui.fire('ready', this);
123 CKEDITOR.plugins.add('oeref', {
126 init: function (editor) {
127 editor.widgets.add('oeref', {
130 allowedContent: '[data-oe-type]',
131 editables: { text: '*' },
134 var element = this.element;
136 model: element.data('oe-model'),
137 id: parseInt(element.data('oe-id'), 10),
138 field: element.data('oe-field'),
142 this.element.data('oe-model', this.data.model);
143 this.element.data('oe-id', this.data.id);
144 this.element.data('oe-field', this.data.field);
146 upcast: function (el) {
147 return el.attributes['data-oe-type'];
153 var editor = new website.EditorBar();
154 var $body = $(document.body);
155 editor.prependTo($body);
156 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
158 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
159 website.EditorBar = openerp.Widget.extend({
160 template: 'website.editorbar',
162 'click button[data-action=edit]': 'edit',
163 'click button[data-action=save]': 'save',
164 'click button[data-action=cancel]': 'cancel',
167 customize_setup: function() {
169 var view_name = $(document.documentElement).data('view-xmlid');
170 var menu = $('#customize-menu');
171 this.$('#customize-menu-button').click(function(event) {
173 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
175 _.each(result, function (item) {
177 menu.append('<li class="dropdown-header">' + item.name + '</li>');
179 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
180 item.id, item.active ? '' : '-empty', item.name));
183 // Adding Static Menus
184 menu.append('<li class="divider"></li><li><a href="/page/website.themes">Change Theme</a></li>');
188 menu.on('click', 'a', function (event) {
189 var view_id = $(event.currentTarget).data('view-id');
190 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
192 }).then( function(result) {
193 window.location.reload();
200 this.saving_mutex = new openerp.Mutex();
202 this.$('#website-top-edit').hide();
203 this.$('#website-top-view').show();
205 $('.dropdown-toggle').dropdown();
206 this.customize_setup();
209 edit: this.$('button[data-action=edit]'),
210 save: this.$('button[data-action=save]'),
211 cancel: this.$('button[data-action=cancel]'),
214 this.rte = new website.RTE(this);
215 this.rte.on('change', this, this.proxy('rte_changed'));
218 this._super.apply(this, arguments),
219 this.rte.prependTo(this.$('#website-top-edit .nav.pull-right'))
224 this.$buttons.edit.prop('disabled', true);
225 this.$('#website-top-view').hide();
226 this.$('#website-top-edit').show();
227 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
229 var $editables = $('[data-oe-model][data-oe-xpath]')
230 // FIXME: propagation should make "meta" blocks non-editable in the first place...
232 .not('[data-oe-type]')
233 .not('.oe_snippet_editor')
234 .prop('contentEditable', true)
235 .addClass('oe_editable');
236 this.rte.start_edition($editables);
238 rte_changed: function () {
239 this.$buttons.save.prop('disabled', false);
244 observer.disconnect();
245 var defs = _(CKEDITOR.instances).chain()
246 .filter(function (editor) { return editor.element.hasClass('oe_dirty'); })
247 .map(function (editor) {
248 var $el = $(editor.element.$);
249 // TODO: Add a queue with concurrency limit in webclient
250 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
251 return self.saving_mutex.exec(function () {
252 return self.saveEditor(editor)
254 var data = $el.data();
255 console.error(_.str.sprintf('Could not save %s(%d).%s', data.oeModel, data.oeId, data.oeField));
259 return $.when.apply(null, defs).then(function () {
260 window.location.reload();
264 * Saves an RTE content, which always corresponds to a view section (?).
266 saveEditor: function (editor) {
267 var element = editor.element;
269 element.removeClass('cke_focus')
270 .removeClass('oe_editable')
271 .removeAttribute('contentEditable');
272 var data = element.getOuterHtml();
273 return openerp.jsonRpc('/web/dataset/call', 'call', {
276 args: [element.data('oe-id'), element.data('oe-xpath'), data],
279 cancel: function () {
280 window.location.reload();
284 /* ----- RICH TEXT EDITOR ---- */
285 website.RTE = openerp.Widget.extend({
287 id: 'oe_rte_toolbar',
288 className: 'oe_right oe_rte_toolbar',
289 // editor.ui.items -> possible commands &al
290 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
292 start_edition: function ($elements) {
298 var editor = CKEDITOR.inline(this, self._config());
299 editor.on('instanceReady', function () {
300 observer.observe(node, OBSERVER_CONFIG);
302 $node.one('content_changed', function () {
303 $node.addClass('oe_dirty');
304 self.trigger('change');
309 _current_editor: function () {
310 return CKEDITOR.currentInstance;
312 _config: function () {
313 var removed_plugins = [
314 // remove custom context menu
315 'contextmenu,tabletools,liststyle',
316 // 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 // FIXME: currently breaks RTE?
326 // // Ensure no config file is loaded
329 allowedContent: true,
330 // Don't insert paragraphs around content in e.g. <li>
331 autoParagraph: false,
332 filebrowserImageUploadUrl: "/website/attach",
333 // Support for sharedSpaces in 4.x
334 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref',
335 // Place toolbar in controlled location
336 sharedSpaces: { top: 'oe_rte_toolbar' },
338 {name: 'basicstyles', items: [
339 "Bold", "Italic", "Underline", "Strike", "Subscript",
340 "Superscript", "TextColor", "BGColor", "RemoveFormat"
342 name: 'span', items: [
343 "Link", "Unlink", "Blockquote", "BulletedList",
344 "NumberedList", "Indent", "Outdent"
346 name: 'justify', items: [
347 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
349 name: 'special', items: [
350 "Image", "TableButton"
352 name: 'styles', items: [
356 // styles dropdown in toolbar
358 {name: "Normal", element: 'p'},
359 {name: "Heading 1", element: 'h1'},
360 {name: "Heading 2", element: 'h2'},
361 {name: "Heading 3", element: 'h3'},
362 {name: "Heading 4", element: 'h4'},
363 {name: "Heading 5", element: 'h5'},
364 {name: "Heading 6", element: 'h6'},
365 {name: "Formatted", element: 'pre'},
366 {name: "Address", element: 'address'},
368 {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}},
369 {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}},
370 {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}},
371 {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}},
372 {name: "Success", element: 'span', attributes: {'class': 'text-success'}},
373 {name: "Info", element: 'span', attributes: {'class': 'text-info'}}
379 website.editor = { };
380 website.editor.Dialog = openerp.Widget.extend({
382 'hidden.bs.modal': 'destroy',
383 'click button.save': 'save',
385 init: function (editor) {
387 this.editor = editor;
390 var sup = this._super();
395 this.$el.modal('hide');
399 website.editor.LinkDialog = website.editor.Dialog.extend({
400 template: 'website.editor.dialog.link',
401 events: _.extend({}, website.editor.Dialog.prototype.events, {
402 'change .url-source': function (e) { this.changed($(e.target)); },
403 'click div.existing a': 'select_page',
405 init: function (editor) {
407 // url -> name mapping for existing pages
408 this.pages = Object.create(null);
409 // name -> url mapping for the same
410 this.pages_by_name = Object.create(null);
414 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
415 this.editor.getSelection().selectElement(element);
417 this.element = element;
420 this.fetch_pages().done(this.proxy('fill_pages')),
422 ).done(this.proxy('bind_data'));
425 * Greatly simplified version of CKEDITOR's
426 * plugins.link.dialogs.link.onOk.
428 * @param {String} url
429 * @param {Boolean} [new_window=false]
430 * @param {String} [label=null]
432 make_link: function (url, new_window, label) {
433 var attributes = {href: url, 'data-cke-saved-href': url};
436 attributes['target'] = '_blank';
438 to_remove.push('target');
442 this.element.setAttributes(attributes);
443 this.element.removeAttributes(to_remove);
445 var selection = this.editor.getSelection();
446 var range = selection.getRanges(true)[0];
448 if (range.collapsed) {
449 //noinspection JSPotentiallyInvalidConstructorUsage
450 var text = new CKEDITOR.dom.text(label || url);
451 range.insertNode(text);
452 range.selectNodeContents(text);
455 //noinspection JSPotentiallyInvalidConstructorUsage
457 type: CKEDITOR.STYLE_INLINE,
459 attributes: attributes,
460 }).applyToRange(range);
462 // focus dance between RTE & dialog blow up the stack in Safari
463 // and Chrome, so defer select() until dialog has been closed
464 setTimeout(function () {
470 var self = this, _super = this._super.bind(this);
471 var $e = this.$('.url-source').filter(function () { return !!this.value; });
473 var val = $e.val(), done = $.when();
474 if ($e.hasClass('email-address')) {
475 this.make_link('mailto:' + val, false, val);
476 } else if ($e.hasClass('pages')) {
477 // ``val`` is the *name* of the page
478 var url = this.pages_by_name[val];
480 // Create the page, get the URL back
481 done = $.get(_.str.sprintf(
482 '/pagenew/%s?noredirect', encodeURIComponent(val)))
483 .then(function (response) {
487 done.then(function () {
488 self.make_link(url, false, val);
491 this.make_link(val, this.$('input.window-new').prop('checked'));
495 bind_data: function () {
496 var href = this.element && (this.element.data( 'cke-saved-href')
497 || this.element.getAttribute('href'));
498 if (!href) { return; }
501 if (match = /(mailto):(.+)/.exec(href)) {
502 $control = this.$('input.email-address').val(match[2]);
503 } else if(href in this.pages) {
504 $control = this.$('input.pages').val(this.pages[href]);
507 $control = this.$('input.url').val(href);
510 this.changed($control);
512 this.$('input.window-new').prop(
513 'checked', this.element.getAttribute('target') === '_blank');
515 changed: function ($e) {
516 $e.closest('li.list-group-item').addClass('active')
517 .siblings().removeClass('active');
518 this.$('.url-source').not($e).val('');
521 * Selected an existing page in dropdown
523 select_page: function (e) {
526 var $target = $(e.target);
527 this.$('input.pages').val($target.text()).change();
528 // No #dropdown('close'), and using #dropdown('toggle') sur
529 // #closest('.dropdown') makes the dropdown not work correctly
530 $target.closest('.open').removeClass('open')
533 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
534 * if the editor is set directly on a link it will thus not work.
536 get_selected_link: function () {
537 var sel = this.editor.getSelection(),
538 el = sel.getSelectedElement();
539 if (el && el.is('a')) { return el; }
541 var range = sel.getRanges(true)[0];
542 if (!range) { return null; }
544 range.shrink(CKEDITOR.SHRINK_TEXT);
545 return this.editor.elementPath(range.getCommonAncestor())
549 fetch_pages: function () {
550 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
552 method: 'list_pages',
557 fill_pages: function (results) {
559 var $pages = this.$('div.existing ul').empty();
560 _(results).each(function (result) {
561 self.pages[result.url] = result.name;
562 self.pages_by_name[result.name] = result.url;
563 var $link = $('<a>').attr('href', result.url).text(result.name);
564 $('<li>').append($link).appendTo($pages);
568 website.editor.ImageDialog = website.editor.Dialog.extend({
569 template: 'website.editor.dialog.image',
570 events: _.extend({}, website.editor.Dialog.prototype.events, {
571 'change .url-source': function (e) { this.changed($(e.target)); },
572 'click button.filepicker': function () {
573 this.$('input[type=file]').click();
575 'change input[type=file]': 'file_selection',
576 'change input.url': 'preview_image',
577 'click .existing-attachments a': 'select_existing',
580 var selection = this.editor.getSelection();
581 var el = selection && selection.getSelectedElement();
583 if (el && el.is('img')) {
585 this.set_image(el.getAttribute('src'));
590 this.fetch_existing().then(this.proxy('fetched_existing')));
593 var url = this.$('input.url').val();
594 var element, editor = this.editor;
595 if (!(element = this.element)) {
596 element = editor.document.createElement('img');
597 // focus event handler interactions between bootstrap (modal)
598 // and ckeditor (RTE) lead to blowing the stack in Safari and
599 // Chrome (but not FF) when this is done synchronously =>
600 // defer insertion so modal has been hidden & destroyed before
602 setTimeout(function () {
603 editor.insertElement(element);
606 element.setAttribute('src', url);
611 * Sets the provided image url as the dialog's value-to-save and
612 * refreshes the preview element to use it.
614 set_image: function (url) {
615 this.$('input.url').val(url);
616 this.preview_image();
619 file_selection: function (e) {
620 this.$('button.filepicker').removeClass('btn-danger btn-success');
623 var callback = _.uniqueId('func_');
624 this.$('input[name=func]').val(callback);
626 window[callback] = function (url, error) {
627 delete window[callback];
628 self.file_selected(url, error);
630 this.$('form').submit();
632 file_selected: function(url, error) {
633 var $button = this.$('button.filepicker');
635 $button.addClass('btn-danger');
638 $button.addClass('btn-success');
641 preview_image: function () {
642 var image = this.$('input.url').val();
643 if (!image) { return; }
645 this.$('img.image-preview').attr('src', image);
648 fetch_existing: function () {
649 // FIXME: lazy load attachments?
650 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
651 model: 'ir.attachment',
652 method: 'search_read',
656 domain: [['res_model', '=', 'ir.ui.view']],
661 fetched_existing: function (records) {
662 // Create rows of 3 records
663 var rows = _(records).chain()
664 .groupBy(function (_, index) { return Math.floor(index / 3); })
667 this.$('.existing-attachments').replaceWith(
668 openerp.qweb.render('website.editor.dialog.image.existing', {rows: rows}));
670 select_existing: function (e) {
672 this.set_image(e.currentTarget.getAttribute('href'));
677 var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
678 var OBSERVER_CONFIG = {
683 attributeOldValue: true,
685 var observer = new Observer(function (mutations) {
686 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
687 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
688 // will not mark dirty on attribute changes (@class, img/@src,
691 .filter(function (m) {
693 case 'attributes': // ignore .cke_focus being added or removed
694 // if attribute is not a class, can't be .cke_focus change
695 if (m.attributeName !== 'class') { return true; }
697 // find out what classes were added or removed
698 var oldClasses = m.oldValue.split(/\s+/);
699 var newClasses = m.target.className.split(/\s+/);
700 var change = _.union(_.difference(oldClasses, newClasses),
701 _.difference(newClasses, oldClasses));
702 // ignore mutation if the *only* change is .cke_focus
703 return change.length !== 1 || change[0] === 'cke_focus';
705 // <br type="_moz"> appears when focusing RTE in FF, ignore
706 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
713 while (node && !$(node).hasClass('oe_editable')) {
714 node = node.parentNode;
720 .each(function (node) { $(node).trigger('content_changed'); })