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 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) {
548 window_title: "New Page",
550 }).then(function (val) {
551 document.location = '/pagenew/' + encodeURI(val);
556 var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(',');
557 /* ----- RICH TEXT EDITOR ---- */
558 website.RTE = openerp.Widget.extend({
560 id: 'oe_rte_toolbar',
561 className: 'oe_right oe_rte_toolbar',
562 // editor.ui.items -> possible commands &al
563 // editor.applyStyle(new CKEDITOR.style({element: "span",styles: {color: "#(color)"},overrides: [{element: "font",attributes: {color: null}}]}, {color: '#ff0000'}));
565 init: function (EditorBar) {
566 this.EditorBar = EditorBar;
567 this._super.apply(this, arguments);
571 * In Webkit-based browsers, triple-click will select a paragraph up to
572 * the start of the next "paragraph" including any empty space
573 * inbetween. When said paragraph is removed or altered, it nukes
574 * the empty space and brings part of the content of the next
575 * "paragraph" (which may well be e.g. an image) into the current one,
576 * completely fucking up layouts and breaking snippets.
578 * Try to fuck around with selections on triple-click to attempt to
579 * fix this garbage behavior.
581 * Note: for consistent behavior we may actually want to take over
582 * triple-clicks, in all browsers in order to ensure consistent cross-
583 * platform behavior instead of being at the mercy of rendering engines
584 * & platform selection quirks?
586 webkitSelectionFixer: function (root) {
587 root.addEventListener('click', function (e) {
588 // only webkit seems to have a fucked up behavior, ignore others
589 // FIXME: $.browser goes away in jquery 1.9...
590 if (!$.browser.webkit) { return; }
591 // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents
592 // The detail attribute indicates the number of times a mouse button has been pressed
593 // we just want the triple click
594 if (e.detail !== 3) { return; }
597 // Get closest block-level element to the triple-clicked
598 // element (using ckeditor's block list because why not)
599 var $closest_block = $(e.target).closest(blocks_selector);
601 // manually set selection range to the content of the
602 // triple-clicked block-level element, to avoid crossing over
603 // between block-level elements
604 document.getSelection().selectAllChildren($closest_block[0]);
607 tableNavigation: function (root) {
609 $(root).on('keydown', function (e) {
611 if (e.which !== 9) { return; }
613 if (self.handleTab(e)) {
619 * Performs whatever operation is necessary on a [TAB] hit, returns
620 * ``true`` if the event's default should be cancelled (if the TAB was
621 * handled by the function)
623 handleTab: function (event) {
624 var forward = !event.shiftKey;
626 var root = window.getSelection().getRangeAt(0).commonAncestorContainer;
627 var $cell = $(root).closest('td,th');
629 if (!$cell.length) { return false; }
633 // find cell in same row
634 var row = cell.parentNode;
635 var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)];
637 document.getSelection().selectAllChildren(sibling);
641 // find cell in previous/next row
642 var table = row.parentNode;
643 var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)];
645 var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1];
646 document.getSelection().selectAllChildren(new_cell);
650 // at edge cells, copy word/openoffice behavior: if going backwards
651 // from first cell do nothing, if going forwards from last cell add
654 var row_size = row.cells.length;
655 var new_row = document.createElement('tr');
657 var newcell = document.createElement('td');
659 newcell.textContent = '\u200B';
660 new_row.appendChild(newcell);
662 table.appendChild(new_row);
663 document.getSelection().selectAllChildren(new_row.cells[0]);
669 * Makes the page editable
671 * @param {Boolean} [restart=false] in case the edition was already set
672 * up once and is being re-enabled.
673 * @returns {$.Deferred} deferred indicating when the RTE is ready
675 start_edition: function (restart) {
677 // create a single editor for the whole page
678 var root = document.getElementById('wrapwrap');
680 $(root).on('dragstart', 'img', function (e) {
683 this.webkitSelectionFixer(root);
684 this.tableNavigation(root);
686 var def = $.Deferred();
687 var editor = this.editor = CKEDITOR.inline(root, self._config());
688 editor.on('instanceReady', function () {
689 editor.setReadOnly(false);
690 // ckeditor set root to editable, disable it (only inner
691 // sections are editable)
692 // FIXME: are there cases where the whole editor is editable?
693 editor.editable().setReadOnly(true);
695 self.setup_editables(root);
697 // disable firefox's broken table resizing thing
698 document.execCommand("enableObjectResizing", false, "false");
699 document.execCommand("enableInlineTableEditing", false, "false");
701 self.trigger('rte:ready');
707 setup_editables: function (root) {
708 // selection of editable sub-items was previously in
709 // EditorBar#edit, but for some unknown reason the elements were
710 // apparently removed and recreated (?) at editor initalization,
711 // and observer setup was lost.
713 // setup dirty-marking for each editable element
714 this.fetch_editables(root)
715 .addClass('oe_editable')
719 // only explicitly set contenteditable on view sections,
720 // cke widgets system will do the widgets themselves
721 if ($node.data('oe-model') === 'ir.ui.view') {
722 node.contentEditable = true;
725 observer.observe(node, OBSERVER_CONFIG);
726 $node.one('content_changed', function () {
727 $node.addClass('oe_dirty');
728 self.trigger('change');
733 fetch_editables: function (root) {
734 return $(root).find('[data-oe-model]')
736 .not('.oe_snippet_editor')
737 .filter(function () {
739 // keep view sections and fields which are *not* in
740 // view sections for top-level editables
741 return $this.data('oe-model') === 'ir.ui.view'
742 || !$this.closest('[data-oe-model = "ir.ui.view"]').length;
746 _current_editor: function () {
747 return CKEDITOR.currentInstance;
749 _config: function () {
750 // base plugins minus
751 // - magicline (captures mousein/mouseout -> breaks draggable)
752 // - contextmenu & tabletools (disable contextual menu)
753 // - bunch of unused plugins
755 'a11yhelp', 'basicstyles', 'blockquote',
756 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab',
757 'elementspath', 'enterkey', 'entities', 'filebrowser',
758 'find', 'floatingspace','format', 'htmlwriter', 'iframe',
759 'indentblock', 'indentlist', 'justify',
760 'list', 'pastefromword', 'pastetext', 'preview',
761 'removeformat', 'resize', 'save', 'selectall', 'stylescombo',
762 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea'
767 // Disable auto-generated titles
768 // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label
770 plugins: plugins.join(','),
772 // FIXME: currently breaks RTE?
773 // Ensure no config file is loaded
776 allowedContent: true,
777 // Don't insert paragraphs around content in e.g. <li>
778 autoParagraph: false,
779 // Don't automatically add or <br> in empty block-level
780 // elements when edition starts
781 fillEmptyBlocks: false,
782 filebrowserImageUploadUrl: "/website/attach",
783 // Support for sharedSpaces in 4.x
784 extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo',
785 // Place toolbar in controlled location
786 sharedSpaces: { top: 'oe_rte_toolbar' },
788 name: 'clipboard', items: [
791 name: 'basicstyles', items: [
792 "Bold", "Italic", "Underline", "Strike", "Subscript",
793 "Superscript", "TextColor", "BGColor", "RemoveFormat"
795 name: 'span', items: [
796 "Link", "Blockquote", "BulletedList",
797 "NumberedList", "Indent", "Outdent"
799 name: 'justify', items: [
800 "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"
802 name: 'special', items: [
803 "Image", "TableButton"
805 name: 'styles', items: [
806 "Styles", "BootstrapLinkCombo"
809 // styles dropdown in toolbar
811 {name: "Normal", element: 'p'},
812 {name: "Heading 1", element: 'h1'},
813 {name: "Heading 2", element: 'h2'},
814 {name: "Heading 3", element: 'h3'},
815 {name: "Heading 4", element: 'h4'},
816 {name: "Heading 5", element: 'h5'},
817 {name: "Heading 6", element: 'h6'},
818 {name: "Formatted", element: 'pre'},
819 {name: "Address", element: 'address'}
825 website.editor = { };
826 website.editor.Dialog = openerp.Widget.extend({
828 'hidden.bs.modal': 'destroy',
829 'click button.save': 'save',
831 init: function (editor) {
833 this.editor = editor;
836 var sup = this._super();
837 this.$el.modal({backdrop: 'static'});
844 this.$el.modal('hide');
848 website.editor.LinkDialog = website.editor.Dialog.extend({
849 template: 'website.editor.dialog.link',
850 events: _.extend({}, website.editor.Dialog.prototype.events, {
851 'change :input.url-source': function (e) { this.changed($(e.target)); },
852 'mousedown': function (e) {
853 var $target = $(e.target).closest('.list-group-item');
854 if (!$target.length || $target.hasClass('active')) {
855 // clicked outside groups, or clicked in active groups
859 this.changed($target.find('.url-source').filter(':input'));
861 'click button.remove': 'remove_link',
862 'change input#link-text': function (e) {
863 this.text = $(e.target).val()
866 init: function (editor) {
869 // Store last-performed request to be able to cancel/abort it.
874 this.$('#link-page').select2({
875 minimumInputLength: 3,
876 placeholder: _t("New or existing page"),
877 query: function (q) {
878 // FIXME: out-of-order, abort
879 self.fetch_pages(q.term).then(function (results) {
880 var rs = _.map(results, function (r) {
881 return { id: r.url, text: r.name, };
886 text: _.str.sprintf(_t("Create page '%s'"), q.term),
895 return this._super().then(this.proxy('bind_data'));
898 var self = this, _super = this._super.bind(this);
899 var $e = this.$('.list-group-item.active .url-source').filter(':input');
901 if (!val || !$e[0].checkValidity()) {
902 // FIXME: error message
903 $e.closest('.form-group').addClass('has-error');
909 if ($e.hasClass('email-address')) {
910 this.make_link('mailto:' + val, false, val);
911 } else if ($e.hasClass('page')) {
912 var data = $e.select2('data');
914 self.make_link(data.id, false, data.text);
916 // Create the page, get the URL back
917 done = $.get(_.str.sprintf(
918 '/pagenew/%s?noredirect', encodeURI(data.id)))
919 .then(function (response) {
920 self.make_link(response, false, data.id);
924 this.make_link(val, this.$('input.window-new').prop('checked'));
928 make_link: function (url, new_window, label) {
930 bind_data: function (text, href, new_window) {
931 href = href || this.element && (this.element.data( 'cke-saved-href')
932 || this.element.getAttribute('href'));
933 if (!href) { return; }
935 if (new_window === undefined) {
936 new_window = this.element.getAttribute('target') === '_blank';
938 if (text === undefined) {
939 text = this.element.getText();
943 if ((match = /mailto:(.+)/.exec(href))) {
944 $control = this.$('input.email-address').val(match[1]);
947 $control = this.$('input.url').val(href);
950 this.changed($control);
952 this.$('input#link-text').val(text);
953 this.$('input.window-new').prop('checked', new_window);
955 changed: function ($e) {
956 this.$('.url-source').filter(':input').not($e).val('')
957 .filter(function () { return !!$(this).data('select2'); })
958 .select2('data', null);
959 $e.closest('.list-group-item')
961 .siblings().removeClass('active')
962 .addBack().removeClass('has-error');
964 fetch_pages: function (term) {
966 if (this.req) { this.req.abort(); }
967 return this.req = openerp.jsonRpc('/web/dataset/call_kw', 'call', {
969 method: 'search_pages',
973 context: website.get_context()
975 }).done(function () {
976 // request completed successfully -> unstore it
981 website.editor.RTELinkDialog = website.editor.LinkDialog.extend({
984 if ((element = this.get_selected_link()) && element.hasAttribute('href')) {
985 this.editor.getSelection().selectElement(element);
987 this.element = element;
989 this.add_removal_button();
992 return this._super();
994 add_removal_button: function () {
995 this.$('.modal-footer').prepend(
997 'website.editor.dialog.link.footer-button'));
999 remove_link: function () {
1000 var editor = this.editor;
1001 // same issue as in make_link
1002 setTimeout(function () {
1003 editor.removeStyle(new CKEDITOR.style({
1005 type: CKEDITOR.STYLE_INLINE,
1006 alwaysRemoveElement: true,
1012 * Greatly simplified version of CKEDITOR's
1013 * plugins.link.dialogs.link.onOk.
1015 * @param {String} url
1016 * @param {Boolean} [new_window=false]
1017 * @param {String} [label=null]
1019 make_link: function (url, new_window, label) {
1020 var attributes = {href: url, 'data-cke-saved-href': url};
1023 attributes['target'] = '_blank';
1025 to_remove.push('target');
1029 this.element.setAttributes(attributes);
1030 this.element.removeAttributes(to_remove);
1031 if (this.text) { this.element.setText(this.text); }
1033 var selection = this.editor.getSelection();
1034 var range = selection.getRanges(true)[0];
1036 if (range.collapsed) {
1037 //noinspection JSPotentiallyInvalidConstructorUsage
1038 var text = new CKEDITOR.dom.text(
1039 this.text || label || url);
1040 range.insertNode(text);
1041 range.selectNodeContents(text);
1044 //noinspection JSPotentiallyInvalidConstructorUsage
1045 new CKEDITOR.style({
1046 type: CKEDITOR.STYLE_INLINE,
1048 attributes: attributes,
1049 }).applyToRange(range);
1051 // focus dance between RTE & dialog blow up the stack in Safari
1052 // and Chrome, so defer select() until dialog has been closed
1053 setTimeout(function () {
1059 * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root,
1060 * if the editor is set directly on a link it will thus not work.
1062 get_selected_link: function () {
1063 return get_selected_link(this.editor);
1068 * ImageDialog widget. Lets users change an image, including uploading a
1069 * new image in OpenERP or selecting the image style (if supported by
1072 * Initialized as usual, but the caller can hook into two events:
1074 * @event start({url, style}) called during dialog initialization and
1075 * opening, the handler can *set* the ``url``
1076 * and ``style`` properties on its parameter
1077 * to provide these as default values to the
1079 * @event save({url, style}) called during dialog finalization, the handler
1080 * is provided with the image url and style
1081 * selected by the users (or possibly the ones
1082 * originally passed in)
1084 website.editor.ImageDialog = website.editor.Dialog.extend({
1085 template: 'website.editor.dialog.image',
1086 events: _.extend({}, website.editor.Dialog.prototype.events, {
1087 'change .url-source': function (e) { this.changed($(e.target)); },
1088 'click button.filepicker': function () {
1089 this.$('input[type=file]').click();
1091 'change input[type=file]': 'file_selection',
1092 'change input.url': 'preview_image',
1093 'click a[href=#existing]': 'browse_existing',
1094 'change select.image-style': 'preview_image',
1097 start: function () {
1098 this.$('.modal-footer [disabled]').text("Uploading…");
1099 var $options = this.$('.image-style').children();
1100 this.image_styles = $options.map(function () { return this.value; }).get();
1102 var o = { url: null, style: null, };
1103 // avoid typos, prevent addition of new properties to the object
1104 Object.preventExtensions(o);
1105 this.trigger('start', o);
1109 this.$('.image-style').val(o.style);
1111 this.set_image(o.url);
1114 return this._super();
1117 this.trigger('save', {
1118 url: this.$('input.url').val(),
1119 style: this.$('.image-style').val(),
1121 return this._super();
1125 * Sets the provided image url as the dialog's value-to-save and
1126 * refreshes the preview element to use it.
1128 set_image: function (url) {
1129 this.$('input.url').val(url);
1130 this.preview_image();
1133 file_selection: function () {
1134 this.$el.addClass('nosave');
1135 this.$('button.filepicker').removeClass('btn-danger btn-success');
1138 var callback = _.uniqueId('func_');
1139 this.$('input[name=func]').val(callback);
1141 window[callback] = function (url, error) {
1142 delete window[callback];
1143 self.file_selected(url, error);
1145 this.$('form').submit();
1147 file_selected: function(url, error) {
1148 var $button = this.$('button.filepicker');
1150 $button.addClass('btn-danger');
1153 $button.addClass('btn-success');
1154 this.set_image(url);
1156 preview_image: function () {
1157 this.$el.removeClass('nosave');
1158 var image = this.$('input.url').val();
1159 if (!image) { return; }
1161 this.$('img.image-preview')
1163 .removeClass(this.image_styles.join(' '))
1164 .addClass(this.$('select.image-style').val());
1166 browse_existing: function (e) {
1168 new website.editor.ExistingImageDialog(this).appendTo(document.body);
1171 website.editor.RTEImageDialog = website.editor.ImageDialog.extend({
1173 this._super.apply(this, arguments);
1175 this.on('start', this, this.proxy('started'));
1176 this.on('save', this, this.proxy('saved'));
1178 started: function (holder) {
1179 var selection = this.editor.getSelection();
1180 var el = selection && selection.getSelectedElement();
1181 this.element = null;
1183 if (el && el.is('img')) {
1185 _(this.image_styles).each(function (style) {
1186 if (el.hasClass(style)) {
1187 holder.style = style;
1190 holder.url = el.getAttribute('src');
1193 saved: function (data) {
1194 var element, editor = this.editor;
1195 if (!(element = this.element)) {
1196 element = editor.document.createElement('img');
1197 element.addClass('img');
1198 // focus event handler interactions between bootstrap (modal)
1199 // and ckeditor (RTE) lead to blowing the stack in Safari and
1200 // Chrome (but not FF) when this is done synchronously =>
1201 // defer insertion so modal has been hidden & destroyed before
1203 setTimeout(function () {
1204 editor.insertElement(element);
1208 var style = data.style;
1209 element.setAttribute('src', data.url);
1210 element.removeAttribute('data-cke-saved-src');
1211 $(element.$).removeClass(this.image_styles.join(' '));
1212 if (style) { element.addClass(style); }
1216 var IMAGES_PER_ROW = 6;
1217 var IMAGES_ROWS = 4;
1218 website.editor.ExistingImageDialog = website.editor.Dialog.extend({
1219 template: 'website.editor.dialog.image.existing',
1220 events: _.extend({}, website.editor.Dialog.prototype.events, {
1221 'click .existing-attachments img': 'select_existing',
1222 'click .pager > li': function (e) {
1224 var $target = $(e.currentTarget);
1225 if ($target.hasClass('disabled')) {
1228 this.page += $target.hasClass('previous') ? -1 : 1;
1229 this.display_attachments();
1232 init: function (parent) {
1235 this.parent = parent;
1236 this._super(parent.editor);
1239 start: function () {
1242 this.fetch_existing().then(this.proxy('fetched_existing')));
1245 fetch_existing: function () {
1246 return openerp.jsonRpc('/web/dataset/call_kw', 'call', {
1247 model: 'ir.attachment',
1248 method: 'search_read',
1251 fields: ['name', 'website_url'],
1252 domain: [['res_model', '=', 'ir.ui.view']],
1254 context: website.get_context(),
1258 fetched_existing: function (records) {
1259 this.records = records;
1260 this.display_attachments();
1262 display_attachments: function () {
1263 var per_screen = IMAGES_PER_ROW * IMAGES_ROWS;
1265 var from = this.page * per_screen;
1266 var records = this.records;
1268 // Create rows of 3 records
1269 var rows = _(records).chain()
1270 .slice(from, from + per_screen)
1271 .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); })
1275 this.$('.existing-attachments').replaceWith(
1276 openerp.qweb.render(
1277 'website.editor.dialog.image.existing.content', {rows: rows}));
1279 .find('li.previous').toggleClass('disabled', (from === 0)).end()
1280 .find('li.next').toggleClass('disabled', (from + per_screen >= records.length));
1283 select_existing: function (e) {
1284 var link = $(e.currentTarget).attr('src');
1286 this.parent.set_image(link);
1292 function get_selected_link(editor) {
1293 var sel = editor.getSelection(),
1294 el = sel.getSelectedElement();
1295 if (el && el.is('a')) { return el; }
1297 var range = sel.getRanges(true)[0];
1298 if (!range) { return null; }
1300 range.shrink(CKEDITOR.SHRINK_TEXT);
1301 var commonAncestor = range.getCommonAncestor();
1302 var viewRoot = editor.elementPath(commonAncestor).contains(function (element) {
1303 return element.data('oe-model') === 'ir.ui.view'
1305 if (!viewRoot) { return null; }
1306 // if viewRoot is the first link, don't edit it.
1307 return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot)
1308 .contains('a', true);
1312 website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver;
1313 var OBSERVER_CONFIG = {
1316 characterData: true,
1318 attributeOldValue: true,
1320 var observer = new website.Observer(function (mutations) {
1321 // NOTE: Webkit does not fire DOMAttrModified => webkit browsers
1322 // relying on JsMutationObserver shim (Chrome < 18, Safari < 6)
1323 // will not mark dirty on attribute changes (@class, img/@src,
1325 _(mutations).chain()
1326 .filter(function (m) {
1328 case 'attributes': // ignore .cke_focus being added or removed
1329 // if attribute is not a class, can't be .cke_focus change
1330 if (m.attributeName !== 'class') { return true; }
1332 // find out what classes were added or removed
1333 var oldClasses = (m.oldValue || '').split(/\s+/);
1334 var newClasses = m.target.className.split(/\s+/);
1335 var change = _.union(_.difference(oldClasses, newClasses),
1336 _.difference(newClasses, oldClasses));
1337 // ignore mutation if the *only* change is .cke_focus
1338 return change.length !== 1 || change[0] === 'cke_focus';
1340 // <br type="_moz"> appears when focusing RTE in FF, ignore
1341 return m.addedNodes.length !== 1 || m.addedNodes[0].nodeName !== 'BR';
1347 var node = m.target;
1348 while (node && !$(node).hasClass('oe_editable')) {
1349 node = node.parentNode;
1351 $(m.target).trigger('node_changed');
1356 .each(function (node) { $(node).trigger('content_changed'); })