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('click', 'a.js_link2post', function (ev) {
17 var form = document.createElement('form');
18 form.action = this.pathname; // restrict to same origin
20 // TODO: support this.search as form input fields
25 $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
26 // Prevent dropdown closing when a contenteditable children is focused
28 && $(ev.target).has(ev.originalEvent.target).length
29 && $(ev.originalEvent.target).is('[contenteditable]')) {
35 function link_dialog(editor) {
36 return new website.editor.RTELinkDialog(editor).appendTo(document.body);
38 function image_dialog(editor) {
39 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
42 // only enable editors manually
43 CKEDITOR.disableAutoInline = true;
44 // EDIT ALL THE THINGS
45 CKEDITOR.dtd.$editable = $.extend(
46 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
47 // Disable removal of empty elements on CKEDITOR activation. Empty
48 // elements are used for e.g. support of FontAwesome icons
49 CKEDITOR.dtd.$removeEmpty = {};
51 website.init_editor = function () {
52 CKEDITOR.plugins.add('customdialogs', {
53 // requires: 'link,image',
54 init: function (editor) {
55 editor.on('doubleclick', function (evt) {
56 var element = evt.data.element;
58 && !element.data('cke-realelement')
59 && !element.isReadOnly()
60 && (element.data('oe-model') !== 'ir.ui.view')) {
65 element = get_selected_link(editor) || evt.data.element;
66 if (element.isReadOnly()
68 || element.data('oe-model')) {
72 editor.getSelection().selectElement(element);
76 var previousSelection;
77 editor.on('selectionChange', function (evt) {
78 var selected = evt.data.path.lastElement;
79 if (previousSelection) {
80 // cleanup previous selection
81 $(previousSelection).next().remove();
82 previousSelection = null;
84 if (!selected.is('img')
85 || selected.data('cke-realelement')
86 || selected.isReadOnly()
87 || selected.data('oe-model') === 'ir.ui.view') {
92 var $el = $(previousSelection = selected.$);
93 var $btn = $('<button type="button" class="btn btn-primary" contenteditable="false">Edit</button>')
101 var position = $el.position();
103 position: 'absolute',
104 top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
105 left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
109 //noinspection JSValidateTypes
110 editor.addCommand('link', {
111 exec: function (editor) {
118 //noinspection JSValidateTypes
119 editor.addCommand('image', {
120 exec: function (editor) {
121 image_dialog(editor);
128 editor.ui.addButton('Link', {
132 icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png',
134 editor.ui.addButton('Image', {
137 toolbar: 'insert,10',
138 icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png',
141 editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link');
144 CKEDITOR.plugins.add( 'tablebutton', {
145 requires: 'panelbutton,floatpanel',
146 init: function( editor ) {
149 editor.ui.add('TableButton', CKEDITOR.UI_PANELBUTTON, {
152 // use existing 'table' icon
154 modes: { wysiwyg: true },
156 // panel opens in iframe, @css is CSS file <link>-ed within
157 // frame document, @attributes are set on iframe itself.
159 css: '/website/static/src/css/editor.css',
160 attributes: { 'role': 'listbox', 'aria-label': label, },
163 onBlock: function (panel, block) {
164 block.autoSize = true;
165 block.element.setHtml(openerp.qweb.render('website.editor.table.panel', {
170 var $table = $(block.element.$).on('mouseenter', 'td', function (e) {
171 var $e = $(e.target);
172 var y = $e.index() + 1;
173 var x = $e.closest('tr').index() + 1;
176 .find('td').removeClass('selected').end()
177 .find('tr:lt(' + String(x) + ')')
178 .children().filter(function () { return $(this).index() < y; })
179 .addClass('selected');
180 }).on('click', 'td', function (e) {
181 var $e = $(e.target);
183 //noinspection JSPotentiallyInvalidConstructorUsage
184 var table = new CKEDITOR.dom.element(
185 $(openerp.qweb.render('website.editor.table', {
186 rows: $e.closest('tr').index() + 1,
187 cols: $e.index() + 1,
190 editor.insertElement(table);
191 setTimeout(function () {
192 //noinspection JSPotentiallyInvalidConstructorUsage
193 var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]);
194 var range = editor.createRange();
195 range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START);
200 block.element.getDocument().getBody().setStyle('overflow', 'hidden');
201 CKEDITOR.ui.fire('ready', this);
207 CKEDITOR.plugins.add('oeref', {
210 init: function (editor) {
211 editor.widgets.add('oeref', {
212 editables: { text: '*' },
214 upcast: function (el) {
215 return el.attributes['data-oe-type']
216 && el.attributes['data-oe-type'] !== 'monetary';
219 editor.widgets.add('monetary', {
220 editables: { text: 'span.oe_currency_value' },
222 upcast: function (el) {
223 return el.attributes['data-oe-type'] === 'monetary';
229 CKEDITOR.plugins.add('bootstrapcombo', {
230 requires: 'richcombo',
232 init: function (editor) {
233 var config = editor.config;
235 editor.ui.addRichCombo('BootstrapLinkCombo', {
239 title: "Link styling",
240 toolbar: 'styles,10',
241 allowedContent: ['a'],
245 '/website/static/lib/bootstrap/css/bootstrap.css',
246 CKEDITOR.skin.getPath( 'editor' )
247 ].concat( config.contentsCss ),
252 'basic': 'btn-default',
253 'primary': 'btn-primary',
254 'success': 'btn-success',
256 'warning': 'btn-warning',
257 'danger': 'btn-danger',
264 'extra small': 'btn-xs',
268 this.add('', 'Reset');
269 this.startGroup("Types");
270 for(var type in this.types) {
271 if (!this.types.hasOwnProperty(type)) { continue; }
272 var cls = this.types[type];
273 var el = _.str.sprintf(
274 '<span class="btn %s">%s</span>',
278 this.startGroup("Sizes");
279 for (var size in this.sizes) {
280 if (!this.sizes.hasOwnProperty(size)) { continue; }
281 cls = this.sizes[size];
284 '<span class="btn btn-default %s">%s</span>',
290 onRender: function () {
292 editor.on('selectionChange', function (e) {
293 var path = e.data.path, el;
295 if (!(el = path.contains('a'))) {
302 // This is crap, but getting the currently selected
303 // element from within onOpen absolutely does not
304 // work, so store the "current" element in the
308 setTimeout(function () {
309 // Because I can't find any normal hook where the
310 // bloody button's bloody element is available
314 onOpen: function () {
318 for(var val in this.types) {
319 if (!this.types.hasOwnProperty(val)) { continue; }
320 var cls = this.types[val];
321 if (!this.element.hasClass(cls)) { continue; }
328 for(val in this.sizes) {
329 if (!this.sizes.hasOwnProperty(val)) { continue; }
330 cls = this.sizes[val];
331 if (!cls || !this.element.hasClass(cls)) { continue; }
337 if (!found && this.element.hasClass('btn')) {
338 this.mark('default');
341 onClick: function (value) {
343 editor.fire('saveShapshot');
346 var el = this.element;
347 if (!el.hasClass('btn')) {
349 el.addClass('btn-default');
353 this.setClass(this.types);
354 this.setClass(this.sizes);
355 el.removeClass('btn');
356 } else if (value in this.types) {
357 this.setClass(this.types, value);
358 } else if (value in this.sizes) {
359 this.setClass(this.sizes, value);
362 editor.fire('saveShapshot');
364 setClass: function (classMap, value) {
365 var element = this.element;
366 _(classMap).each(function (cls) {
367 if (!cls) { return; }
368 element.removeClass(cls);
371 var cls = classMap[value];
373 element.addClass(cls);
380 var editor = new website.EditorBar();
381 var $body = $(document.body);
382 editor.prependTo($body).then(function () {
383 if (location.search.indexOf("enable_editor") >= 0) {
387 $body.css('padding-top', '50px'); // Not working properly: editor.$el.outerHeight());
389 /* ----- TOP EDITOR BAR FOR ADMIN ---- */
390 website.EditorBar = openerp.Widget.extend({
391 template: 'website.editorbar',
393 'click button[data-action=edit]': 'edit',
394 'click button[data-action=save]': 'save',
395 'click button[data-action=cancel]': 'cancel',
396 'click a[data-action=new_page]': 'new_page',
399 customize_setup: function() {
401 var view_name = $(document.documentElement).data('view-xmlid');
402 var menu = $('#customize-menu');
403 this.$('#customize-menu-button').click(function(event) {
405 openerp.jsonRpc('/website/customize_template_get', 'call', { 'xml_id': view_name }).then(
407 _.each(result, function (item) {
409 menu.append('<li class="dropdown-header">' + item.name + '</li>');
411 menu.append(_.str.sprintf('<li role="presentation"><a href="#" data-view-id="%s" role="menuitem"><strong class="icon-check%s"></strong> %s</a></li>',
412 item.id, item.active ? '' : '-empty', item.name));
415 // Adding Static Menus
416 menu.append('<li class="divider"></li>');
417 menu.append('<li><a data-action="ace" href="#">HTML Editor</a></li>');
418 menu.append('<li class="js_change_theme"><a href="/page/website.themes">Change Theme</a></li>');
419 menu.append('<li><a href="/web#action=website.action_module_website">Install Apps</a></li>');
420 self.trigger('rte:customize_menu_ready');
424 menu.on('click', 'a[data-action!=ace]', function (event) {
425 var view_id = $(event.currentTarget).data('view-id');
426 openerp.jsonRpc('/website/customize_template_toggle', 'call', {
428 }).then( function() {
429 window.location.reload();
436 this.saving_mutex = new openerp.Mutex();
438 this.$('#website-top-edit').hide();
439 this.$('#website-top-view').show();
441 $('.dropdown-toggle').dropdown();
442 this.customize_setup();
445 edit: this.$('button[data-action=edit]'),
446 save: this.$('button[data-action=save]'),
447 cancel: this.$('button[data-action=cancel]'),
450 this.rte = new website.RTE(this);
451 this.rte.on('change', this, this.proxy('rte_changed'));
452 this.rte.on('rte:ready', this, function () {
453 self.trigger('rte:ready');
457 this._super.apply(this, arguments),
458 this.rte.appendTo(this.$('#website-top-edit .nav.pull-right'))
462 this.$buttons.edit.prop('disabled', true);
463 this.$('#website-top-view').hide();
464 this.$('#website-top-edit').show();
465 $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden");
467 this.rte.start_edition();
469 rte_changed: function () {
470 this.$buttons.save.prop('disabled', false);
475 observer.disconnect();
476 var editor = this.rte.editor;
477 var root = editor.element.$;
479 // FIXME: select editables then filter by dirty?
480 var defs = this.rte.fetch_editables(root)
482 .removeAttr('contentEditable')
483 .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger')
486 // TODO: Add a queue with concurrency limit in webclient
487 // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
488 return self.saving_mutex.exec(function () {
489 return self.saveElement($el)
490 .then(undefined, function (thing, response) {
491 // because ckeditor regenerates all the dom,
492 // we can't just setup the popover here as
493 // everything will be destroyed by the DOM
494 // regeneration. Add markings instead, and
495 // returns a new rejection with all relevant
497 var id = _.uniqueId('carlos_danger_');
498 $el.addClass('oe_dirty oe_carlos_danger');
500 return $.Deferred().reject({
502 error: response.data,
507 return $.when.apply(null, defs).then(function () {
509 }, function (failed) {
510 // If there were errors, re-enable edition
511 self.rte.start_edition(true).then(function () {
512 // jquery's deferred being a pain in the ass
513 if (!_.isArray(failed)) { failed = [failed]; }
515 _(failed).each(function (failure) {
516 $(root).find('.' + failure.id)
517 .removeClass(failure.id)
520 content: failure.error.message,
521 placement: 'auto top',
523 // Force-show popovers so users will notice them.
530 * Saves an RTE content, which always corresponds to a view section (?).
532 saveElement: function ($el) {
533 var markup = $el.prop('outerHTML');
534 return openerp.jsonRpc('/web/dataset/call', 'call', {
537 args: [$el.data('oe-id'), markup,
538 $el.data('oe-xpath') || null,
539 website.get_context()],
542 cancel: function () {
545 new_page: function (ev) {
547 website.prompt('Create a new page', 'Page name').then(function (val) {
548 document.location = '/pagenew/' + encodeURI(val);
553 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
554 /* ----- RICH TEXT EDITOR ---- */
555 website.RTE = openerp.Widget.extend({
557 id: 'oe_rte_toolbar',
558 className: 'oe_right oe_rte_toolbar',
559 // editor.ui.items -> possible commands &al
560 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
562 init: function (EditorBar) {
563 this.EditorBar = EditorBar;
564 this._super.apply(this, arguments);
568 * In Webkit-based browsers, triple-click will select a paragraph up to
569 * the start of the next "paragraph" including any empty space
570 * inbetween. When said paragraph is removed or altered, it nukes
571 * the empty space and brings part of the content of the next
572 * "paragraph" (which may well be e.g. an image) into the current one,
573 * completely fucking up layouts and breaking snippets.
575 * Try to fuck around with selections on triple-click to attempt to
576 * fix this garbage behavior.
578 * Note: for consistent behavior we may actually want to take over
579 * triple-clicks, in all browsers in order to ensure consistent cross-
580 * platform behavior instead of being at the mercy of rendering engines
581 * & platform selection quirks?
583 webkitSelectionFixer: function (root) {
584 root.addEventListener('click', function (e) {
585 // only webkit seems to have a fucked up behavior, ignore others
586 // FIXME: $.browser goes away in jquery 1.9...
587 if (!$.browser.webkit) { return; }
588 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
589 // The detail attribute indicates the number of times a mouse button has been pressed
590 // we just want the triple click
591 if (e.detail !== 3) { return; }
594 // Get closest block-level element to the triple-clicked
595 // element (using ckeditor's block list because why not)
596 var $closest_block = $(e.target).closest(blocks_selector);
598 // manually set selection range to the content of the
599 // triple-clicked block-level element, to avoid crossing over
600 // between block-level elements
601 document.getSelection().selectAllChildren($closest_block[0]);
604 tableNavigation: function (root) {
606 $(root).on('keydown', function (e) {
608 if (e.which !== 9) { return; }
610 if (self.handleTab(e)) {
616 * Performs whatever operation is necessary on a [TAB] hit, returns
617 * ``true`` if the event's default should be cancelled (if the TAB was
618 * handled by the function)
620 handleTab: function (event) {
621 var forward = !event.shiftKey;
623 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
624 var $cell = $(root).closest('td,th');
626 if (!$cell.length) { return false; }
630 // find cell in same row
631 var row = cell.parentNode;
632 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
634 document.getSelection().selectAllChildren(sibling);
638 // find cell in previous/next row
639 var table = row.parentNode;
640 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
642 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
643 document.getSelection().selectAllChildren(new_cell);
647 // at edge cells, copy word/openoffice behavior: if going backwards
648 // from first cell do nothing, if going forwards from last cell add
651 var row_size = row.cells.length;
652 var new_row = document.createElement('tr');
654 var newcell = document.createElement('td');
656 newcell.textContent = '\u200B';
657 new_row.appendChild(newcell);
659 table.appendChild(new_row);
660 document.getSelection().selectAllChildren(new_row.cells[0]);
666 * Makes the page editable
668 * @param {Boolean} [restart=false] in case the edition was already set
669 * up once and is being re-enabled.
670 * @returns {$.Deferred} deferred indicating when the RTE is ready
672 start_edition: function (restart) {
674 // create a single editor for the whole page
675 var root = document.getElementById('wrapwrap');
677 $(root).on('dragstart', 'img', function (e) {
680 this.webkitSelectionFixer(root);
681 this.tableNavigation(root);
683 var def = $.Deferred();
684 var editor = this.editor = CKEDITOR.inline(root, self._config());
685 editor.on('instanceReady', function () {
686 editor.setReadOnly(false);
687 // ckeditor set root to editable, disable it (only inner
688 // sections are editable)
689 // FIXME: are there cases where the whole editor is editable?
690 editor.editable().setReadOnly(true);
692 self.setup_editables(root);
694 // disable firefox's broken table resizing thing
695 document.execCommand("enableObjectResizing", false, "false");
696 document.execCommand("enableInlineTableEditing", false, "false");
698 self.trigger('rte:ready');
704 setup_editables: function (root) {
705 // selection of editable sub-items was previously in
706 // EditorBar#edit, but for some unknown reason the elements were
707 // apparently removed and recreated (?) at editor initalization,
708 // and observer setup was lost.
710 // setup dirty-marking for each editable element
711 this.fetch_editables(root)
712 .addClass('oe_editable')
716 // only explicitly set contenteditable on view sections,
717 // cke widgets system will do the widgets themselves
718 if ($node.data('oe-model') === 'ir.ui.view') {
719 node.contentEditable = true;
722 observer.observe(node, OBSERVER_CONFIG);
723 $node.one('content_changed', function () {
724 $node.addClass('oe_dirty');
725 self.trigger('change');
730 fetch_editables: function (root) {
731 return $(root).find('[data-oe-model]')
733 .not('.oe_snippet_editor')
734 .filter(function () {
736 // keep view sections and fields which are *not* in
737 // view sections for top-level editables
738 return $this.data('oe-model') === 'ir.ui.view'
739 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
743 _current_editor: function () {
744 return CKEDITOR.currentInstance;
746 _config: function () {
747 // base plugins minus
748 // - magicline (captures mousein/mouseout -> breaks draggable)
749 // - contextmenu & tabletools (disable contextual menu)
750 // - bunch of unused plugins
752 'a11yhelp', 'basicstyles', 'blockquote',
753 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
754 'elementspath', 'enterkey', 'entities', 'filebrowser',
755 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
756 'indentblock', 'indentlist', 'justify',
757 'list', 'pastefromword', 'pastetext', 'preview',
758 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
759 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
764 // Disable auto-generated titles
765 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
767 plugins: plugins.join(','),
769 // FIXME: currently breaks RTE?
770 // Ensure no config file is loaded
773 allowedContent: true,
774 // Don't insert paragraphs around content in e.g. <li>
775 autoParagraph: false,
776 // Don't automatically add or <br> in empty block-level
777 // elements when edition starts
778 fillEmptyBlocks: false,
779 filebrowserImageUploadUrl: "/website/attach",
780 // Support for sharedSpaces in 4.x
781 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
782 // Place toolbar in controlled location
783 sharedSpaces: { top: 'oe_rte_toolbar' },
785 name: 'clipboard', items: [
788 name: 'basicstyles', items: [
789 "Bold", "Italic", "Underline", "Strike", "Subscript",
790 "Superscript", "TextColor", "BGColor", "RemoveFormat"
792 name: 'span', items: [
793 "Link", "Blockquote", "BulletedList",
794 "NumberedList", "Indent", "Outdent"
796 name: 'justify', items: [
797 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
799 name: 'special', items: [
800 "Image", "TableButton"
802 name: 'styles', items: [
803 "Styles", "BootstrapLinkCombo"
806 // styles dropdown in toolbar
808 {name: "Normal", element: 'p'},
809 {name: "Heading 1", element: 'h1'},
810 {name: "Heading 2", element: 'h2'},
811 {name: "Heading 3", element: 'h3'},
812 {name: "Heading 4", element: 'h4'},
813 {name: "Heading 5", element: 'h5'},
814 {name: "Heading 6", element: 'h6'},
815 {name: "Formatted", element: 'pre'},
816 {name: "Address", element: 'address'}
822 website.editor = { };
823 website.editor.Dialog = openerp.Widget.extend({
825 'hidden.bs.modal': 'destroy',
826 'click button.save': 'save',
828 init: function (editor) {
830 this.editor = editor;
833 var sup = this._super();
834 this.$el.modal({backdrop: 'static'});
841 this.$el.modal('hide');
845 website.editor.LinkDialog = website.editor.Dialog.extend({
846 template: 'website.editor.dialog.link',
847 events: _.extend({}, website.editor.Dialog.prototype.events, {
848 'change .url-source': function (e) { this.changed($(e.target)); },
849 'mousedown': function (e) {
850 var $target = $(e.target).closest('.list-group-item');
851 if (!$target.length || $target.hasClass('active')) {
852 // clicked outside groups, or clicked in active groups
856 this.changed($target.find('.url-source'));
858 'click button.remove': 'remove_link',
859 'change input#link-text': function (e) {
860 this.text = $(e.target).val()
863 init: function (editor) {
865 // url -> name mapping for existing pages
866 this.pages = Object.create(null);
872 this.fetch_pages().done(this.proxy('fill_pages')),
879 var self = this, _super = this._super.bind(this);
880 var $e = this.$('.list-group-item.active .url-source');
882 if (!val || !$e[0].checkValidity()) {
883 // FIXME: error message
884 $e.closest('.form-group').addClass('has-error');
890 if ($e.hasClass('email-address')) {
891 this.make_link('mailto:' + val, false, val);
892 } else if ($e.hasClass('existing')) {
893 self.make_link(val, false, this.pages[val]);
894 } else if ($e.hasClass('pages')) {
895 // Create the page, get the URL back
896 done = $.get(_.str.sprintf(
897 '/pagenew/%s?noredirect', encodeURI(val)))
898 .then(function (response) {
899 self.make_link(response, false, val);
902 this.make_link(val, this.$('input.window-new').prop('checked'));
906 make_link: function (url, new_window, label) {
908 bind_data: function (text, href, new_window) {
909 href = href || this.element && (this.element.data( 'cke-saved-href')
910 || this.element.getAttribute('href'));
911 if (!href) { return; }
913 if (new_window === undefined) {
914 new_window = this.element.getAttribute('target') === '_blank';
916 if (text === undefined) {
917 text = this.element.getText();
921 if ((match = /mailto:(.+)/.exec(href))) {
922 $control = this.$('input.email-address').val(match[1]);
923 } else if (href in this.pages) {
924 $control = this.$('select.existing').val(href);
925 } else if ((match = /\/page\/(.+)/.exec(href))) {
926 var actual_href = '/page/website.' + match[1];
927 if (actual_href in this.pages) {
928 $control = this.$('select.existing').val(actual_href);
932 $control = this.$('input.url').val(href);
935 this.changed($control);
937 this.$('input#link-text').val(text);
938 this.$('input.window-new').prop('checked', new_window);
940 changed: function ($e) {
941 this.$('.url-source').not($e).val('');
942 $e.closest('.list-group-item')
944 .siblings().removeClass('active')
945 .addBack().removeClass('has-error');
947 fetch_pages: function () {
948 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
950 method: 'list_pages',
953 context: website.get_context()
957 fill_pages: function (results) {
959 var pages = this.$('select.existing')[0];
960 _(results).each(function (result) {
961 self.pages[result.url] = result.name;
963 pages.options[pages.options.length] =
964 new Option(result.name, result.url);
968 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
971 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
972 this.editor.getSelection().selectElement(element);
974 this.element = element;
976 this.add_removal_button();
979 return this._super();
981 add_removal_button: function () {
982 this.$('.modal-footer').prepend(
984 'website.editor.dialog.link.footer-button'));
986 remove_link: function () {
987 var editor = this.editor;
988 // same issue as in make_link
989 setTimeout(function () {
990 editor.removeStyle(new CKEDITOR.style({
992 type: CKEDITOR.STYLE_INLINE,
993 alwaysRemoveElement: true,
999 * Greatly simplified version of CKEDITOR's
1000 * plugins.link.dialogs.link.onOk.
1002 * @param {String} url
1003 * @param {Boolean} [new_window=false]
1004 * @param {String} [label=null]
1006 make_link: function (url, new_window, label) {
1007 var attributes = {href: url, 'data-cke-saved-href': url};
1010 attributes['target'] = '_blank';
1012 to_remove.push('target');
1016 this.element.setAttributes(attributes);
1017 this.element.removeAttributes(to_remove);
1018 if (this.text) { this.element.setText(this.text); }
1020 var selection = this.editor.getSelection();
1021 var range = selection.getRanges(true)[0];
1023 if (range.collapsed) {
1024 //noinspection JSPotentiallyInvalidConstructorUsage
1025 var text = new CKEDITOR.dom.text(
1026 this.text || label || url);
1027 range.insertNode(text);
1028 range.selectNodeContents(text);
1031 //noinspection JSPotentiallyInvalidConstructorUsage
1032 new CKEDITOR.style({
1033 type: CKEDITOR.STYLE_INLINE,
1035 attributes: attributes,
1036 }).applyToRange(range);
1038 // focus dance between RTE & dialog blow up the stack in Safari
1039 // and Chrome, so defer select() until dialog has been closed
1040 setTimeout(function () {
1046 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1047 * if the editor is set directly on a link it will thus not work.
1049 get_selected_link: function () {
1050 return get_selected_link(this.editor);
1055 * ImageDialog widget. Lets users change an image, including uploading a
1056 * new image in OpenERP or selecting the image style (if supported by
1059 * Initialized as usual, but the caller can hook into two events:
1061 * @event start({url, style}) called during dialog initialization and
1062 * opening, the handler can *set* the ``url``
1063 * and ``style`` properties on its parameter
1064 * to provide these as default values to the
1066 * @event save({url, style}) called during dialog finalization, the handler
1067 * is provided with the image url and style
1068 * selected by the users (or possibly the ones
1069 * originally passed in)
1071 website.editor.ImageDialog = website.editor.Dialog.extend({
1072 template: 'website.editor.dialog.image',
1073 events: _.extend({}, website.editor.Dialog.prototype.events, {
1074 'change .url-source': function (e) { this.changed($(e.target)); },
1075 'click button.filepicker': function () {
1076 this.$('input[type=file]').click();
1078 'change input[type=file]': 'file_selection',
1079 'change input.url': 'preview_image',
1080 'click a[href=#existing]': 'browse_existing',
1081 'change select.image-style': 'preview_image',
1084 start: function () {
1085 this.$('.modal-footer [disabled]').text("Uploading…");
1086 var $options = this.$('.image-style').children();
1087 this.image_styles = $options.map(function () { return this.value; }).get();
1089 var o = { url: null, style: null, };
1090 // avoid typos, prevent addition of new properties to the object
1091 Object.preventExtensions(o);
1092 this.trigger('start', o);
1096 this.$('.image-style').val(o.style);
1098 this.set_image(o.url);
1101 return this._super();
1104 this.trigger('save', {
1105 url: this.$('input.url').val(),
1106 style: this.$('.image-style').val(),
1108 return this._super();
1112 * Sets the provided image url as the dialog's value-to-save and
1113 * refreshes the preview element to use it.
1115 set_image: function (url) {
1116 this.$('input.url').val(url);
1117 this.preview_image();
1120 file_selection: function () {
1121 this.$el.addClass('nosave');
1122 this.$('button.filepicker').removeClass('btn-danger btn-success');
1125 var callback = _.uniqueId('func_');
1126 this.$('input[name=func]').val(callback);
1128 window[callback] = function (url, error) {
1129 delete window[callback];
1130 self.file_selected(url, error);
1132 this.$('form').submit();
1134 file_selected: function(url, error) {
1135 var $button = this.$('button.filepicker');
1137 $button.addClass('btn-danger');
1140 $button.addClass('btn-success');
1141 this.set_image(url);
1143 preview_image: function () {
1144 this.$el.removeClass('nosave');
1145 var image = this.$('input.url').val();
1146 if (!image) { return; }
1148 this.$('img.image-preview')
1150 .removeClass(this.image_styles.join(' '))
1151 .addClass(this.$('select.image-style').val());
1153 browse_existing: function (e) {
1155 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1158 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1160 this._super.apply(this, arguments);
1162 this.on('start', this, this.proxy('started'));
1163 this.on('save', this, this.proxy('saved'));
1165 started: function (holder) {
1166 var selection = this.editor.getSelection();
1167 var el = selection && selection.getSelectedElement();
1168 this.element = null;
1170 if (el && el.is('img')) {
1172 _(this.image_styles).each(function (style) {
1173 if (el.hasClass(style)) {
1174 holder.style = style;
1177 holder.url = el.getAttribute('src');
1180 saved: function (data) {
1181 var element, editor = this.editor;
1182 if (!(element = this.element)) {
1183 element = editor.document.createElement('img');
1184 element.addClass('img');
1185 // focus event handler interactions between bootstrap (modal)
1186 // and ckeditor (RTE) lead to blowing the stack in Safari and
1187 // Chrome (but not FF) when this is done synchronously =>
1188 // defer insertion so modal has been hidden & destroyed before
1190 setTimeout(function () {
1191 editor.insertElement(element);
1195 var style = data.style;
1196 element.setAttribute('src', data.url);
1197 element.removeAttribute('data-cke-saved-src');
1198 $(element.$).removeClass(this.image_styles.join(' '));
1199 if (style) { element.addClass(style); }
1203 var IMAGES_PER_ROW = 6;
1204 var IMAGES_ROWS = 4;
1205 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1206 template: 'website.editor.dialog.image.existing',
1207 events: _.extend({}, website.editor.Dialog.prototype.events, {
1208 'click .existing-attachments img': 'select_existing',
1209 'click .pager > li': function (e) {
1211 var $target = $(e.currentTarget);
1212 if ($target.hasClass('disabled')) {
1215 this.page += $target.hasClass('previous') ? -1 : 1;
1216 this.display_attachments();
1219 init: function (parent) {
1222 this.parent = parent;
1223 this._super(parent.editor);
1226 start: function () {
1229 this.fetch_existing().then(this.proxy('fetched_existing')));
1232 fetch_existing: function () {
1233 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1234 model: 'ir.attachment',
1235 method: 'search_read',
1238 fields: ['name', 'website_url'],
1239 domain: [['res_model', '=', 'ir.ui.view']],
1241 context: website.get_context(),
1245 fetched_existing: function (records) {
1246 this.records = records;
1247 this.display_attachments();
1249 display_attachments: function () {
1250 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1252 var from = this.page * per_screen;
1253 var records = this.records;
1255 // Create rows of 3 records
1256 var rows = _(records).chain()
1257 .slice(from, from + per_screen)
1258 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1262 this.$('.existing-attachments').replaceWith(
1263 openerp.qweb.render(
1264 'website.editor.dialog.image.existing.content', {rows: rows}));
1266 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1267 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1270 select_existing: function (e) {
1271 var link = $(e.currentTarget).attr('src');
1273 this.parent.set_image(link);
1279 function get_selected_link(editor) {
1280 var sel = editor.getSelection(),
1281 el = sel.getSelectedElement();
1282 if (el && el.is('a')) { return el; }
1284 var range = sel.getRanges(true)[0];
1285 if (!range) { return null; }
1287 range.shrink(CKEDITOR.SHRINK_TEXT);
1288 var commonAncestor = range.getCommonAncestor();
1289 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1290 return element.data('oe-model') === 'ir.ui.view'
1292 if (!viewRoot) { return null; }
1293 // if viewRoot is the first link, don't edit it.
1294 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1295 .contains('a', true);
1299 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1300 var OBSERVER_CONFIG = {
1303 characterData: true,
1305 attributeOldValue: true,
1307 var observer = new website.Observer(function (mutations) {
1308 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1309 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1310 // will not mark dirty on attribute changes (@class, img/@src,
1312 _(mutations).chain()
1313 .filter(function (m) {
1315 case 'attributes': // ignore .cke_focus being added or removed
1316 // if attribute is not a class, can't be .cke_focus change
1317 if (m.attributeName !== 'class') { return true; }
1319 // find out what classes were added or removed
1320 var oldClasses = (m.oldValue || '').split(/\s+/);
1321 var newClasses = m.target.className.split(/\s+/);
1322 var change = _.union(_.difference(oldClasses, newClasses),
1323 _.difference(newClasses, oldClasses));
1324 // ignore mutation if the *only* change is .cke_focus
1325 return change.length !== 1 || change[0] === 'cke_focus';
1327 // <br type="_moz"> appears when focusing RTE in FF, ignore
1328 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1334 var node = m.target;
1335 while (node && !$(node).hasClass('oe_editable')) {
1336 node = node.parentNode;
1338 $(m.target).trigger('node_changed');
1343 .each(function (node) { $(node).trigger('content_changed'); })