4 var website = openerp.website;
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 website.form(this.pathname, 'POST');
20 $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
21 // Prevent dropdown closing when a contenteditable children is focused
23 && $(ev.target).has(ev.originalEvent.target).length
24 && $(ev.originalEvent.target).is('[contenteditable]')) {
30 function link_dialog(editor) {
31 return new website.editor.RTELinkDialog(editor).appendTo(document.body);
33 function image_dialog(editor) {
34 return new website.editor.RTEImageDialog(editor).appendTo(document.body);
37 // only enable editors manually
38 CKEDITOR.disableAutoInline = true;
39 // EDIT ALL THE THINGS
40 CKEDITOR.dtd.$editable = $.extend(
41 {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline);
42 // Disable removal of empty elements on CKEDITOR activation. Empty
43 // elements are used for e.g. support of FontAwesome icons
44 CKEDITOR.dtd.$removeEmpty = {};
46 website.init_editor = function () {
47 CKEDITOR.plugins.add('customdialogs', {
48 // requires: 'link,image',
49 init: function (editor) {
50 editor.on('doubleclick', function (evt) {
51 var element = evt.data.element;
53 && !element.data('cke-realelement')
54 && !element.isReadOnly()
55 && (element.data('oe-model') !== 'ir.ui.view')) {
60 element = get_selected_link(editor) || evt.data.element;
61 if (element.isReadOnly()
63 || element.data('oe-model')) {
67 editor.getSelection().selectElement(element);
71 var previousSelection;
72 editor.on('selectionChange', function (evt) {
73 var selected = evt.data.path.lastElement;
74 if (previousSelection) {
75 // cleanup previous selection
76 $(previousSelection).next().remove();
77 previousSelection = null;
79 if (!selected.is('img')
80 || selected.data('cke-realelement')
81 || selected.isReadOnly()
82 || selected.data('oe-model') === 'ir.ui.view') {
87 var $el = $(previousSelection = selected.$);
88 var $btn = $('<button type="button" class="btn btn-primary image-edit-button" contenteditable="false">Edit</button>')
96 var position = $el.position();
99 top: $el.height() / 2 + position.top - $btn.outerHeight() / 2,
100 left: $el.width() / 2 + position.left - $btn.outerWidth() / 2,
103 editor.on('destroy', function (evt) {
104 if (previousSelection) {
105 $('.image-edit-button').remove();
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#return_label=Website&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 var html = failure.error.exception_type === "except_osv";
518 var msg = $("<div/>").text(failure.error.message).html();
519 var data = msg.substring(3,msg.length-2).split(/', u'/);
520 failure.error.message = '<b>' + data[0] + '</b><br/>' + data[1];
522 $(root).find('.' + failure.id)
523 .removeClass(failure.id)
527 content: failure.error.message,
528 placement: 'auto top',
530 // Force-show popovers so users will notice them.
537 * Saves an RTE content, which always corresponds to a view section (?).
539 saveElement: function ($el) {
540 var markup = $el.prop('outerHTML');
541 return openerp.jsonRpc('/web/dataset/call', 'call', {
544 args: [$el.data('oe-id'), markup,
545 $el.data('oe-xpath') || null,
546 website.get_context()],
549 cancel: function () {
552 new_page: function (ev) {
555 window_title: "New Page",
557 }).then(function (val) {
558 document.location = '/pagenew/' + encodeURI(val);
563 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
564 /* ----- RICH TEXT EDITOR ---- */
565 website.RTE = openerp.Widget.extend({
567 id: 'oe_rte_toolbar',
568 className: 'oe_right oe_rte_toolbar',
569 // editor.ui.items -> possible commands &al
570 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
572 init: function (EditorBar) {
573 this.EditorBar = EditorBar;
574 this._super.apply(this, arguments);
578 * In Webkit-based browsers, triple-click will select a paragraph up to
579 * the start of the next "paragraph" including any empty space
580 * inbetween. When said paragraph is removed or altered, it nukes
581 * the empty space and brings part of the content of the next
582 * "paragraph" (which may well be e.g. an image) into the current one,
583 * completely fucking up layouts and breaking snippets.
585 * Try to fuck around with selections on triple-click to attempt to
586 * fix this garbage behavior.
588 * Note: for consistent behavior we may actually want to take over
589 * triple-clicks, in all browsers in order to ensure consistent cross-
590 * platform behavior instead of being at the mercy of rendering engines
591 * & platform selection quirks?
593 webkitSelectionFixer: function (root) {
594 root.addEventListener('click', function (e) {
595 // only webkit seems to have a fucked up behavior, ignore others
596 // FIXME: $.browser goes away in jquery 1.9...
597 if (!$.browser.webkit) { return; }
598 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
599 // The detail attribute indicates the number of times a mouse button has been pressed
600 // we just want the triple click
601 if (e.detail !== 3) { return; }
604 // Get closest block-level element to the triple-clicked
605 // element (using ckeditor's block list because why not)
606 var $closest_block = $(e.target).closest(blocks_selector);
608 // manually set selection range to the content of the
609 // triple-clicked block-level element, to avoid crossing over
610 // between block-level elements
611 document.getSelection().selectAllChildren($closest_block[0]);
614 tableNavigation: function (root) {
616 $(root).on('keydown', function (e) {
618 if (e.which !== 9) { return; }
620 if (self.handleTab(e)) {
626 * Performs whatever operation is necessary on a [TAB] hit, returns
627 * ``true`` if the event's default should be cancelled (if the TAB was
628 * handled by the function)
630 handleTab: function (event) {
631 var forward = !event.shiftKey;
633 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
634 var $cell = $(root).closest('td,th');
636 if (!$cell.length) { return false; }
640 // find cell in same row
641 var row = cell.parentNode;
642 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
644 document.getSelection().selectAllChildren(sibling);
648 // find cell in previous/next row
649 var table = row.parentNode;
650 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
652 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
653 document.getSelection().selectAllChildren(new_cell);
657 // at edge cells, copy word/openoffice behavior: if going backwards
658 // from first cell do nothing, if going forwards from last cell add
661 var row_size = row.cells.length;
662 var new_row = document.createElement('tr');
664 var newcell = document.createElement('td');
666 newcell.textContent = '\u200B';
667 new_row.appendChild(newcell);
669 table.appendChild(new_row);
670 document.getSelection().selectAllChildren(new_row.cells[0]);
676 * Makes the page editable
678 * @param {Boolean} [restart=false] in case the edition was already set
679 * up once and is being re-enabled.
680 * @returns {$.Deferred} deferred indicating when the RTE is ready
682 start_edition: function (restart) {
684 // create a single editor for the whole page
685 var root = document.getElementById('wrapwrap');
687 $(root).on('dragstart', 'img', function (e) {
690 this.webkitSelectionFixer(root);
691 this.tableNavigation(root);
693 var def = $.Deferred();
694 var editor = this.editor = CKEDITOR.inline(root, self._config());
695 editor.on('instanceReady', function () {
696 editor.setReadOnly(false);
697 // ckeditor set root to editable, disable it (only inner
698 // sections are editable)
699 // FIXME: are there cases where the whole editor is editable?
700 editor.editable().setReadOnly(true);
702 self.setup_editables(root);
705 // disable firefox's broken table resizing thing
706 document.execCommand("enableObjectResizing", false, "false");
707 document.execCommand("enableInlineTableEditing", false, "false");
710 self.trigger('rte:ready');
716 setup_editables: function (root) {
717 // selection of editable sub-items was previously in
718 // EditorBar#edit, but for some unknown reason the elements were
719 // apparently removed and recreated (?) at editor initalization,
720 // and observer setup was lost.
722 // setup dirty-marking for each editable element
723 this.fetch_editables(root)
724 .addClass('oe_editable')
728 // only explicitly set contenteditable on view sections,
729 // cke widgets system will do the widgets themselves
730 if ($node.data('oe-model') === 'ir.ui.view') {
731 node.contentEditable = true;
734 observer.observe(node, OBSERVER_CONFIG);
735 $node.one('content_changed', function () {
736 $node.addClass('oe_dirty');
737 self.trigger('change');
742 fetch_editables: function (root) {
743 return $(root).find('[data-oe-model]')
745 .not('.oe_snippet_editor')
746 .filter(function () {
748 // keep view sections and fields which are *not* in
749 // view sections for top-level editables
750 return $this.data('oe-model') === 'ir.ui.view'
751 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
755 _current_editor: function () {
756 return CKEDITOR.currentInstance;
758 _config: function () {
759 // base plugins minus
760 // - magicline (captures mousein/mouseout -> breaks draggable)
761 // - contextmenu & tabletools (disable contextual menu)
762 // - bunch of unused plugins
764 'a11yhelp', 'basicstyles', 'blockquote',
765 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
766 'elementspath', 'enterkey', 'entities', 'filebrowser',
767 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
768 'indentblock', 'indentlist', 'justify',
769 'list', 'pastefromword', 'pastetext', 'preview',
770 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
771 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
776 // Disable auto-generated titles
777 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
779 plugins: plugins.join(','),
781 // FIXME: currently breaks RTE?
782 // Ensure no config file is loaded
785 allowedContent: true,
786 // Don't insert paragraphs around content in e.g. <li>
787 autoParagraph: false,
788 // Don't automatically add or <br> in empty block-level
789 // elements when edition starts
790 fillEmptyBlocks: false,
791 filebrowserImageUploadUrl: "/website/attach",
792 // Support for sharedSpaces in 4.x
793 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
794 // Place toolbar in controlled location
795 sharedSpaces: { top: 'oe_rte_toolbar' },
797 name: 'clipboard', items: [
800 name: 'basicstyles', items: [
801 "Bold", "Italic", "Underline", "Strike", "Subscript",
802 "Superscript", "TextColor", "BGColor", "RemoveFormat"
804 name: 'span', items: [
805 "Link", "Blockquote", "BulletedList",
806 "NumberedList", "Indent", "Outdent"
808 name: 'justify', items: [
809 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
811 name: 'special', items: [
812 "Image", "TableButton"
814 name: 'styles', items: [
815 "Styles", "BootstrapLinkCombo"
818 // styles dropdown in toolbar
820 {name: "Normal", element: 'p'},
821 {name: "Heading 1", element: 'h1'},
822 {name: "Heading 2", element: 'h2'},
823 {name: "Heading 3", element: 'h3'},
824 {name: "Heading 4", element: 'h4'},
825 {name: "Heading 5", element: 'h5'},
826 {name: "Heading 6", element: 'h6'},
827 {name: "Formatted", element: 'pre'},
828 {name: "Address", element: 'address'}
834 website.editor = { };
835 website.editor.Dialog = openerp.Widget.extend({
837 'hidden.bs.modal': 'destroy',
838 'click button.save': 'save',
840 init: function (editor) {
842 this.editor = editor;
845 var sup = this._super();
846 this.$el.modal({backdrop: 'static'});
853 this.$el.modal('hide');
857 website.editor.LinkDialog = website.editor.Dialog.extend({
858 template: 'website.editor.dialog.link',
859 events: _.extend({}, website.editor.Dialog.prototype.events, {
860 'change :input.url-source': function (e) { this.changed($(e.target)); },
861 'mousedown': function (e) {
862 var $target = $(e.target).closest('.list-group-item');
863 if (!$target.length || $target.hasClass('active')) {
864 // clicked outside groups, or clicked in active groups
868 this.changed($target.find('.url-source').filter(':input'));
870 'click button.remove': 'remove_link',
871 'change input#link-text': function (e) {
872 this.text = $(e.target).val()
875 init: function (editor) {
878 // Store last-performed request to be able to cancel/abort it.
883 this.$('#link-page').select2({
884 minimumInputLength: 3,
885 placeholder: _t("New or existing page"),
886 query: function (q) {
887 // FIXME: out-of-order, abort
888 self.fetch_pages(q.term).then(function (results) {
889 var rs = _.map(results, function (r) {
890 return { id: r.url, text: r.name, };
895 text: _.str.sprintf(_t("Create page '%s'"), q.term),
904 return this._super().then(this.proxy('bind_data'));
907 var self = this, _super = this._super.bind(this);
908 var $e = this.$('.list-group-item.active .url-source').filter(':input');
910 if (!val || !$e[0].checkValidity()) {
911 // FIXME: error message
912 $e.closest('.form-group').addClass('has-error');
918 if ($e.hasClass('email-address')) {
919 this.make_link('mailto:' + val, false, val);
920 } else if ($e.hasClass('page')) {
921 var data = $e.select2('data');
923 self.make_link(data.id, false, data.text);
925 // Create the page, get the URL back
926 done = $.get(_.str.sprintf(
927 '/pagenew/%s?noredirect', encodeURI(data.id)))
928 .then(function (response) {
929 self.make_link(response, false, data.id);
933 this.make_link(val, this.$('input.window-new').prop('checked'));
937 make_link: function (url, new_window, label) {
939 bind_data: function (text, href, new_window) {
940 href = href || this.element && (this.element.data( 'cke-saved-href')
941 || this.element.getAttribute('href'));
942 if (!href) { return; }
944 if (new_window === undefined) {
945 new_window = this.element.getAttribute('target') === '_blank';
947 if (text === undefined) {
948 text = this.element.getText();
952 if ((match = /mailto:(.+)/.exec(href))) {
953 $control = this.$('input.email-address').val(match[1]);
956 $control = this.$('input.url').val(href);
959 this.changed($control);
961 this.$('input#link-text').val(text);
962 this.$('input.window-new').prop('checked', new_window);
964 changed: function ($e) {
965 this.$('.url-source').filter(':input').not($e).val('')
966 .filter(function () { return !!$(this).data('select2'); })
967 .select2('data', null);
968 $e.closest('.list-group-item')
970 .siblings().removeClass('active')
971 .addBack().removeClass('has-error');
973 fetch_pages: function (term) {
975 if (this.req) { this.req.abort(); }
976 return this.req = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
978 method: 'search_pages',
982 context: website.get_context()
984 }).done(function () {
985 // request completed successfully -> unstore it
990 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
993 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
994 this.editor.getSelection().selectElement(element);
996 this.element = element;
998 this.add_removal_button();
1001 return this._super();
1003 add_removal_button: function () {
1004 this.$('.modal-footer').prepend(
1005 openerp.qweb.render(
1006 'website.editor.dialog.link.footer-button'));
1008 remove_link: function () {
1009 var editor = this.editor;
1010 // same issue as in make_link
1011 setTimeout(function () {
1012 editor.removeStyle(new CKEDITOR.style({
1014 type: CKEDITOR.STYLE_INLINE,
1015 alwaysRemoveElement: true,
1021 * Greatly simplified version of CKEDITOR's
1022 * plugins.link.dialogs.link.onOk.
1024 * @param {String} url
1025 * @param {Boolean} [new_window=false]
1026 * @param {String} [label=null]
1028 make_link: function (url, new_window, label) {
1029 var attributes = {href: url, 'data-cke-saved-href': url};
1032 attributes['target'] = '_blank';
1034 to_remove.push('target');
1038 this.element.setAttributes(attributes);
1039 this.element.removeAttributes(to_remove);
1040 if (this.text) { this.element.setText(this.text); }
1042 var selection = this.editor.getSelection();
1043 var range = selection.getRanges(true)[0];
1045 if (range.collapsed) {
1046 //noinspection JSPotentiallyInvalidConstructorUsage
1047 var text = new CKEDITOR.dom.text(
1048 this.text || label || url);
1049 range.insertNode(text);
1050 range.selectNodeContents(text);
1053 //noinspection JSPotentiallyInvalidConstructorUsage
1054 new CKEDITOR.style({
1055 type: CKEDITOR.STYLE_INLINE,
1057 attributes: attributes,
1058 }).applyToRange(range);
1060 // focus dance between RTE & dialog blow up the stack in Safari
1061 // and Chrome, so defer select() until dialog has been closed
1062 setTimeout(function () {
1068 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1069 * if the editor is set directly on a link it will thus not work.
1071 get_selected_link: function () {
1072 return get_selected_link(this.editor);
1077 * ImageDialog widget. Lets users change an image, including uploading a
1078 * new image in OpenERP or selecting the image style (if supported by
1081 * Initialized as usual, but the caller can hook into two events:
1083 * @event start({url, style}) called during dialog initialization and
1084 * opening, the handler can *set* the ``url``
1085 * and ``style`` properties on its parameter
1086 * to provide these as default values to the
1088 * @event save({url, style}) called during dialog finalization, the handler
1089 * is provided with the image url and style
1090 * selected by the users (or possibly the ones
1091 * originally passed in)
1093 website.editor.ImageDialog = website.editor.Dialog.extend({
1094 template: 'website.editor.dialog.image',
1095 events: _.extend({}, website.editor.Dialog.prototype.events, {
1096 'change .url-source': function (e) { this.changed($(e.target)); },
1097 'click button.filepicker': function () {
1098 this.$('input[type=file]').click();
1100 'change input[type=file]': 'file_selection',
1101 'change input.url': 'preview_image',
1102 'click a[href=#existing]': 'browse_existing',
1103 'change select.image-style': 'preview_image',
1106 start: function () {
1107 this.$('.modal-footer [disabled]').text("Uploading…");
1108 var $options = this.$('.image-style').children();
1109 this.image_styles = $options.map(function () { return this.value; }).get();
1111 var o = { url: null, style: null, };
1112 // avoid typos, prevent addition of new properties to the object
1113 Object.preventExtensions(o);
1114 this.trigger('start', o);
1118 this.$('.image-style').val(o.style);
1120 this.set_image(o.url);
1123 return this._super();
1126 this.trigger('save', {
1127 url: this.$('input.url').val(),
1128 style: this.$('.image-style').val(),
1130 return this._super();
1134 * Sets the provided image url as the dialog's value-to-save and
1135 * refreshes the preview element to use it.
1137 set_image: function (url) {
1138 this.$('input.url').val(url);
1139 this.preview_image();
1142 file_selection: function () {
1143 this.$el.addClass('nosave');
1144 this.$('button.filepicker').removeClass('btn-danger btn-success');
1147 var callback = _.uniqueId('func_');
1148 this.$('input[name=func]').val(callback);
1150 window[callback] = function (url, error) {
1151 delete window[callback];
1152 self.file_selected(url, error);
1154 this.$('form').submit();
1156 file_selected: function(url, error) {
1157 var $button = this.$('button.filepicker');
1159 $button.addClass('btn-danger');
1162 $button.addClass('btn-success');
1163 this.set_image(url);
1165 preview_image: function () {
1166 this.$el.removeClass('nosave');
1167 var image = this.$('input.url').val();
1168 if (!image) { return; }
1170 this.$('img.image-preview')
1172 .removeClass(this.image_styles.join(' '))
1173 .addClass(this.$('select.image-style').val());
1175 browse_existing: function (e) {
1177 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1180 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1182 this._super.apply(this, arguments);
1184 this.on('start', this, this.proxy('started'));
1185 this.on('save', this, this.proxy('saved'));
1187 started: function (holder) {
1188 var selection = this.editor.getSelection();
1189 var el = selection && selection.getSelectedElement();
1190 this.element = null;
1192 if (el && el.is('img')) {
1194 _(this.image_styles).each(function (style) {
1195 if (el.hasClass(style)) {
1196 holder.style = style;
1199 holder.url = el.getAttribute('src');
1202 saved: function (data) {
1203 var element, editor = this.editor;
1204 if (!(element = this.element)) {
1205 element = editor.document.createElement('img');
1206 element.addClass('img');
1207 element.addClass('img-responsive');
1208 // focus event handler interactions between bootstrap (modal)
1209 // and ckeditor (RTE) lead to blowing the stack in Safari and
1210 // Chrome (but not FF) when this is done synchronously =>
1211 // defer insertion so modal has been hidden & destroyed before
1213 setTimeout(function () {
1214 editor.insertElement(element);
1218 var style = data.style;
1219 element.setAttribute('src', data.url);
1220 element.removeAttribute('data-cke-saved-src');
1221 $(element.$).removeClass(this.image_styles.join(' '));
1222 if (style) { element.addClass(style); }
1226 var IMAGES_PER_ROW = 6;
1227 var IMAGES_ROWS = 4;
1228 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1229 template: 'website.editor.dialog.image.existing',
1230 events: _.extend({}, website.editor.Dialog.prototype.events, {
1231 'click .existing-attachments img': 'select_existing',
1232 'click .pager > li': function (e) {
1234 var $target = $(e.currentTarget);
1235 if ($target.hasClass('disabled')) {
1238 this.page += $target.hasClass('previous') ? -1 : 1;
1239 this.display_attachments();
1242 init: function (parent) {
1245 this.parent = parent;
1246 this._super(parent.editor);
1249 start: function () {
1252 this.fetch_existing().then(this.proxy('fetched_existing')));
1255 fetch_existing: function () {
1256 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1257 model: 'ir.attachment',
1258 method: 'search_read',
1261 fields: ['name', 'website_url'],
1262 domain: [['res_model', '=', 'ir.ui.view']],
1264 context: website.get_context(),
1268 fetched_existing: function (records) {
1269 this.records = records;
1270 this.display_attachments();
1272 display_attachments: function () {
1273 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1275 var from = this.page * per_screen;
1276 var records = this.records;
1278 // Create rows of 3 records
1279 var rows = _(records).chain()
1280 .slice(from, from + per_screen)
1281 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1285 this.$('.existing-attachments').replaceWith(
1286 openerp.qweb.render(
1287 'website.editor.dialog.image.existing.content', {rows: rows}));
1289 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1290 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1293 select_existing: function (e) {
1294 var link = $(e.currentTarget).attr('src');
1296 this.parent.set_image(link);
1302 function get_selected_link(editor) {
1303 var sel = editor.getSelection(),
1304 el = sel.getSelectedElement();
1305 if (el && el.is('a')) { return el; }
1307 var range = sel.getRanges(true)[0];
1308 if (!range) { return null; }
1310 range.shrink(CKEDITOR.SHRINK_TEXT);
1311 var commonAncestor = range.getCommonAncestor();
1312 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1313 return element.data('oe-model') === 'ir.ui.view'
1315 if (!viewRoot) { return null; }
1316 // if viewRoot is the first link, don't edit it.
1317 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1318 .contains('a', true);
1322 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1323 var OBSERVER_CONFIG = {
1326 characterData: true,
1328 attributeOldValue: true,
1330 var observer = new website.Observer(function (mutations) {
1331 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1332 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1333 // will not mark dirty on attribute changes (@class, img/@src,
1335 _(mutations).chain()
1336 .filter(function (m) {
1338 case 'attributes': // ignore .cke_focus being added or removed
1339 // if attribute is not a class, can't be .cke_focus change
1340 if (m.attributeName !== 'class') { return true; }
1342 // find out what classes were added or removed
1343 var oldClasses = (m.oldValue || '').split(/\s+/);
1344 var newClasses = m.target.className.split(/\s+/);
1345 var change = _.union(_.difference(oldClasses, newClasses),
1346 _.difference(newClasses, oldClasses));
1347 // ignore mutation if the *only* change is .cke_focus
1348 return change.length !== 1 || change[0] === 'cke_focus';
1350 // <br type="_moz"> appears when focusing RTE in FF, ignore
1351 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1357 var node = m.target;
1358 while (node && !$(node).hasClass('oe_editable')) {
1359 node = node.parentNode;
1361 $(m.target).trigger('node_changed');
1366 .each(function (node) { $(node).trigger('content_changed'); })