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 var previousSelection;
67 editor.on('selectionChange', function (evt) {
68 var selected = evt.data.path.lastElement;
69 if (previousSelection) {
70 // cleanup previous selection
71 $(previousSelection).next().remove();
72 previousSelection = null;
74 if (!selected.is('img')
75 || selected.data('cke-realelement')
76 || selected.isReadOnly()
77 || selected.data('oe-model') === 'ir.ui.view') {
82 var $el = $(previousSelection = selected.$);
83 var $btn = $('<button type="button" class="btn btn-primary" contenteditable="false">Edit</button>')
91 var position = $el.position();
94 top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
95 left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
99 //noinspection JSValidateTypes
100 editor.addCommand('link', {
101 exec: function (editor) {
108 //noinspection JSValidateTypes
109 editor.addCommand('image', {
110 exec: function (editor) {
111 image_dialog(editor);
118 editor.ui.addButton('Link', {
122 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
124 editor.ui.addButton('Image', {
127 toolbar: 'insert,10',
128 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
131 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
134 CKEDITOR.plugins.add( 'tablebutton', {
135 requires: 'panelbutton,floatpanel',
136 init: function( editor ) {
139 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
142 // use existing 'table' icon
144 modes: { wysiwyg: true },
146 // panel opens in iframe, @css is CSS file <link>-ed within
147 // frame document, @attributes are set on iframe itself.
149 css: '/website/static/src/css/editor.css',
150 attributes: { 'role': 'listbox', 'aria-label': label, },
153 onBlock: function (panel, block) {
154 block.autoSize = true;
155 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
160 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
161 var $e = $(e.target);
162 var y = $e.index() + 1;
163 var x = $e.closest('tr').index() + 1;
166 .find('td').removeClass('selected').end()
167 .find('tr:lt(' + String(x) + ')')
168 .children().filter(function () { return $(this).index() < y; })
169 .addClass('selected');
170 }).on('click', 'td', function (e) {
171 var $e = $(e.target);
173 //noinspection JSPotentiallyInvalidConstructorUsage
174 var table = new CKEDITOR.dom.element(
175 $(openerp.qweb.render('website.editor.table', {
176 rows: $e.closest('tr').index() + 1,
177 cols: $e.index() + 1,
180 editor.insertElement(table);
181 setTimeout(function () {
182 //noinspection JSPotentiallyInvalidConstructorUsage
183 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
184 var range = editor.createRange();
185 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
190 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
191 CKEDITOR.ui.fire('ready', this);
197 CKEDITOR.plugins.add('oeref', {
200 init: function (editor) {
201 editor.widgets.add('oeref', {
202 editables: { text: '*' },
204 upcast: function (el) {
205 return el.attributes['data-oe-type']
206 && el.attributes['data-oe-type'] !== 'monetary';
209 editor.widgets.add('monetary', {
210 editables: { text: 'span.oe_currency_value' },
212 upcast: function (el) {
213 return el.attributes['data-oe-type'] === 'monetary';
219 CKEDITOR.plugins.add('bootstrapcombo', {
220 requires: 'richcombo',
222 init: function (editor) {
223 var config = editor.config;
225 editor.ui.addRichCombo('BootstrapLinkCombo', {
229 title: "Link styling",
230 toolbar: 'styles,10',
231 allowedContent: ['a'],
235 '/website/static/lib/bootstrap/css/bootstrap.css',
236 CKEDITOR.skin.getPath( 'editor' )
237 ].concat( config.contentsCss ),
242 'basic': 'btn-default',
243 'primary': 'btn-primary',
244 'success': 'btn-success',
246 'warning': 'btn-warning',
247 'danger': 'btn-danger',
254 'extra small': 'btn-xs',
258 this.add('', 'Reset');
259 this.startGroup("Types");
260 for(var type in this.types) {
261 if (!this.types.hasOwnProperty(type)) { continue; }
262 var cls = this.types[type];
263 var el = _.str.sprintf(
264 '<span class="btn %s">%s</span>',
268 this.startGroup("Sizes");
269 for (var size in this.sizes) {
270 if (!this.sizes.hasOwnProperty(size)) { continue; }
271 cls = this.sizes[size];
274 '<span class="btn btn-default %s">%s</span>',
280 onRender: function () {
282 editor.on('selectionChange', function (e) {
283 var path = e.data.path, el;
285 if (!(el = path.contains('a'))) {
292 // This is crap, but getting the currently selected
293 // element from within onOpen absolutely does not
294 // work, so store the "current" element in the
298 setTimeout(function () {
299 // Because I can't find any normal hook where the
300 // bloody button's bloody element is available
304 onOpen: function () {
308 for(var val in this.types) {
309 if (!this.types.hasOwnProperty(val)) { continue; }
310 var cls = this.types[val];
311 if (!this.element.hasClass(cls)) { continue; }
318 for(val in this.sizes) {
319 if (!this.sizes.hasOwnProperty(val)) { continue; }
320 cls = this.sizes[val];
321 if (!cls || !this.element.hasClass(cls)) { continue; }
327 if (!found && this.element.hasClass('btn')) {
328 this.mark('default');
331 onClick: function (value) {
333 editor.fire('saveShapshot');
336 var el = this.element;
337 if (!el.hasClass('btn')) {
339 el.addClass('btn-default');
343 this.setClass(this.types);
344 this.setClass(this.sizes);
345 el.removeClass('btn');
346 } else if (value in this.types) {
347 this.setClass(this.types, value);
348 } else if (value in this.sizes) {
349 this.setClass(this.sizes, value);
352 editor.fire('saveShapshot');
354 setClass: function (classMap, value) {
355 var element = this.element;
356 _(classMap).each(function (cls) {
357 if (!cls) { return; }
358 element.removeClass(cls);
361 var cls = classMap[value];
363 element.addClass(cls);
370 var editor = new website.EditorBar();
371 var $body = $(document.body);
372 editor.prependTo($body).then(function () {
373 if (location.search.indexOf("enable_editor") >= 0) {
377 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
379 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
380 website.EditorBar = openerp.Widget.extend({
381 template: 'website.editorbar',
383 'click button[data-action=edit]': 'edit',
384 'click button[data-action=save]': 'save',
385 'click button[data-action=cancel]': 'cancel',
388 customize_setup: function() {
390 var view_name = $(document.documentElement).data('view-xmlid');
391 var menu = $('#customize-menu');
392 this.$('#customize-menu-button').click(function(event) {
394 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
396 _.each(result, function (item) {
398 menu.append('<li class="dropdown-header">' + item.name + '</li>');
400 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
401 item.id, item.active ? '' : '-empty', item.name));
404 // Adding Static Menus
405 menu.append('<li class="divider"></li><li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
406 menu.append('<li><a data-action="ace" href="#">HTML Editor</a></li>');
407 self.trigger('rte:customize_menu_ready');
411 menu.on('click', 'a[data-action!=ace]', function (event) {
412 var view_id = $(event.currentTarget).data('view-id');
413 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
415 }).then( function() {
416 window.location.reload();
423 this.saving_mutex = new openerp.Mutex();
425 this.$('#website-top-edit').hide();
426 this.$('#website-top-view').show();
428 $('.dropdown-toggle').dropdown();
429 this.customize_setup();
432 edit: this.$('button[data-action=edit]'),
433 save: this.$('button[data-action=save]'),
434 cancel: this.$('button[data-action=cancel]'),
437 this.rte = new website.RTE(this);
438 this.rte.on('change', this, this.proxy('rte_changed'));
439 this.rte.on('rte:ready', this, function () {
440 self.trigger('rte:ready');
444 this._super.apply(this, arguments),
445 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
449 this.$buttons.edit.prop('disabled', true);
450 this.$('#website-top-view').hide();
451 this.$('#website-top-edit').show();
452 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
454 this.rte.start_edition();
456 rte_changed: function () {
457 this.$buttons.save.prop('disabled', false);
462 observer.disconnect();
463 var editor = this.rte.editor;
464 var root = editor.element.$;
466 // FIXME: select editables then filter by dirty?
467 var defs = this.rte.fetch_editables(root)
469 .removeAttr('contentEditable')
470 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
473 // TODO: Add a queue with concurrency limit in webclient
474 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
475 return self.saving_mutex.exec(function () {
476 return self.saveElement($el)
477 .then(undefined, function (thing, response) {
478 // because ckeditor regenerates all the dom,
479 // we can't just setup the popover here as
480 // everything will be destroyed by the DOM
481 // regeneration. Add markings instead, and
482 // returns a new rejection with all relevant
484 var id = _.uniqueId('carlos_danger_');
485 $el.addClass('oe_dirty oe_carlos_danger');
487 return $.Deferred().reject({
489 error: response.data,
494 return $.when.apply(null, defs).then(function () {
496 }, function (failed) {
497 // If there were errors, re-enable edition
498 self.rte.start_edition(true).then(function () {
499 // jquery's deferred being a pain in the ass
500 if (!_.isArray(failed)) { failed = [failed]; }
502 _(failed).each(function (failure) {
503 $(root).find('.' + failure.id)
504 .removeClass(failure.id)
507 content: failure.error.message,
508 placement: 'auto top',
510 // Force-show popovers so users will notice them.
517 * Saves an RTE content, which always corresponds to a view section (?).
519 saveElement: function ($el) {
520 var markup = $el.prop('outerHTML');
521 return openerp.jsonRpc('/web/dataset/call', 'call', {
524 args: [$el.data('oe-id'), markup,
525 $el.data('oe-xpath') || null,
526 website.get_context()],
529 cancel: function () {
534 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
535 /* ----- RICH TEXT EDITOR ---- */
536 website.RTE = openerp.Widget.extend({
538 id: 'oe_rte_toolbar',
539 className: 'oe_right oe_rte_toolbar',
540 // editor.ui.items -> possible commands &al
541 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
543 init: function (EditorBar) {
544 this.EditorBar = EditorBar;
545 this._super.apply(this, arguments);
549 * In Webkit-based browsers, triple-click will select a paragraph up to
550 * the start of the next "paragraph" including any empty space
551 * inbetween. When said paragraph is removed or altered, it nukes
552 * the empty space and brings part of the content of the next
553 * "paragraph" (which may well be e.g. an image) into the current one,
554 * completely fucking up layouts and breaking snippets.
556 * Try to fuck around with selections on triple-click to attempt to
557 * fix this garbage behavior.
559 * Note: for consistent behavior we may actually want to take over
560 * triple-clicks, in all browsers in order to ensure consistent cross-
561 * platform behavior instead of being at the mercy of rendering engines
562 * & platform selection quirks?
564 webkitSelectionFixer: function (root) {
565 root.addEventListener('click', function (e) {
566 // only webkit seems to have a fucked up behavior, ignore others
567 // FIXME: $.browser goes away in jquery 1.9...
568 if (!$.browser.webkit) { return; }
569 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
570 // The detail attribute indicates the number of times a mouse button has been pressed
571 // we just want the triple click
572 if (e.detail !== 3) { return; }
575 // Get closest block-level element to the triple-clicked
576 // element (using ckeditor's block list because why not)
577 var $closest_block = $(e.target).closest(blocks_selector);
579 // manually set selection range to the content of the
580 // triple-clicked block-level element, to avoid crossing over
581 // between block-level elements
582 document.getSelection().selectAllChildren($closest_block[0]);
585 tableNavigation: function (root) {
587 $(root).on('keydown', function (e) {
589 if (e.which !== 9) { return; }
591 if (self.handleTab(e)) {
597 * Performs whatever operation is necessary on a [TAB] hit, returns
598 * ``true`` if the event's default should be cancelled (if the TAB was
599 * handled by the function)
601 handleTab: function (event) {
602 var forward = !event.shiftKey;
604 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
605 var $cell = $(root).closest('td,th');
607 if (!$cell.length) { return false; }
611 // find cell in same row
612 var row = cell.parentNode;
613 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
615 document.getSelection().selectAllChildren(sibling);
619 // find cell in previous/next row
620 var table = row.parentNode;
621 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
623 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
624 document.getSelection().selectAllChildren(new_cell);
628 // at edge cells, copy word/openoffice behavior: if going backwards
629 // from first cell do nothing, if going forwards from last cell add
632 var row_size = row.cells.length;
633 var new_row = document.createElement('tr');
635 var newcell = document.createElement('td');
637 newcell.textContent = '\u200B';
638 new_row.appendChild(newcell);
640 table.appendChild(new_row);
641 document.getSelection().selectAllChildren(new_row.cells[0]);
647 * Makes the page editable
649 * @param {Boolean} [restart=false] in case the edition was already set
650 * up once and is being re-enabled.
651 * @returns {$.Deferred} deferred indicating when the RTE is ready
653 start_edition: function (restart) {
655 // create a single editor for the whole page
656 var root = document.getElementById('wrapwrap');
658 $(root).on('dragstart', 'img', function (e) {
661 this.webkitSelectionFixer(root);
662 this.tableNavigation(root);
664 var def = $.Deferred();
665 var editor = this.editor = CKEDITOR.inline(root, self._config());
666 editor.on('instanceReady', function () {
667 editor.setReadOnly(false);
668 // ckeditor set root to editable, disable it (only inner
669 // sections are editable)
670 // FIXME: are there cases where the whole editor is editable?
671 editor.editable().setReadOnly(true);
673 self.setup_editables(root);
675 // disable firefox's broken table resizing thing
676 document.execCommand("enableObjectResizing", false, "false");
677 document.execCommand("enableInlineTableEditing", false, "false");
679 self.trigger('rte:ready');
685 setup_editables: function (root) {
686 // selection of editable sub-items was previously in
687 // EditorBar#edit, but for some unknown reason the elements were
688 // apparently removed and recreated (?) at editor initalization,
689 // and observer setup was lost.
691 // setup dirty-marking for each editable element
692 this.fetch_editables(root)
693 .addClass('oe_editable')
697 // only explicitly set contenteditable on view sections,
698 // cke widgets system will do the widgets themselves
699 if ($node.data('oe-model') === 'ir.ui.view') {
700 node.contentEditable = true;
703 observer.observe(node, OBSERVER_CONFIG);
704 $node.one('content_changed', function () {
705 $node.addClass('oe_dirty');
706 self.trigger('change');
711 fetch_editables: function (root) {
712 return $(root).find('[data-oe-model]')
714 .not('.oe_snippet_editor')
715 .filter(function () {
717 // keep view sections and fields which are *not* in
718 // view sections for top-level editables
719 return $this.data('oe-model') === 'ir.ui.view'
720 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
724 _current_editor: function () {
725 return CKEDITOR.currentInstance;
727 _config: function () {
728 // base plugins minus
729 // - magicline (captures mousein/mouseout -> breaks draggable)
730 // - contextmenu & tabletools (disable contextual menu)
731 // - bunch of unused plugins
733 'a11yhelp', 'basicstyles', 'blockquote',
734 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
735 'elementspath', 'enterkey', 'entities', 'filebrowser',
736 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
737 'indentblock', 'indentlist', 'justify',
738 'list', 'pastefromword', 'pastetext', 'preview',
739 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
740 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
745 // Disable auto-generated titles
746 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
748 plugins: plugins.join(','),
750 // FIXME: currently breaks RTE?
751 // Ensure no config file is loaded
754 allowedContent: true,
755 // Don't insert paragraphs around content in e.g. <li>
756 autoParagraph: false,
757 // Don't automatically add or <br> in empty block-level
758 // elements when edition starts
759 fillEmptyBlocks: false,
760 filebrowserImageUploadUrl: "/website/attach",
761 // Support for sharedSpaces in 4.x
762 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
763 // Place toolbar in controlled location
764 sharedSpaces: { top: 'oe_rte_toolbar' },
766 name: 'clipboard', items: [
769 name: 'basicstyles', items: [
770 "Bold", "Italic", "Underline", "Strike", "Subscript",
771 "Superscript", "TextColor", "BGColor", "RemoveFormat"
773 name: 'span', items: [
774 "Link", "Blockquote", "BulletedList",
775 "NumberedList", "Indent", "Outdent"
777 name: 'justify', items: [
778 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
780 name: 'special', items: [
781 "Image", "TableButton"
783 name: 'styles', items: [
784 "Styles", "BootstrapLinkCombo"
787 // styles dropdown in toolbar
789 {name: "Normal", element: 'p'},
790 {name: "Heading 1", element: 'h1'},
791 {name: "Heading 2", element: 'h2'},
792 {name: "Heading 3", element: 'h3'},
793 {name: "Heading 4", element: 'h4'},
794 {name: "Heading 5", element: 'h5'},
795 {name: "Heading 6", element: 'h6'},
796 {name: "Formatted", element: 'pre'},
797 {name: "Address", element: 'address'}
803 website.editor = { };
804 website.editor.Dialog = openerp.Widget.extend({
806 'hidden.bs.modal': 'destroy',
807 'click button.save': 'save',
809 init: function (editor) {
811 this.editor = editor;
814 var sup = this._super();
815 this.$el.modal({backdrop: 'static'});
822 this.$el.modal('hide');
826 website.editor.LinkDialog = website.editor.Dialog.extend({
827 template: 'website.editor.dialog.link',
828 events: _.extend({}, website.editor.Dialog.prototype.events, {
829 'change .url-source': function (e) { this.changed($(e.target)); },
830 'mousedown': function (e) {
831 var $target = $(e.target).closest('.list-group-item');
832 if (!$target.length || $target.hasClass('active')) {
833 // clicked outside groups, or clicked in active groups
837 this.changed($target.find('.url-source'));
839 'click button.remove': 'remove_link',
840 'change input#link-text': function (e) {
841 this.text = $(e.target).val()
844 init: function (editor) {
846 // url -> name mapping for existing pages
847 this.pages = Object.create(null);
853 this.fetch_pages().done(this.proxy('fill_pages')),
860 var self = this, _super = this._super.bind(this);
861 var $e = this.$('.list-group-item.active .url-source');
863 if (!val || !$e[0].checkValidity()) {
864 // FIXME: error message
865 $e.closest('.form-group').addClass('has-error');
871 if ($e.hasClass('email-address')) {
872 this.make_link('mailto:' + val, false, val);
873 } else if ($e.hasClass('existing')) {
874 self.make_link(val, false, this.pages[val]);
875 } else if ($e.hasClass('pages')) {
876 // Create the page, get the URL back
877 done = $.get(_.str.sprintf(
878 '/pagenew/%s?noredirect', encodeURI(val)))
879 .then(function (response) {
880 self.make_link(response, false, val);
883 this.make_link(val, this.$('input.window-new').prop('checked'));
887 make_link: function (url, new_window, label) {
889 bind_data: function (text, href, new_window) {
890 href = href || this.element && (this.element.data( 'cke-saved-href')
891 || this.element.getAttribute('href'));
892 if (!href) { return; }
894 if (new_window === undefined) {
895 new_window = this.element.getAttribute('target') === '_blank';
897 if (text === undefined) {
898 text = this.element.getText();
902 if ((match = /mailto:(.+)/.exec(href))) {
903 $control = this.$('input.email-address').val(match[1]);
904 } else if (href in this.pages) {
905 $control = this.$('select.existing').val(href);
906 } else if ((match = /\/page\/(.+)/.exec(href))) {
907 var actual_href = '/page/website.' + match[1];
908 if (actual_href in this.pages) {
909 $control = this.$('select.existing').val(actual_href);
913 $control = this.$('input.url').val(href);
916 this.changed($control);
918 this.$('input#link-text').val(text);
919 this.$('input.window-new').prop('checked', new_window);
921 changed: function ($e) {
922 this.$('.url-source').not($e).val('');
923 $e.closest('.list-group-item')
925 .siblings().removeClass('active')
926 .addBack().removeClass('has-error');
928 fetch_pages: function () {
929 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
931 method: 'list_pages',
934 context: website.get_context()
938 fill_pages: function (results) {
940 var pages = this.$('select.existing')[0];
941 _(results).each(function (result) {
942 self.pages[result.url] = result.name;
944 pages.options[pages.options.length] =
945 new Option(result.name, result.url);
949 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
952 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
953 this.editor.getSelection().selectElement(element);
955 this.element = element;
957 this.add_removal_button();
960 return this._super();
962 add_removal_button: function () {
963 this.$('.modal-footer').prepend(
965 'website.editor.dialog.link.footer-button'));
967 remove_link: function () {
968 var editor = this.editor;
969 // same issue as in make_link
970 setTimeout(function () {
971 editor.removeStyle(new CKEDITOR.style({
973 type: CKEDITOR.STYLE_INLINE,
974 alwaysRemoveElement: true,
980 * Greatly simplified version of CKEDITOR's
981 * plugins.link.dialogs.link.onOk.
983 * @param {String} url
984 * @param {Boolean} [new_window=false]
985 * @param {String} [label=null]
987 make_link: function (url, new_window, label) {
988 var attributes = {href: url, 'data-cke-saved-href': url};
991 attributes['target'] = '_blank';
993 to_remove.push('target');
997 this.element.setAttributes(attributes);
998 this.element.removeAttributes(to_remove);
999 if (this.text) { this.element.setText(this.text); }
1001 var selection = this.editor.getSelection();
1002 var range = selection.getRanges(true)[0];
1004 if (range.collapsed) {
1005 //noinspection JSPotentiallyInvalidConstructorUsage
1006 var text = new CKEDITOR.dom.text(
1007 this.text || label || url);
1008 range.insertNode(text);
1009 range.selectNodeContents(text);
1012 //noinspection JSPotentiallyInvalidConstructorUsage
1013 new CKEDITOR.style({
1014 type: CKEDITOR.STYLE_INLINE,
1016 attributes: attributes,
1017 }).applyToRange(range);
1019 // focus dance between RTE & dialog blow up the stack in Safari
1020 // and Chrome, so defer select() until dialog has been closed
1021 setTimeout(function () {
1027 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1028 * if the editor is set directly on a link it will thus not work.
1030 get_selected_link: function () {
1031 return get_selected_link(this.editor);
1036 * ImageDialog widget. Lets users change an image, including uploading a
1037 * new image in OpenERP or selecting the image style (if supported by
1040 * Initialized as usual, but the caller can hook into two events:
1042 * @event start({url, style}) called during dialog initialization and
1043 * opening, the handler can *set* the ``url``
1044 * and ``style`` properties on its parameter
1045 * to provide these as default values to the
1047 * @event save({url, style}) called during dialog finalization, the handler
1048 * is provided with the image url and style
1049 * selected by the users (or possibly the ones
1050 * originally passed in)
1052 website.editor.ImageDialog = website.editor.Dialog.extend({
1053 template: 'website.editor.dialog.image',
1054 events: _.extend({}, website.editor.Dialog.prototype.events, {
1055 'change .url-source': function (e) { this.changed($(e.target)); },
1056 'click button.filepicker': function () {
1057 this.$('input[type=file]').click();
1059 'change input[type=file]': 'file_selection',
1060 'change input.url': 'preview_image',
1061 'click a[href=#existing]': 'browse_existing',
1062 'change select.image-style': 'preview_image',
1065 start: function () {
1066 this.$('.modal-footer [disabled]').text("Uploading…");
1067 var $options = this.$('.image-style').children();
1068 this.image_styles = $options.map(function () { return this.value; }).get();
1070 var o = { url: null, style: null, };
1071 // avoid typos, prevent addition of new properties to the object
1072 Object.preventExtensions(o);
1073 this.trigger('start', o);
1077 this.$('.image-style').val(o.style);
1079 this.set_image(o.url);
1082 return this._super();
1085 this.trigger('save', {
1086 url: this.$('input.url').val(),
1087 style: this.$('.image-style').val(),
1089 return this._super();
1093 * Sets the provided image url as the dialog's value-to-save and
1094 * refreshes the preview element to use it.
1096 set_image: function (url) {
1097 this.$('input.url').val(url);
1098 this.preview_image();
1101 file_selection: function () {
1102 this.$el.addClass('nosave');
1103 this.$('button.filepicker').removeClass('btn-danger btn-success');
1106 var callback = _.uniqueId('func_');
1107 this.$('input[name=func]').val(callback);
1109 window[callback] = function (url, error) {
1110 delete window[callback];
1111 self.file_selected(url, error);
1113 this.$('form').submit();
1115 file_selected: function(url, error) {
1116 var $button = this.$('button.filepicker');
1118 $button.addClass('btn-danger');
1121 $button.addClass('btn-success');
1122 this.set_image(url);
1124 preview_image: function () {
1125 this.$el.removeClass('nosave');
1126 var image = this.$('input.url').val();
1127 if (!image) { return; }
1129 this.$('img.image-preview')
1131 .removeClass(this.image_styles.join(' '))
1132 .addClass(this.$('select.image-style').val());
1134 browse_existing: function (e) {
1136 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1139 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1141 this._super.apply(this, arguments);
1143 this.on('start', this, this.proxy('started'));
1144 this.on('save', this, this.proxy('saved'));
1146 started: function (holder) {
1147 var selection = this.editor.getSelection();
1148 var el = selection && selection.getSelectedElement();
1149 this.element = null;
1151 if (el && el.is('img')) {
1153 _(this.image_styles).each(function (style) {
1154 if (el.hasClass(style)) {
1155 holder.style = style;
1158 holder.url = el.getAttribute('src');
1161 saved: function (data) {
1162 var element, editor = this.editor;
1163 if (!(element = this.element)) {
1164 element = editor.document.createElement('img');
1165 element.addClass('img');
1166 // focus event handler interactions between bootstrap (modal)
1167 // and ckeditor (RTE) lead to blowing the stack in Safari and
1168 // Chrome (but not FF) when this is done synchronously =>
1169 // defer insertion so modal has been hidden & destroyed before
1171 setTimeout(function () {
1172 editor.insertElement(element);
1176 var style = data.style;
1177 element.setAttribute('src', data.url);
1178 element.removeAttribute('data-cke-saved-src');
1179 $(element.$).removeClass(this.image_styles.join(' '));
1180 if (style) { element.addClass(style); }
1184 var IMAGES_PER_ROW = 6;
1185 var IMAGES_ROWS = 4;
1186 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1187 template: 'website.editor.dialog.image.existing',
1188 events: _.extend({}, website.editor.Dialog.prototype.events, {
1189 'click .existing-attachments img': 'select_existing',
1190 'click .pager > li': function (e) {
1192 var $target = $(e.currentTarget);
1193 if ($target.hasClass('disabled')) {
1196 this.page += $target.hasClass('previous') ? -1 : 1;
1197 this.display_attachments();
1200 init: function (parent) {
1203 this.parent = parent;
1204 this._super(parent.editor);
1207 start: function () {
1210 this.fetch_existing().then(this.proxy('fetched_existing')));
1213 fetch_existing: function () {
1214 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1215 model: 'ir.attachment',
1216 method: 'search_read',
1219 fields: ['name', 'website_url'],
1220 domain: [['res_model', '=', 'ir.ui.view']],
1222 context: website.get_context(),
1226 fetched_existing: function (records) {
1227 this.records = records;
1228 this.display_attachments();
1230 display_attachments: function () {
1231 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1233 var from = this.page * per_screen;
1234 var records = this.records;
1236 // Create rows of 3 records
1237 var rows = _(records).chain()
1238 .slice(from, from + per_screen)
1239 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1243 this.$('.existing-attachments').replaceWith(
1244 openerp.qweb.render(
1245 'website.editor.dialog.image.existing.content', {rows: rows}));
1247 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1248 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1251 select_existing: function (e) {
1252 var link = $(e.currentTarget).attr('src');
1254 this.parent.set_image(link);
1260 function get_selected_link(editor) {
1261 var sel = editor.getSelection(),
1262 el = sel.getSelectedElement();
1263 if (el && el.is('a')) { return el; }
1265 var range = sel.getRanges(true)[0];
1266 if (!range) { return null; }
1268 range.shrink(CKEDITOR.SHRINK_TEXT);
1269 var commonAncestor = range.getCommonAncestor();
1270 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1271 return element.data('oe-model') === 'ir.ui.view'
1273 if (!viewRoot) { return null; }
1274 // if viewRoot is the first link, don't edit it.
1275 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1276 .contains('a', true);
1280 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1281 var OBSERVER_CONFIG = {
1284 characterData: true,
1286 attributeOldValue: true,
1288 var observer = new website.Observer(function (mutations) {
1289 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1290 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1291 // will not mark dirty on attribute changes (@class, img/@src,
1293 _(mutations).chain()
1294 .filter(function (m) {
1296 case 'attributes': // ignore .cke_focus being added or removed
1297 // if attribute is not a class, can't be .cke_focus change
1298 if (m.attributeName !== 'class') { return true; }
1300 // find out what classes were added or removed
1301 var oldClasses = (m.oldValue || '').split(/\s+/);
1302 var newClasses = m.target.className.split(/\s+/);
1303 var change = _.union(_.difference(oldClasses, newClasses),
1304 _.difference(newClasses, oldClasses));
1305 // ignore mutation if the *only* change is .cke_focus
1306 return change.length !== 1 || change[0] === 'cke_focus';
1308 // <br type="_moz"> appears when focusing RTE in FF, ignore
1309 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1315 var node = m.target;
1316 while (node && !$(node).hasClass('oe_editable')) {
1317 node = node.parentNode;
1319 $(m.target).trigger('node_changed');
1324 .each(function (node) { $(node).trigger('content_changed'); })