4 var website = openerp.website;
5 // $.fn.data automatically parses value, '0'|'1' -> 0|1
7 website.add_template_file('/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);
15 $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
16 // Prevent dropdown closing when a contenteditable children is focused
18 && $(ev.target).has(ev.originalEvent.target).length
19 && $(ev.originalEvent.target).is('[contenteditable]')) {
25 function link_dialog(editor) {
26 return new website.editor.RTELinkDialog(editor).appendTo(document.body);
28 function image_dialog(editor) {
29 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
32 // only enable editors manually
33 CKEDITOR.disableAutoInline = true;
34 // EDIT ALL THE THINGS
35 CKEDITOR.dtd.$editable = $.extend(
36 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
37 // Disable removal of empty elements on CKEDITOR activation. Empty
38 // elements are used for e.g. support of FontAwesome icons
39 CKEDITOR.dtd.$removeEmpty = {};
41 website.init_editor = function () {
42 CKEDITOR.plugins.add('customdialogs', {
43 // requires: 'link,image',
44 init: function (editor) {
45 editor.on('doubleclick', function (evt) {
46 var element = evt.data.element;
48 && !element.data('cke-realelement')
49 && !element.isReadOnly()
50 && (element.data('oe-model') !== 'ir.ui.view')) {
55 element = get_selected_link(editor) || evt.data.element;
56 if (element.isReadOnly()
58 || element.data('oe-model')) {
62 editor.getSelection().selectElement(element);
66 //noinspection JSValidateTypes
67 editor.addCommand('link', {
68 exec: function (editor) {
75 //noinspection JSValidateTypes
76 editor.addCommand('image', {
77 exec: function (editor) {
85 editor.ui.addButton('Link', {
89 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
91 editor.ui.addButton('Image', {
95 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
98 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
101 CKEDITOR.plugins.add( 'tablebutton', {
102 requires: 'panelbutton,floatpanel',
103 init: function( editor ) {
106 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
109 // use existing 'table' icon
111 modes: { wysiwyg: true },
113 // panel opens in iframe, @css is CSS file <link>-ed within
114 // frame document, @attributes are set on iframe itself.
116 css: '/website/static/src/css/editor.css',
117 attributes: { 'role': 'listbox', 'aria-label': label, },
120 onBlock: function (panel, block) {
121 block.autoSize = true;
122 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
127 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
128 var $e = $(e.target);
129 var y = $e.index() + 1;
130 var x = $e.closest('tr').index() + 1;
133 .find('td').removeClass('selected').end()
134 .find('tr:lt(' + String(x) + ')')
135 .children().filter(function () { return $(this).index() < y; })
136 .addClass('selected');
137 }).on('click', 'td', function (e) {
138 var $e = $(e.target);
140 //noinspection JSPotentiallyInvalidConstructorUsage
141 var table = new CKEDITOR.dom.element(
142 $(openerp.qweb.render('website.editor.table', {
143 rows: $e.closest('tr').index() + 1,
144 cols: $e.index() + 1,
147 editor.insertElement(table);
148 setTimeout(function () {
149 //noinspection JSPotentiallyInvalidConstructorUsage
150 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
151 var range = editor.createRange();
152 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
157 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
158 CKEDITOR.ui.fire('ready', this);
164 CKEDITOR.plugins.add('oeref', {
167 init: function (editor) {
168 editor.widgets.add('oeref', {
169 editables: { text: '*' },
171 upcast: function (el) {
172 return el.attributes['data-oe-type']
173 && el.attributes['data-oe-type'] !== 'monetary';
176 editor.widgets.add('monetary', {
177 editables: { text: 'span.oe_currency_value' },
179 upcast: function (el) {
180 return el.attributes['data-oe-type'] === 'monetary';
186 CKEDITOR.plugins.add('bootstrapcombo', {
187 requires: 'richcombo',
189 init: function (editor) {
190 var config = editor.config;
192 editor.ui.addRichCombo('BootstrapLinkCombo', {
196 title: "Link styling",
197 toolbar: 'styles,10',
198 allowedContent: ['a'],
202 '/website/static/lib/bootstrap/css/bootstrap.css',
203 CKEDITOR.skin.getPath( 'editor' )
204 ].concat( config.contentsCss ),
209 'basic': 'btn-default',
210 'primary': 'btn-primary',
211 'success': 'btn-success',
213 'warning': 'btn-warning',
214 'danger': 'btn-danger',
221 'extra small': 'btn-xs',
225 this.add('', 'Reset');
226 this.startGroup("Types");
227 for(var type in this.types) {
228 if (!this.types.hasOwnProperty(type)) { continue; }
229 var cls = this.types[type];
230 var el = _.str.sprintf(
231 '<span class="btn %s">%s</span>',
235 this.startGroup("Sizes");
236 for (var size in this.sizes) {
237 if (!this.sizes.hasOwnProperty(size)) { continue; }
238 cls = this.sizes[size];
241 '<span class="btn btn-default %s">%s</span>',
247 onRender: function () {
249 editor.on('selectionChange', function (e) {
250 var path = e.data.path, el;
252 if (!(el = path.contains('a'))) {
259 // This is crap, but getting the currently selected
260 // element from within onOpen absolutely does not
261 // work, so store the "current" element in the
265 setTimeout(function () {
266 // Because I can't find any normal hook where the
267 // bloody button's bloody element is available
271 onOpen: function () {
275 for(var val in this.types) {
276 if (!this.types.hasOwnProperty(val)) { continue; }
277 var cls = this.types[val];
278 if (!this.element.hasClass(cls)) { continue; }
285 for(val in this.sizes) {
286 if (!this.sizes.hasOwnProperty(val)) { continue; }
287 cls = this.sizes[val];
288 if (!cls || !this.element.hasClass(cls)) { continue; }
294 if (!found && this.element.hasClass('btn')) {
295 this.mark('default');
298 onClick: function (value) {
300 editor.fire('saveShapshot');
303 var el = this.element;
304 if (!el.hasClass('btn')) {
306 el.addClass('btn-default');
310 this.setClass(this.types);
311 this.setClass(this.sizes);
312 el.removeClass('btn');
313 } else if (value in this.types) {
314 this.setClass(this.types, value);
315 } else if (value in this.sizes) {
316 this.setClass(this.sizes, value);
319 editor.fire('saveShapshot');
321 setClass: function (classMap, value) {
322 var element = this.element;
323 _(classMap).each(function (cls) {
324 if (!cls) { return; }
325 element.removeClass(cls);
328 var cls = classMap[value];
330 element.addClass(cls);
337 var editor = new website.EditorBar();
338 var $body = $(document.body);
339 editor.prependTo($body).then(function () {
340 if (location.search.indexOf("enable_editor") >= 0) {
344 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
346 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
347 website.EditorBar = openerp.Widget.extend({
348 template: 'website.editorbar',
350 'click button[data-action=edit]': 'edit',
351 'click button[data-action=save]': 'save',
352 'click button[data-action=cancel]': 'cancel',
355 customize_setup: function() {
357 var view_name = $(document.documentElement).data('view-xmlid');
358 var menu = $('#customize-menu');
359 this.$('#customize-menu-button').click(function(event) {
361 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
363 _.each(result, function (item) {
365 menu.append('<li class="dropdown-header">' + item.name + '</li>');
367 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
368 item.id, item.active ? '' : '-empty', item.name));
371 // Adding Static Menus
372 menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
373 menu.append('<li class="divider"></li><li><a data-action="ace" href="#">HTML Editor</a></li>');
374 self.trigger('rte:customize_menu_ready');
378 menu.on('click', 'a[data-action!=ace]', function (event) {
379 var view_id = $(event.currentTarget).data('view-id');
380 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
382 }).then( function() {
383 window.location.reload();
390 this.saving_mutex = new openerp.Mutex();
392 this.$('#website-top-edit').hide();
393 this.$('#website-top-view').show();
395 $('.dropdown-toggle').dropdown();
396 this.customize_setup();
399 edit: this.$('button[data-action=edit]'),
400 save: this.$('button[data-action=save]'),
401 cancel: this.$('button[data-action=cancel]'),
404 this.rte = new website.RTE(this);
405 this.rte.on('change', this, this.proxy('rte_changed'));
406 this.rte.on('rte:ready', this, function () {
407 self.trigger('rte:ready');
411 this._super.apply(this, arguments),
412 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
416 this.$buttons.edit.prop('disabled', true);
417 this.$('#website-top-view').hide();
418 this.$('#website-top-edit').show();
419 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
421 this.rte.start_edition();
423 rte_changed: function () {
424 this.$buttons.save.prop('disabled', false);
429 observer.disconnect();
430 var editor = this.rte.editor;
431 var root = editor.element.$;
433 // FIXME: select editables then filter by dirty?
434 var defs = this.rte.fetch_editables(root)
436 .removeAttr('contentEditable')
437 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
440 // TODO: Add a queue with concurrency limit in webclient
441 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
442 return self.saving_mutex.exec(function () {
443 return self.saveElement($el)
444 .then(undefined, function (thing, response) {
445 // because ckeditor regenerates all the dom,
446 // we can't just setup the popover here as
447 // everything will be destroyed by the DOM
448 // regeneration. Add markings instead, and
449 // returns a new rejection with all relevant
451 var id = _.uniqueId('carlos_danger_');
452 $el.addClass('oe_dirty oe_carlos_danger');
454 return $.Deferred().reject({
456 error: response.data,
461 return $.when.apply(null, defs).then(function () {
463 }, function (failed) {
464 // If there were errors, re-enable edition
465 self.rte.start_edition(true).then(function () {
466 // jquery's deferred being a pain in the ass
467 if (!_.isArray(failed)) { failed = [failed]; }
469 _(failed).each(function (failure) {
470 $(root).find('.' + failure.id)
471 .removeClass(failure.id)
474 content: failure.error.message,
475 placement: 'auto top',
477 // Force-show popovers so users will notice them.
484 * Saves an RTE content, which always corresponds to a view section (?).
486 saveElement: function ($el) {
487 var markup = $el.prop('outerHTML');
488 return openerp.jsonRpc('/web/dataset/call', 'call', {
491 args: [$el.data('oe-id'), markup,
492 $el.data('oe-xpath') || null,
493 website.get_context()],
496 cancel: function () {
501 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
502 /* ----- RICH TEXT EDITOR ---- */
503 website.RTE = openerp.Widget.extend({
505 id: 'oe_rte_toolbar',
506 className: 'oe_right oe_rte_toolbar',
507 // editor.ui.items -> possible commands &al
508 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
510 init: function (EditorBar) {
511 this.EditorBar = EditorBar;
512 this._super.apply(this, arguments);
516 * In Webkit-based browsers, triple-click will select a paragraph up to
517 * the start of the next "paragraph" including any empty space
518 * inbetween. When said paragraph is removed or altered, it nukes
519 * the empty space and brings part of the content of the next
520 * "paragraph" (which may well be e.g. an image) into the current one,
521 * completely fucking up layouts and breaking snippets.
523 * Try to fuck around with selections on triple-click to attempt to
524 * fix this garbage behavior.
526 * Note: for consistent behavior we may actually want to take over
527 * triple-clicks, in all browsers in order to ensure consistent cross-
528 * platform behavior instead of being at the mercy of rendering engines
529 * & platform selection quirks?
531 webkitSelectionFixer: function (root) {
532 root.addEventListener('click', function (e) {
533 // only webkit seems to have a fucked up behavior, ignore others
534 // FIXME: $.browser goes away in jquery 1.9...
535 if (!$.browser.webkit) { return; }
536 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
537 // The detail attribute indicates the number of times a mouse button has been pressed
538 // we just want the triple click
539 if (e.detail !== 3) { return; }
542 // Get closest block-level element to the triple-clicked
543 // element (using ckeditor's block list because why not)
544 var $closest_block = $(e.target).closest(blocks_selector);
546 // manually set selection range to the content of the
547 // triple-clicked block-level element, to avoid crossing over
548 // between block-level elements
549 document.getSelection().selectAllChildren($closest_block[0]);
552 tableNavigation: function (root) {
554 $(root).on('keydown', function (e) {
556 if (e.which !== 9) { return; }
558 if (self.handleTab(e)) {
564 * Performs whatever operation is necessary on a [TAB] hit, returns
565 * ``true`` if the event's default should be cancelled (if the TAB was
566 * handled by the function)
568 handleTab: function (event) {
569 var forward = !event.shiftKey;
571 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
572 var $cell = $(root).closest('td,th');
574 if (!$cell.length) { return false; }
578 // find cell in same row
579 var row = cell.parentNode;
580 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
582 document.getSelection().selectAllChildren(sibling);
586 // find cell in previous/next row
587 var table = row.parentNode;
588 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
590 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
591 document.getSelection().selectAllChildren(new_cell);
595 // at edge cells, copy word/openoffice behavior: if going backwards
596 // from first cell do nothing, if going forwards from last cell add
599 var row_size = row.cells.length;
600 var new_row = document.createElement('tr');
602 var newcell = document.createElement('td');
604 newcell.textContent = '\u200B';
605 new_row.appendChild(newcell);
607 table.appendChild(new_row);
608 document.getSelection().selectAllChildren(new_row.cells[0]);
614 * Makes the page editable
616 * @param {Boolean} [restart=false] in case the edition was already set
617 * up once and is being re-enabled.
618 * @returns {$.Deferred} deferred indicating when the RTE is ready
620 start_edition: function (restart) {
622 // create a single editor for the whole page
623 var root = document.getElementById('wrapwrap');
625 $(root).on('dragstart', 'img', function (e) {
628 this.webkitSelectionFixer(root);
629 this.tableNavigation(root);
631 var def = $.Deferred();
632 var editor = this.editor = CKEDITOR.inline(root, self._config());
633 editor.on('instanceReady', function () {
634 editor.setReadOnly(false);
635 // ckeditor set root to editable, disable it (only inner
636 // sections are editable)
637 // FIXME: are there cases where the whole editor is editable?
638 editor.editable().setReadOnly(true);
640 self.setup_editables(root);
642 // disable firefox's broken table resizing thing
643 document.execCommand("enableObjectResizing", false, "false");
644 document.execCommand("enableInlineTableEditing", false, "false");
646 self.trigger('rte:ready');
652 setup_editables: function (root) {
653 // selection of editable sub-items was previously in
654 // EditorBar#edit, but for some unknown reason the elements were
655 // apparently removed and recreated (?) at editor initalization,
656 // and observer setup was lost.
658 // setup dirty-marking for each editable element
659 this.fetch_editables(root)
660 .addClass('oe_editable')
664 // only explicitly set contenteditable on view sections,
665 // cke widgets system will do the widgets themselves
666 if ($node.data('oe-model') === 'ir.ui.view') {
667 node.contentEditable = true;
670 observer.observe(node, OBSERVER_CONFIG);
671 $node.one('content_changed', function () {
672 $node.addClass('oe_dirty');
673 self.trigger('change');
678 fetch_editables: function (root) {
679 return $(root).find('[data-oe-model]')
681 .not('.oe_snippet_editor')
682 .filter(function () {
684 // keep view sections and fields which are *not* in
685 // view sections for top-level editables
686 return $this.data('oe-model') === 'ir.ui.view'
687 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
691 _current_editor: function () {
692 return CKEDITOR.currentInstance;
694 _config: function () {
695 // base plugins minus
696 // - magicline (captures mousein/mouseout -> breaks draggable)
697 // - contextmenu & tabletools (disable contextual menu)
698 // - bunch of unused plugins
700 'a11yhelp', 'basicstyles', 'blockquote',
701 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
702 'elementspath', 'enterkey', 'entities', 'filebrowser',
703 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
704 'indentblock', 'indentlist', 'justify',
705 'list', 'pastefromword', 'pastetext', 'preview',
706 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
707 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
712 // Disable auto-generated titles
713 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
715 plugins: plugins.join(','),
717 // FIXME: currently breaks RTE?
718 // Ensure no config file is loaded
721 allowedContent: true,
722 // Don't insert paragraphs around content in e.g. <li>
723 autoParagraph: false,
724 // Don't automatically add or <br> in empty block-level
725 // elements when edition starts
726 fillEmptyBlocks: false,
727 filebrowserImageUploadUrl: "/website/attach",
728 // Support for sharedSpaces in 4.x
729 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
730 // Place toolbar in controlled location
731 sharedSpaces: { top: 'oe_rte_toolbar' },
733 name: 'clipboard', items: [
736 name: 'basicstyles', items: [
737 "Bold", "Italic", "Underline", "Strike", "Subscript",
738 "Superscript", "TextColor", "BGColor", "RemoveFormat"
740 name: 'span', items: [
741 "Link", "Blockquote", "BulletedList",
742 "NumberedList", "Indent", "Outdent"
744 name: 'justify', items: [
745 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
747 name: 'special', items: [
748 "Image", "TableButton"
750 name: 'styles', items: [
751 "Styles", "BootstrapLinkCombo"
754 // styles dropdown in toolbar
756 {name: "Normal", element: 'p'},
757 {name: "Heading 1", element: 'h1'},
758 {name: "Heading 2", element: 'h2'},
759 {name: "Heading 3", element: 'h3'},
760 {name: "Heading 4", element: 'h4'},
761 {name: "Heading 5", element: 'h5'},
762 {name: "Heading 6", element: 'h6'},
763 {name: "Formatted", element: 'pre'},
764 {name: "Address", element: 'address'}
770 website.editor = { };
771 website.editor.Dialog = openerp.Widget.extend({
773 'hidden.bs.modal': 'destroy',
774 'click button.save': 'save',
776 init: function (editor) {
778 this.editor = editor;
781 var sup = this._super();
782 this.$el.modal({backdrop: 'static'});
789 this.$el.modal('hide');
793 website.editor.LinkDialog = website.editor.Dialog.extend({
794 template: 'website.editor.dialog.link',
795 events: _.extend({}, website.editor.Dialog.prototype.events, {
796 'change .url-source': function (e) { this.changed($(e.target)); },
797 'mousedown': function (e) {
798 var $target = $(e.target).closest('.list-group-item');
799 if (!$target.length || $target.hasClass('active')) {
800 // clicked outside groups, or clicked in active groups
804 this.changed($target.find('.url-source'));
806 'click button.remove': 'remove_link',
807 'change input#link-text': function (e) {
808 this.text = $(e.target).val()
811 init: function (editor) {
813 // url -> name mapping for existing pages
814 this.pages = Object.create(null);
820 this.fetch_pages().done(this.proxy('fill_pages')),
827 var self = this, _super = this._super.bind(this);
828 var $e = this.$('.list-group-item.active .url-source');
830 if (!val || !$e[0].checkValidity()) {
831 // FIXME: error message
832 $e.closest('.form-group').addClass('has-error');
838 if ($e.hasClass('email-address')) {
839 this.make_link('mailto:' + val, false, val);
840 } else if ($e.hasClass('existing')) {
841 self.make_link(val, false, this.pages[val]);
842 } else if ($e.hasClass('pages')) {
843 // Create the page, get the URL back
844 done = $.get(_.str.sprintf(
845 '/pagenew/%s?noredirect', encodeURI(val)))
846 .then(function (response) {
847 self.make_link(response, false, val);
850 this.make_link(val, this.$('input.window-new').prop('checked'));
854 make_link: function (url, new_window, label) {
856 bind_data: function (text, href, new_window) {
857 href = href || this.element && (this.element.data( 'cke-saved-href')
858 || this.element.getAttribute('href'));
859 if (!href) { return; }
861 if (new_window === undefined) {
862 new_window = this.element.getAttribute('target') === '_blank';
864 if (text === undefined) {
865 text = this.element.getText();
869 if ((match = /mailto:(.+)/.exec(href))) {
870 $control = this.$('input.email-address').val(match[1]);
871 } else if (href in this.pages) {
872 $control = this.$('select.existing').val(href);
873 } else if ((match = /\/page\/(.+)/.exec(href))) {
874 var actual_href = '/page/website.' + match[1];
875 if (actual_href in this.pages) {
876 $control = this.$('select.existing').val(actual_href);
880 $control = this.$('input.url').val(href);
883 this.changed($control);
885 this.$('input#link-text').val(text);
886 this.$('input.window-new').prop('checked', new_window);
888 changed: function ($e) {
889 this.$('.url-source').not($e).val('');
890 $e.closest('.list-group-item')
892 .siblings().removeClass('active')
893 .addBack().removeClass('has-error');
895 fetch_pages: function () {
896 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
898 method: 'list_pages',
901 context: website.get_context()
905 fill_pages: function (results) {
907 var pages = this.$('select.existing')[0];
908 _(results).each(function (result) {
909 self.pages[result.url] = result.name;
911 pages.options[pages.options.length] =
912 new Option(result.name, result.url);
916 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
919 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
920 this.editor.getSelection().selectElement(element);
922 this.element = element;
924 this.add_removal_button();
927 return this._super();
929 add_removal_button: function () {
930 this.$('.modal-footer').prepend(
932 'website.editor.dialog.link.footer-button'));
934 remove_link: function () {
935 var editor = this.editor;
936 // same issue as in make_link
937 setTimeout(function () {
938 editor.removeStyle(new CKEDITOR.style({
940 type: CKEDITOR.STYLE_INLINE,
941 alwaysRemoveElement: true,
947 * Greatly simplified version of CKEDITOR's
948 * plugins.link.dialogs.link.onOk.
950 * @param {String} url
951 * @param {Boolean} [new_window=false]
952 * @param {String} [label=null]
954 make_link: function (url, new_window, label) {
955 var attributes = {href: url, 'data-cke-saved-href': url};
958 attributes['target'] = '_blank';
960 to_remove.push('target');
964 this.element.setAttributes(attributes);
965 this.element.removeAttributes(to_remove);
966 if (this.text) { this.element.setText(this.text); }
968 var selection = this.editor.getSelection();
969 var range = selection.getRanges(true)[0];
971 if (range.collapsed) {
972 //noinspection JSPotentiallyInvalidConstructorUsage
973 var text = new CKEDITOR.dom.text(
974 this.text || label || url);
975 range.insertNode(text);
976 range.selectNodeContents(text);
979 //noinspection JSPotentiallyInvalidConstructorUsage
981 type: CKEDITOR.STYLE_INLINE,
983 attributes: attributes,
984 }).applyToRange(range);
986 // focus dance between RTE & dialog blow up the stack in Safari
987 // and Chrome, so defer select() until dialog has been closed
988 setTimeout(function () {
994 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
995 * if the editor is set directly on a link it will thus not work.
997 get_selected_link: function () {
998 return get_selected_link(this.editor);
1003 * ImageDialog widget. Lets users change an image, including uploading a
1004 * new image in OpenERP or selecting the image style (if supported by
1007 * Initialized as usual, but the caller can hook into two events:
1009 * @event start({url, style}) called during dialog initialization and
1010 * opening, the handler can *set* the ``url``
1011 * and ``style`` properties on its parameter
1012 * to provide these as default values to the
1014 * @event save({url, style}) called during dialog finalization, the handler
1015 * is provided with the image url and style
1016 * selected by the users (or possibly the ones
1017 * originally passed in)
1019 website.editor.ImageDialog = website.editor.Dialog.extend({
1020 template: 'website.editor.dialog.image',
1021 events: _.extend({}, website.editor.Dialog.prototype.events, {
1022 'change .url-source': function (e) { this.changed($(e.target)); },
1023 'click button.filepicker': function () {
1024 this.$('input[type=file]').click();
1026 'change input[type=file]': 'file_selection',
1027 'change input.url': 'preview_image',
1028 'click a[href=#existing]': 'browse_existing',
1029 'change select.image-style': 'preview_image',
1032 start: function () {
1033 var $options = this.$('.image-style').children();
1034 this.image_styles = $options.map(function () { return this.value; }).get();
1036 var o = { url: null, style: null, };
1037 // avoid typos, prevent addition of new properties to the object
1038 Object.preventExtensions(o);
1039 this.trigger('start', o);
1043 this.$('.image-style').val(o.style);
1045 this.set_image(o.url);
1048 return this._super();
1051 this.trigger('save', {
1052 url: this.$('input.url').val(),
1053 style: this.$('.image-style').val(),
1055 return this._super();
1059 * Sets the provided image url as the dialog's value-to-save and
1060 * refreshes the preview element to use it.
1062 set_image: function (url) {
1063 this.$('input.url').val(url);
1064 this.preview_image();
1067 file_selection: function () {
1068 this.$('button.filepicker').removeClass('btn-danger btn-success');
1071 var callback = _.uniqueId('func_');
1072 this.$('input[name=func]').val(callback);
1074 window[callback] = function (url, error) {
1075 delete window[callback];
1076 self.file_selected(url, error);
1078 this.$('form').submit();
1080 file_selected: function(url, error) {
1081 var $button = this.$('button.filepicker');
1083 $button.addClass('btn-danger');
1086 $button.addClass('btn-success');
1087 this.set_image(url);
1089 preview_image: function () {
1090 var image = this.$('input.url').val();
1091 if (!image) { return; }
1093 this.$('img.image-preview')
1095 .removeClass(this.image_styles.join(' '))
1096 .addClass(this.$('select.image-style').val());
1098 browse_existing: function (e) {
1100 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1103 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1105 this._super.apply(this, arguments);
1107 this.on('start', this, this.proxy('started'));
1108 this.on('save', this, this.proxy('saved'));
1110 started: function (holder) {
1111 var selection = this.editor.getSelection();
1112 var el = selection && selection.getSelectedElement();
1113 this.element = null;
1115 if (el && el.is('img')) {
1117 _(this.image_styles).each(function (style) {
1118 if (el.hasClass(style)) {
1119 holder.style = style;
1122 holder.url = el.getAttribute('src');
1125 saved: function (data) {
1126 var element, editor = this.editor;
1127 if (!(element = this.element)) {
1128 element = editor.document.createElement('img');
1129 element.addClass('img');
1130 // focus event handler interactions between bootstrap (modal)
1131 // and ckeditor (RTE) lead to blowing the stack in Safari and
1132 // Chrome (but not FF) when this is done synchronously =>
1133 // defer insertion so modal has been hidden & destroyed before
1135 setTimeout(function () {
1136 editor.insertElement(element);
1140 var style = data.style;
1141 element.setAttribute('src', data.url);
1142 element.removeAttribute('data-cke-saved-src');
1143 $(element.$).removeClass(this.image_styles.join(' '));
1144 if (style) { element.addClass(style); }
1148 var IMAGES_PER_ROW = 6;
1149 var IMAGES_ROWS = 4;
1150 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1151 template: 'website.editor.dialog.image.existing',
1152 events: _.extend({}, website.editor.Dialog.prototype.events, {
1153 'click .existing-attachments img': 'select_existing',
1154 'click .pager > li': function (e) {
1156 var $target = $(e.currentTarget);
1157 if ($target.hasClass('disabled')) {
1160 this.page += $target.hasClass('previous') ? -1 : 1;
1161 this.display_attachments();
1164 init: function (parent) {
1167 this.parent = parent;
1168 this._super(parent.editor);
1171 start: function () {
1174 this.fetch_existing().then(this.proxy('fetched_existing')));
1177 fetch_existing: function () {
1178 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1179 model: 'ir.attachment',
1180 method: 'search_read',
1183 fields: ['name', 'website_url'],
1184 domain: [['res_model', '=', 'ir.ui.view']],
1186 context: website.get_context(),
1190 fetched_existing: function (records) {
1191 this.records = records;
1192 this.display_attachments();
1194 display_attachments: function () {
1195 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1197 var from = this.page * per_screen;
1198 var records = this.records;
1200 // Create rows of 3 records
1201 var rows = _(records).chain()
1202 .slice(from, from + per_screen)
1203 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1207 this.$('.existing-attachments').replaceWith(
1208 openerp.qweb.render(
1209 'website.editor.dialog.image.existing.content', {rows: rows}));
1211 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1212 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1215 select_existing: function (e) {
1216 var link = $(e.currentTarget).attr('src');
1218 this.parent.set_image(link);
1224 function get_selected_link(editor) {
1225 var sel = editor.getSelection(),
1226 el = sel.getSelectedElement();
1227 if (el && el.is('a')) { return el; }
1229 var range = sel.getRanges(true)[0];
1230 if (!range) { return null; }
1232 range.shrink(CKEDITOR.SHRINK_TEXT);
1233 var commonAncestor = range.getCommonAncestor();
1234 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1235 return element.data('oe-model') === 'ir.ui.view'
1237 if (!viewRoot) { return null; }
1238 // if viewRoot is the first link, don't edit it.
1239 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1240 .contains('a', true);
1244 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1245 var OBSERVER_CONFIG = {
1248 characterData: true,
1250 attributeOldValue: true,
1252 var observer = new website.Observer(function (mutations) {
1253 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1254 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1255 // will not mark dirty on attribute changes (@class, img/@src,
1257 _(mutations).chain()
1258 .filter(function (m) {
1260 case 'attributes': // ignore .cke_focus being added or removed
1261 // if attribute is not a class, can't be .cke_focus change
1262 if (m.attributeName !== 'class') { return true; }
1264 // find out what classes were added or removed
1265 var oldClasses = (m.oldValue || '').split(/\s+/);
1266 var newClasses = m.target.className.split(/\s+/);
1267 var change = _.union(_.difference(oldClasses, newClasses),
1268 _.difference(newClasses, oldClasses));
1269 // ignore mutation if the *only* change is .cke_focus
1270 return change.length !== 1 || change[0] === 'cke_focus';
1272 // <br type="_moz"> appears when focusing RTE in FF, ignore
1273 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1279 var node = m.target;
1280 while (node && !$(node).hasClass('oe_editable')) {
1281 node = node.parentNode;
1283 $(m.target).trigger('node_changed');
1288 .each(function (node) { $(node).trigger('content_changed'); })